Compare commits
123 Commits
94569a4c45
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc7de1098 | |||
| b9aea81d14 | |||
| b60a511cb6 | |||
| ad81f150a5 | |||
| 06b6717eb9 | |||
| a8d1c5dfca | |||
| dfe621362d | |||
| 25a8be6e23 | |||
| 44fd423161 | |||
| f688229c95 | |||
| 9de96f5851 | |||
| d018b9eedf | |||
| 5064dc2b93 | |||
| ca5adbf4a4 | |||
| 6f98c0a84d | |||
| 81c9a5d692 | |||
| afc28c64cf | |||
| c1da59574f | |||
| 5ecda580f7 | |||
| bf834dae7f | |||
| e578c41485 | |||
| 96de7e714d | |||
| 795f1d9bab | |||
| 7f29931f93 | |||
| 6bcf4c96e4 | |||
| 4f5d7796c1 | |||
| 5eaca69f78 | |||
| d2dae3d042 | |||
| d82550610a | |||
| 15cb30311e | |||
| 1b4fba3c19 | |||
| f7c7394a94 | |||
| 690964c994 | |||
| 17b1348f5d | |||
| 0431ae6887 | |||
| 9fc023da9d | |||
| ffd306859f | |||
| 4869e3c97a | |||
| ed81f73f13 | |||
| a5e33b70df | |||
| a087c3d1e5 | |||
| dec696b16e | |||
| d57f9b9b65 | |||
| 970c8164e0 | |||
| cb15ae1d24 | |||
| 6bf36f34f0 | |||
| 3656ee9a59 | |||
| e8091a0391 | |||
| a52fd0db8f | |||
| ae33aa4def | |||
| 3e73077b29 | |||
| 6e415b3e45 | |||
| 696c2ade15 | |||
| c024087c94 | |||
| d0bf5fdd50 | |||
| f6b8badd12 | |||
| 833b590afb | |||
| ce980b6eff | |||
| 4dc70a6fc6 | |||
| 57dfb3a3aa | |||
| 1dc5bc3391 | |||
| 76e6fa5e69 | |||
| ae6454ed37 | |||
| 2afcf487a1 | |||
| 0bc2ef3b98 | |||
| 726ed77b31 | |||
| 2a89d52634 | |||
| 6958714021 | |||
| 52677c606a | |||
| 96191b2d7c | |||
| 5b29854251 | |||
| 597e6504e1 | |||
| b15cbbb1b6 | |||
| dacd01e6b9 | |||
| a57ac23064 | |||
| ecc1ab0954 | |||
| d8dcf682c4 | |||
| 86f020182a | |||
| d29fdac4a0 | |||
| be762e1ee8 | |||
| 1664fbda09 | |||
| 3c64a4604f | |||
| 29f37a1642 | |||
| e1804ad181 | |||
| 6fdc2593be | |||
| 9bc31d23f5 | |||
| 0d1e8b3c2d | |||
| f8874b2aea | |||
| da694266d4 | |||
| 1bf1f1405b | |||
| e0834b1275 | |||
| 5acf7db27c | |||
| 76c7bcc62b | |||
| 9453474c69 | |||
| f924c25f16 | |||
| 7d89a664aa | |||
| 50ec52ab6e | |||
| 78e7e68bb0 | |||
| fd84e17f0b | |||
| a6d52c9725 | |||
| cc9028ac3d | |||
| 47b5eab3ff | |||
| 7f42c40efc | |||
| d34bedcb4c | |||
| 5f7e66c220 | |||
| 6040d5fd7f | |||
| dd719f5b2e | |||
| e91b7feada | |||
| ac098faeea | |||
| 6e5aabc94c | |||
| 69d17f787a | |||
| 435e6fb1bc | |||
| 2d2895c9a4 | |||
| 36665ec308 | |||
| 2dd92d025f | |||
| 66be5105a8 | |||
| c274a8f5e7 | |||
| 8fd7f83586 | |||
| 3e30612b38 | |||
| eab52ca424 | |||
| e634cdedba | |||
| 192c8a8c8c | |||
| a6721e6536 |
3
.gitignore
vendored
@@ -26,3 +26,6 @@ dist-ssr
|
||||
|
||||
# Superpowers visual companion (mockup files)
|
||||
.superpowers/
|
||||
|
||||
# git worktrees (subagent-driven 격리 작업)
|
||||
.worktrees/
|
||||
|
||||
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
109
CHECK_POINT.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# web-ui CHECK_POINT
|
||||
|
||||
> React 18 + Vite + react-router-dom v6. Dev port 3007. NAS Docker 백엔드의 프론트엔드 (nginx :8080).
|
||||
> 2026-05-22 갱신.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 현재 상태 (양호)
|
||||
|
||||
- 라우트 16개 (12 메인 + 4 서브) 정상 운영
|
||||
- agent-office 3×3 그리드 재설계 완료 (5/7~14, WebP 93% 축소, WS 재연결 백오프)
|
||||
- `/insta` 슬레이트 캐러셀 + 반응형 (5/15~16)
|
||||
- Vite proxy 7개 (NAS API + Fear&Greed + VIX + Treasury + WTI + Brent)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 최근 완료 (5/18~22)
|
||||
|
||||
- 2026-05-22: **거래 데스크 AI 투자 탭 제거** (e42b643) — web-ai signal_v1 legacy 이전과 정합 (V2 단독 운영 반영)
|
||||
- 2026-05-22: stock 총 매입을 각 종목 매입가 단순 합으로 표시 (6533743)
|
||||
- 2026-05-22: agent-office 모바일 사이드패널 전체화면 토글 + music 에이전트 이미지 교체 (ee5700d)
|
||||
- 2026-05-14: agent-office Grid 재설계 (canvas 폐기), AGENT_META + GRID_SLOTS 중앙화
|
||||
- 2026-05-15~16: `/insta` 신규 페이지 + InstaCards.jsx + src/api.js(530줄) 음악·인스타·텔레그램 API 확장
|
||||
|
||||
---
|
||||
|
||||
## 🔴 즉시 (1~3일)
|
||||
|
||||
### 1. `/insta` 비동기 폴링 구현 ⭐ (백엔드 준비 완료 → 구현 시점 도래)
|
||||
- **배경**: web-backend insta-lab이 Redis 분할(SP-4) 완료 → `_bg_render`가 Redis push, `GET /api/insta/tasks/{task_id}` 폴링 엔드포인트 존재. **이제 frontend가 비동기 폴링으로 전환해야 정합**
|
||||
- **파일**: `src/pages/insta/InstaCards.jsx`
|
||||
- [ ] 슬레이트 생성 → `task_id` 받고 폴링 (2~5초 간격, NAS 부담 ↓)
|
||||
- [ ] progress bar UI (0~100%) + `queue:paused` 상태 표시 (박재오 작업 중 = Windows 워커 정지)
|
||||
- [ ] failed 상태 처리 (오류 메시지·재시도 버튼)
|
||||
|
||||
### 2. agent-office WebSocket 안정성 점검
|
||||
- 5/7~14 재설계 + 5/22 모바일 토글 직후 운영 확인
|
||||
- [ ] 브라우저 콘솔 WS 끊김 → 재연결 지수 백오프 실제 작동
|
||||
- [ ] 4 테스트(TaskTab·CommandTab·AgentCard·ScoreNodeCard) 통과 재확인
|
||||
|
||||
### 3. agent-office lotto sim_consensus 노출
|
||||
- **배경**: web-backend `/api/lotto/best`에 5종 점수 array 노출됨 (lotto-signals) + weight-evolver 자율 학습 도입
|
||||
- [ ] agent-office lotto 에이전트 카드에 5종 점수·시그널 상태 표시
|
||||
- [ ] (선택) weight-evolver 진화 상태 미니 패널
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중기 (1~2주)
|
||||
|
||||
### 4. `/insta` 카드 템플릿 UI 고도화
|
||||
- 현재 default theme PNG 미리보기만. hedgy75 테마 추가 시 theme 선택 UI 필요
|
||||
- [ ] 테마 선택 dropdown (default / hedgy75)
|
||||
- [ ] 미리보기 컴포넌트 페이지 종류별 분기
|
||||
|
||||
### 5. `/music` Sonic Forge 발행 모니터링
|
||||
- music-lab Redis 분할(SP-6) + Windows music-render 도입 → 발행 상태 모니터링 패널 필요
|
||||
- [ ] 발행 큐·실패·재시도 로그 표시 (Redis 큐 길이 연동)
|
||||
- [ ] 텔레그램 5단계 승인 UX 점검
|
||||
|
||||
### 6. NAS↔Windows 작업 흐름 가시화 (신규)
|
||||
- web-ai 워커 3종 + Redis 큐 도입으로 작업 분산 흐름이 복잡해짐
|
||||
- [ ] agent-office 또는 신규 `/node` 페이지에 큐 상태·Windows 노드 헬스 표시 (web-ai/web-backend 추가 아이디어와 연동)
|
||||
|
||||
---
|
||||
|
||||
## 🟢 장기 (1개월+)
|
||||
|
||||
### 7. 모바일 UX 일관 적용
|
||||
- BottomNav + PullToRefresh + MobileSheet + SwipeableView 있음. 신규 페이지 적용 부족
|
||||
- [ ] `/insta` 모바일 캐러셀 swipe + `/agent-office` 모바일 그리드 압축
|
||||
|
||||
### 8. `/lab` 페이지 확장
|
||||
- 현재 sword-stream · day-calc 2개
|
||||
- [ ] 박재오 데모 콘텐츠 큐 결정 (예: weight-evolver 진화 그래프, AI 음악 빠른 청취)
|
||||
|
||||
---
|
||||
|
||||
## 💡 추가 아이디어 (신규 2026-05-22)
|
||||
|
||||
- **`/node` Windows AI 노드 대시보드** — ai_trade + insta/music/video-render + task-watcher 상태, Redis 큐 길이, `queue:paused` 토글 버튼(task-watcher C안 = "토글 UI 1개"). web-ai/web-backend 모니터링 아이디어의 frontend 진입점
|
||||
- **video 생성 미리보기 페이지** — video-lab(SP-8) + Windows video-render 4 provider 결과 비교 그리드. 무신사 공모전 MU-진 영상 버전 관리에 활용
|
||||
- **weight-evolver 진화 시각화** — auto_picks 적중 추이 + weight base diff 그래프 (`/lab` 또는 lotto 페이지)
|
||||
- **위키 페이지 수 정합** — [[사업-개인-웹-플랫폼]]에 "17개" 박혀 있으나 실제 16개 (12 메인 + 4 서브). *박재오 위키 갱신 항목* (web-ui 코드 아님)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빌드 & 배포
|
||||
|
||||
```bash
|
||||
npm run dev # 개발 (port 3007, Vite proxy)
|
||||
npm run build # 빌드 (rimraf dist + Vite build)
|
||||
npm run release:nas # 자동 배포 (deploy-nas.cjs)
|
||||
```
|
||||
|
||||
배포: Windows `robocopy dist Z:\docker\webpage\frontend\` / macOS `rsync` → nginx 자동 reload
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고
|
||||
|
||||
- 위키: [[사업-개인-웹-플랫폼]] (백엔드 통합 인덱스)
|
||||
- 라우트: `src/routes.jsx` (navLinks 메타) / Vite 프록시: `vite.config.js`
|
||||
- API: 모든 페이지 `/api/` 상대 경로 (Mixed Content 방지)
|
||||
- 백엔드 짝: web-backend CHECK_POINT (insta-lab Redis 분할 → /insta 비동기 폴링 정합 필요)
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-05-18: 페이지 신설. 즉시 3 + 중기 3 + 장기 2.
|
||||
- 2026-05-22: 최근 완료 3건 반영(AI 투자 탭 제거·stock 매입 표시·모바일 사이드패널). **`/insta` 비동기 폴링을 즉시 1순위로 승격** (백엔드 insta-lab Redis 분할 완료 → frontend 정합 필요). lotto sim_consensus 노출 + NAS↔Windows 작업 흐름 가시화 항목 추가. 추가 아이디어 4건 신설 (/node 대시보드·video 미리보기·evolver 시각화·위키 페이지 수 정합).
|
||||
188
CLAUDE.md
@@ -16,19 +16,30 @@
|
||||
| `/blog` | `Blog` | 마크다운 기반 블로그 |
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)<br>• **(모듈 분해: shell(67줄) + components/10 + subscriptionUtils)** |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드<br>• **(모듈 분해: shell(185줄) + components/11 + hooks/usePollTask + instaUtils. Cards/Trends 2탭)** |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
|
||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
|
||||
| `/saju/result?rid=N` | `SajuResult` | 사주 풀이 결과 (4탭: Basic/Chart/Flow/Traits) |
|
||||
| `/saju/today?rid=N` | `Today` | 오늘의 운세 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings) |
|
||||
| `/saju/compatibility` | `Compatibility` | 궁합 입력 (두 사람 폼) |
|
||||
| `/saju/compatibility/result?cid=N` | `CompatibilityResult` | 궁합 점수 + 요약 + strengths/challenges |
|
||||
| `/saju/me` | `SajuMe` | 마이페이지 placeholder ("곧 만나요" + 4 비활성 카드) |
|
||||
| `/tarot` | `Tarot` | 타로 메인 (agent-office에서 분리, tarot-lab API) |
|
||||
| `/tarot/today` | `TarotTodayCard` | 오늘의 카드 (one_card spread) |
|
||||
| `/tarot/reading` | `TarotReading` | 멀티 카드 스프레드 리딩 (three_card 등) |
|
||||
| `/tarot/history` | `TarotHistory` | 리딩 이력 조회 |
|
||||
|
||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||
|
||||
@@ -92,6 +103,10 @@ proxy: {
|
||||
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
|
||||
| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } |
|
||||
| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } |
|
||||
| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` |
|
||||
| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } |
|
||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||
@@ -124,10 +139,33 @@ proxy: {
|
||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
||||
| 부동산-매물 | GET | `/api/realestate/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` — { listings: [...] } |
|
||||
| 부동산-매물 | POST/GET | `/api/realestate/listings/collect`, `/collect/status` |
|
||||
| 부동산-매물 | GET/PUT | `/api/realestate/listings/criteria` (부분 업데이트) |
|
||||
| 부동산-매물 | GET | `/api/realestate/listings/matches` — { matches: [...] } |
|
||||
| 부동산-안전마진 | POST | `/api/realestate/safety-check` — body { area, deal_type, amount(만원), dong? } → { median, ratio, tier, sample, is_toheo, disclaimer } |
|
||||
| 부동산-예산 | POST | `/api/realestate/budget` — body { equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? } → { jeonse, purchase, region, disclaimer } |
|
||||
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
||||
| 사주 | POST | `/api/saju/interpret` — body: `{ year, month, day, hour, gender, calendar_type, is_leap_month? }` → reading_id + saju_data + analysis + fortune_scores + lucky + monthly_flow |
|
||||
| 사주 | GET | `/api/saju/readings/:id` — 저장된 사주 조회 (`useSajuReading` hook 사용) |
|
||||
| 사주 | GET | `/api/saju/current-fortune?reading_id=N` — 현재 연도 세운 |
|
||||
| 사주 | PATCH/DELETE | `/api/saju/readings/:id` — 즐겨찾기·메모 / 삭제 |
|
||||
| 사주 | GET | `/api/saju/readings?page=1&size=20&favorite=bool` — 목록 |
|
||||
| 궁합 | POST | `/api/saju/compat/interpret` — body: `{ person_a, person_b }` → compat_id + score + interpretation |
|
||||
| 궁합 | GET | `/api/saju/compat/readings/:id` — 궁합 결과 |
|
||||
| 궁합 | PATCH/DELETE | `/api/saju/compat/readings/:id` |
|
||||
| 타로 | POST | `/api/tarot/interpret` — body: `{ spread_type, category, question, cards }` → interpretation_json (DB 저장 X) |
|
||||
| 타로 | POST | `/api/tarot/readings` — 확정 후 저장 |
|
||||
| 타로 | GET | `/api/tarot/readings?page=1&spread_type=X&category=Y` — 목록 |
|
||||
| 타로 | GET/PATCH/DELETE | `/api/tarot/readings/:id` |
|
||||
| 영상 생성 | POST | `/api/video/generate` — body: `{ provider, prompt, params }` → task_id (sora/veo/kling/seedance) |
|
||||
| 영상 생성 | GET | `/api/video/tasks/:id`, `/api/video/providers` |
|
||||
| 이미지 생성 | POST | `/api/image/generate` — body: `{ provider, prompt, params }` → task_id (gpt_image/nano_banana/flux) |
|
||||
| 이미지 생성 | GET | `/api/image/tasks/:id`, `/api/image/providers` |
|
||||
| 에이전트 로그 | GET | `/api/agent-office/agents/:id/logs?limit=50` — DB agent_logs + 컨테이너 `/logs/recent` 병합 |
|
||||
|
||||
---
|
||||
|
||||
@@ -205,6 +243,41 @@ npm run preview # 빌드 결과물 미리보기
|
||||
|
||||
---
|
||||
|
||||
## 기능 모듈 구조 (컨벤션)
|
||||
|
||||
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`, `src/pages/realestate/`, `src/pages/insta/`).
|
||||
|
||||
```
|
||||
src/pages/<feature>/
|
||||
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||
├── <Feature>.css # 스타일 (shell에서 1회 import)
|
||||
├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임
|
||||
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||
```
|
||||
|
||||
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
|
||||
|
||||
**중첩 적용** — 기존 기능의 대형 하위 컴포넌트도 같은 규칙으로 in-place 분해한다. 예: `src/pages/stock/components/PortfolioTab.jsx`(671줄)는 74줄 shell + `stock/components/portfolio/` 하위 모듈로 분해됨:
|
||||
|
||||
```
|
||||
src/pages/stock/components/
|
||||
├── PortfolioTab.jsx # shell(74줄): 에러 + 관리 section 조합 + CashPanel + brokerGroups.map(BrokerSection) + 빈 상태
|
||||
└── portfolio/
|
||||
├── portfolioUtils.js # formatPriceTime (순수) + portfolioUtils.test.js
|
||||
├── PriceSessionBadge.jsx # NXT 세션 뱃지 + PriceSessionBadge.test.jsx
|
||||
├── AddHoldingForm.jsx # ({ pf }) 종목 추가 폼 (addFormOpen 가드)
|
||||
├── PortfolioSummary.jsx # ({ pf }) 총매입/평가/손익 요약 카드
|
||||
├── AssetHistoryChart.jsx # ({ asset, pf, handleSaveSnapshot }) recharts 자산 추이
|
||||
├── CashPanel.jsx # ({ pf }) 예수금 테이블(인라인 편집/삭제) + 추가 폼
|
||||
├── BrokerSection.jsx # ({ broker, items, pf, handleSell }) 브로커 섹션 + HoldingRow 반복
|
||||
└── HoldingRow.jsx # ({ item, pf, handleSell }) 보유 1행 (view/편집/매도확인/삭제확인 4상태)
|
||||
```
|
||||
|
||||
`pf`(usePortfolio 훅 객체 전체)를 props로 스레딩하는 behavior-preserving 순수 추출. 각 하위는 실제 참조하는 `pf.*`/`asset.*` 심볼만 사용.
|
||||
|
||||
---
|
||||
|
||||
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
||||
|
||||
### 파일 구조
|
||||
@@ -332,6 +405,102 @@ handleGenerate()
|
||||
|
||||
---
|
||||
|
||||
## 호령 사주 v2 — `/saju` 라우트 트리
|
||||
|
||||
2026-05-27 풀 리디자인 (Phase 1-6, 30 commits). v1 `components/` + `Saju.css` 일괄 삭제 후 신규 디자인 시스템 도입.
|
||||
|
||||
### 디자인 컨셉
|
||||
|
||||
한국 전통 명리학 미학 + 호령 캐릭터. Inter/Roboto 같은 generic AI sans 회피.
|
||||
- **타이포**: Nanum Myeongjo (display, weight 800) + Nanum Gothic (body) + Gowun Batang (fallback serif). `index.html` head에서 preconnect + link 일괄 로드 (기존 Noto Serif KR도 v1 호환 유지)
|
||||
- **컬러**: navy `#1F2A44` dominant + gold `#D4AF37` accent + ivory `#F7F2E8` paper. 화면별 단일 accent (홈=navy, 오늘=gold, 궁합=green, 사주풀이=purple, 마이=gray)
|
||||
- **차별화 요소**: `OrnateFrame` (4 코너 꺽쇠 + double border), `MascotBubble` (paw-bob 2.4s 애니메이션), `OrnamentBloom` (꽃봉오리 SVG), `mt-wash` (산수화 SVG 데스크탑 배경)
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
src/pages/saju/
|
||||
├── _shell/ # 디자인 시스템 + 네비
|
||||
│ ├── tokens.css # CSS 변수 (.saju-v2 scope)
|
||||
│ ├── shell.css # paper-bg/night-bg/mt-wash/screenIn/paw-bob
|
||||
│ ├── useViewportMode.js # 1024px breakpoint hook
|
||||
│ ├── BottomNav.jsx # 모바일 5항목
|
||||
│ ├── DesktopHeader.jsx # 데스크탑 헤더 nav
|
||||
│ ├── Mascot.jsx # 7 variant 매핑 (full/head/upper/greeting/thinking/pointing/happy)
|
||||
│ ├── MascotBubble.jsx # 4 tone (ivory/navy/green/purple)
|
||||
│ ├── OrnateFrame.jsx
|
||||
│ ├── OrnamentBloom.jsx
|
||||
│ ├── TopRibbon.jsx
|
||||
│ ├── TitleBlock.jsx
|
||||
│ ├── PrimaryButton.jsx
|
||||
│ ├── GhostButton.jsx
|
||||
│ ├── InputRow.jsx
|
||||
│ ├── Icons.jsx # 5 nav + IconPaw/Chevron/Sparkle
|
||||
│ └── helpers/
|
||||
│ ├── hexA.js # hex + alpha → rgba
|
||||
│ ├── daeunLabel.js # 나이 → 8 인생 단계 label
|
||||
│ ├── deriveTraits.js # element_scores → 6 성향
|
||||
│ └── colorMap.js # 오행 한자 → CSS var + 한글/한자
|
||||
├── views/ # mobile/desktop 컴포넌트 분리
|
||||
│ ├── home.{mobile,desktop}.jsx
|
||||
│ ├── saju.{mobile,desktop}.jsx # 4탭 (Basic/Chart/Flow/Traits)
|
||||
│ ├── today.{mobile,desktop}.jsx
|
||||
│ └── match.{mobile,desktop}.jsx
|
||||
├── hooks/
|
||||
│ ├── useSajuForm.js # 폼 상태 (year/month/day/hour/gender/calendar_type, handleChange(field,value) 콜백)
|
||||
│ └── useSajuReading.js # rid 기반 { data, loading, error }
|
||||
├── Saju.jsx # /saju 진입 router
|
||||
├── SajuResult.jsx # /saju/result 진입 (Empty/Loading/Error state)
|
||||
├── Today.jsx
|
||||
├── Compatibility.jsx
|
||||
├── CompatibilityResult.jsx
|
||||
└── Me.jsx # placeholder
|
||||
```
|
||||
|
||||
### 응답 schema 매핑 (saju-lab → view)
|
||||
|
||||
`useSajuReading(rid).data` 구조:
|
||||
- `saju_data.{year,month,day,hour}` 각 `{stem, stem_kr, branch, branch_kr, ten_god, fortune}` (4기둥)
|
||||
- `analysis_data.element_scores` (한자 키 `木/火/土/金/水`) — view에서 `wood/fire/earth/metal/water`로 매핑 (`HANJA_TO_ID`)
|
||||
- `analysis_data.day_master_strength.{result, score, reasons}` (신강신약)
|
||||
- `daeun_data` (8개): `{age, start_year, end_year, stem, branch, stem_kr, branch_kr}` — 현재 판정 `start_year ≤ currentYear ≤ end_year`
|
||||
- `interpretation_json.{summary, items: [{key,title,content,evidence}], advice}`
|
||||
- `fortune_scores.{wealth, romance, social, career, overall}` (0-100)
|
||||
- `lucky.{color: string[], number, direction, good_signs: string[], warnings: string[]}`
|
||||
|
||||
### 반응형 전략
|
||||
|
||||
1024px breakpoint로 모바일/데스크탑 컴포넌트 트리 완전 분리:
|
||||
- 모바일 (< 1024): `night-bg` 또는 `paper-bg`, BottomNav 하단 fixed + safe-area
|
||||
- 데스크탑 (≥ 1024): `mt-wash` 산수화 배경, DesktopHeader sticky top, content max-width 1200px
|
||||
|
||||
### 호령 자산
|
||||
|
||||
`public/images/saju/horyung/` 7 PNG (horyung-main/bust/front/greeting/thinking/pointing/happy). Mascot variant API가 매핑:
|
||||
- `full` → horyung-main, `head` → horyung-bust, `upper` → horyung-front, 나머지는 1:1
|
||||
|
||||
---
|
||||
|
||||
## 타로 — `/tarot` 라우트 트리
|
||||
|
||||
agent-office에서 독립 라우트로 분리 (백엔드는 `tarot-lab` 컨테이너).
|
||||
|
||||
| 경로 | 컴포넌트 | 백엔드 |
|
||||
|------|----------|--------|
|
||||
| `/tarot` | `Tarot` | tarot-lab `/api/tarot/interpret` |
|
||||
| `/tarot/today` | `TarotTodayCard` | one_card spread |
|
||||
| `/tarot/reading` | `TarotReading` | three_card spread + 멀티 |
|
||||
| `/tarot/history` | `TarotHistory` | `/api/tarot/readings` 목록 |
|
||||
|
||||
해석 흐름 (interpret ↔ save 분리):
|
||||
1. 사용자가 카드 배치 → `POST /api/tarot/interpret` → Claude 응답 (DB 저장 X)
|
||||
2. 사용자 확정 또는 reroll 결정
|
||||
3. 확정 후 `POST /api/tarot/readings` → DB 저장 + reading_id 반환
|
||||
|
||||
`useTarotReading(id)` + `useTarotShuffle()` hook (`src/pages/tarot/hooks/`).
|
||||
|
||||
---
|
||||
|
||||
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
||||
|
||||
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
||||
@@ -372,3 +541,14 @@ web-ui → POST /api/music/generate (NAS music-lab)
|
||||
```
|
||||
|
||||
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
||||
|
||||
---
|
||||
|
||||
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **FE**
|
||||
|
||||
이 세션은 프론트엔드(FE) 역할이다. co-gahusb MCP 툴로 다른 세션(BE/AI/Producer)과 협업한다.
|
||||
- **소유권**: 이 세션은 `web-ui` repo만 쓴다(BE=web-backend, AI=web-ai).
|
||||
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "FE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||
- **모든 툴 호출에 `role="FE"`** (또는 `from_role`/`created_by`에 FE).
|
||||
- **수신**: `/loop`로 주기적으로 `read_inbox("FE", after_id=<last>)` + `list_tasks(assignee_role="FE")` 확인.
|
||||
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지).
|
||||
|
||||
17
README.md
@@ -64,14 +64,15 @@
|
||||
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||
|
||||
### Stock Trade (`/stock/trade`) — 7 컴포넌트
|
||||
### Stock Trade (`/stock/trade`) — 8 컴포넌트
|
||||
|
||||
포트폴리오 관리 및 트레이딩 데스크.
|
||||
포트폴리오 관리 및 트레이딩 데스크 (5탭).
|
||||
|
||||
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이 + AI 코치
|
||||
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
|
||||
- **보유종목 인텔 탭**: 스크리너 엔진 기반 기술분석·매도룰 신호 (어드바이저리)
|
||||
- **관심종목 탭**: 관심종목 CRUD + 실시간 매매 시그널 알림 이력 (매수/매도 시그널, 1D/7D/30D 필터) — 실시간 매매 알림 파이프라인(BE 엔드포인트 + web-ai `trade-monitor` 워커) 연동
|
||||
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
|
||||
- 매도 히스토리 드로어 (실현손익 추적)
|
||||
|
||||
@@ -221,3 +222,11 @@ apiPost('/api/travel/sync');
|
||||
| 공통 컴포넌트 | 10개 |
|
||||
| API 헬퍼 함수 | 65+ |
|
||||
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
||||
|
||||
---
|
||||
|
||||
### 기능 모듈 구조
|
||||
|
||||
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`, `realestate`, `insta`)
|
||||
|
||||
기존 기능의 대형 하위 컴포넌트도 같은 규칙으로 in-place 분해합니다. 예: `stock/components/PortfolioTab.jsx`(671줄) → 74줄 shell + `stock/components/portfolio/`(AddHoldingForm·PortfolioSummary·AssetHistoryChart·CashPanel·BrokerSection·HoldingRow·PriceSessionBadge + portfolioUtils).
|
||||
|
||||
642
docs/superpowers/plans/2026-05-25-ai-trade-hotfix.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# ai_trade Hotfix — Code Review F1·F2·F3·F4 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** ai_trade(V2) 코드 리뷰 7개 finding 중 High 3건(F1·F2·F3) + Medium 1건(F4)을 TDD로 수정. F5/F6은 별도 plan, F7은 pushback.
|
||||
|
||||
**Architecture:** 모두 ai_trade/ 내부 단일 모듈 수정. (1) config.py default 경로 — legacy/ 경유. (2) kis_client.py — asyncio.Lock으로 `_throttle()` 직렬화. (3) scheduler.py + pull_worker.py — post-close를 시간 윈도우가 아닌 "일 1회 + 16:00 이후" 상태기반으로 변경. (4) chronos_predictor.py — confidence 산식을 absolute spread 기반으로 통일.
|
||||
|
||||
**Tech Stack:** Python 3.12, asyncio, pytest + pytest-asyncio + respx, httpx.
|
||||
|
||||
**Test runner:** `.venv` 한글 경로 깨짐 + 리뷰어 Python 312 경로 부재 보고로, 시스템 Python 사용. 정확한 경로는 `where python` 으로 우선 확인. 기본 시도 순서:
|
||||
1. `python -m pytest ai_trade/tests -q` (PATH의 Python)
|
||||
2. `py -3.12 -m pytest ai_trade/tests -q` (py launcher)
|
||||
3. 둘 다 실패 시 환경 셋업이 선행 작업 — plan 진행 중단하고 박재오에게 보고.
|
||||
|
||||
**Working directory:** `C:\Users\jaeoh\Desktop\workspace\web-ai` (web-ai repo). Commit/push도 이 디렉토리에서만.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 파일 | 변경 종류 | 책임 |
|
||||
|------|-----------|------|
|
||||
| `ai_trade/config.py` | Modify L31-36 | V1_TOKEN_PATH default를 `legacy/signal_v1/data/kis_token.json`로 |
|
||||
| `ai_trade/kis_client.py` | Modify L40-62 | `_throttle_lock: asyncio.Lock` 추가, `_throttle()`을 lock 안에서 실행 |
|
||||
| `ai_trade/scheduler.py` | Modify L79-84 | `_is_post_close_trigger(now, last_post_close_date)` 시그니처 변경 — 상태기반 |
|
||||
| `ai_trade/pull_worker.py` | Modify L1-58 | `poll_loop`에 `last_post_close_date` state 추가, 호출부 갱신 |
|
||||
| `ai_trade/chronos_predictor.py` | Modify L106, L127 | spread 계산을 absolute (q90-q10)로, confidence 산식 `max(0, 1 - spread/0.6)` |
|
||||
| `ai_trade/tests/test_kis_client.py` | Add 1 test | concurrent gather throttle test |
|
||||
| `ai_trade/tests/test_scheduler.py` | Add 3 tests | post-close 상태기반 트리거 |
|
||||
| `ai_trade/tests/test_pull_worker.py` | Add 1 test | 첫 호출 안 됐다가 16:00 이후 5분 cycle에서 호출됨 |
|
||||
| `ai_trade/tests/test_chronos_predictor.py` | Add 2 tests | median≈0에서도 conf 정상, spread 클수록 conf↓ |
|
||||
| `ai_trade/tests/test_main.py` | Modify | v1_token_path default 변경 반영 (있다면) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: F1 — KIS 토큰 경로 default를 legacy/ 경유로
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/config.py:31-36`
|
||||
- Test: `ai_trade/tests/test_config_token_path.py` (Create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# ai_trade/tests/test_config_token_path.py
|
||||
"""F1 — V1_TOKEN_PATH default가 legacy/signal_v1/ 경유인지 검증."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ai_trade.config import Settings
|
||||
|
||||
|
||||
def test_v1_token_default_path_uses_legacy_dir(monkeypatch):
|
||||
"""env에 V1_TOKEN_PATH 없으면 legacy/signal_v1/data/kis_token.json"""
|
||||
monkeypatch.delenv("V1_TOKEN_PATH", raising=False)
|
||||
settings = Settings()
|
||||
expected_suffix = Path("legacy") / "signal_v1" / "data" / "kis_token.json"
|
||||
assert str(settings.v1_token_path).endswith(str(expected_suffix)), (
|
||||
f"expected default to end with {expected_suffix}, got {settings.v1_token_path}"
|
||||
)
|
||||
|
||||
|
||||
def test_v1_token_env_override_wins(monkeypatch, tmp_path):
|
||||
"""env로 명시한 경로가 default를 덮어씀."""
|
||||
custom = tmp_path / "custom_token.json"
|
||||
monkeypatch.setenv("V1_TOKEN_PATH", str(custom))
|
||||
settings = Settings()
|
||||
assert settings.v1_token_path == custom
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_config_token_path.py -v
|
||||
```
|
||||
|
||||
Expected: `test_v1_token_default_path_uses_legacy_dir` FAILs (default가 `signal_v1/...` 임). env override는 PASS.
|
||||
|
||||
- [ ] **Step 3: Fix config.py**
|
||||
|
||||
`ai_trade/config.py:31-36` 변경:
|
||||
|
||||
```python
|
||||
v1_token_path: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.getenv("V1_TOKEN_PATH",
|
||||
str(Path(__file__).parent.parent / "legacy" / "signal_v1" / "data" / "kis_token.json"))
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_config_token_path.py -v
|
||||
```
|
||||
|
||||
Expected: 2 passed.
|
||||
|
||||
- [ ] **Step 5: Verify full test suite still passes**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -q
|
||||
```
|
||||
|
||||
Expected: 모든 기존 테스트 PASS (token path 기본값 변경이 다른 test에 영향 없는지 확인).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/config.py ai_trade/tests/test_config_token_path.py
|
||||
git commit -m "fix(ai_trade): V1_TOKEN_PATH default를 legacy/signal_v1/ 경유로 수정 (F1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: F2 — KIS throttle을 asyncio.Lock으로 직렬화
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/kis_client.py:40-62`
|
||||
- Test: `ai_trade/tests/test_kis_client.py` (Modify — 새 test 추가)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`ai_trade/tests/test_kis_client.py` 파일 끝에 추가:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import time as time_module
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_throttle_serializes_concurrent_gather(kis_client_factory):
|
||||
"""5개 동시 요청이 asyncio.gather로 들어와도 0.5초 간격으로 직렬화되어야 함 (F2).
|
||||
|
||||
초당 2회 = 0.5초 간격. 5개 요청이면 최소 (5-1)*0.5 = 2.0초 소요.
|
||||
Race condition 있으면 5개가 거의 동시에 나가서 2초 훨씬 안쪽에 끝남.
|
||||
"""
|
||||
sample = {"output2": []}
|
||||
respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
).mock(return_value=httpx.Response(200, json=sample))
|
||||
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
start = time_module.monotonic()
|
||||
await asyncio.gather(*[client.get_minute_ohlcv(f"00593{i}") for i in range(5)])
|
||||
elapsed = time_module.monotonic() - start
|
||||
# 5개 throttle = 최소 (5-1)*0.5 = 2.0초. tolerance 0.3초.
|
||||
assert elapsed >= 1.7, (
|
||||
f"throttle race condition: 5 concurrent calls took only {elapsed:.2f}s, "
|
||||
f"expected >=1.7s (0.5s * 4 inter-call gaps)"
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_kis_client.py::test_throttle_serializes_concurrent_gather -v
|
||||
```
|
||||
|
||||
Expected: FAIL — elapsed가 0.5초 이하 (race로 동시 깸).
|
||||
|
||||
- [ ] **Step 3: Add asyncio.Lock to KISClient**
|
||||
|
||||
`ai_trade/kis_client.py:40` `__init__` 끝에 한 줄 추가:
|
||||
|
||||
```python
|
||||
self._token_cache: tuple[str, float] | None = None # (token, file_mtime)
|
||||
self._last_throttle_at = 0.0
|
||||
self._throttle_lock = asyncio.Lock()
|
||||
```
|
||||
|
||||
그리고 `_throttle()` (L58-62)을 lock으로 감쌈:
|
||||
|
||||
```python
|
||||
async def _throttle(self) -> None:
|
||||
async with self._throttle_lock:
|
||||
elapsed = time.monotonic() - self._last_throttle_at
|
||||
if elapsed < _THROTTLE_INTERVAL:
|
||||
await asyncio.sleep(_THROTTLE_INTERVAL - elapsed)
|
||||
self._last_throttle_at = time.monotonic()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_kis_client.py::test_throttle_serializes_concurrent_gather -v
|
||||
```
|
||||
|
||||
Expected: PASS — elapsed >= 1.7s.
|
||||
|
||||
- [ ] **Step 5: Verify full kis_client suite still passes**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_kis_client.py -v
|
||||
```
|
||||
|
||||
Expected: 모든 test PASS (기존 429 retry 등 영향 없는지 확인).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/kis_client.py ai_trade/tests/test_kis_client.py
|
||||
git commit -m "fix(ai_trade): KIS throttle을 asyncio.Lock으로 직렬화 (F2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: F3 — post-close 트리거를 상태기반으로 변경
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/scheduler.py:79-84`
|
||||
- Modify: `ai_trade/pull_worker.py:1-58`
|
||||
- Test: `ai_trade/tests/test_scheduler.py` (add 3 tests)
|
||||
|
||||
**Why state-based:** 16:00:00-16:00:59 윈도우는 5분 sleep + 비결정적 cycle 시작 시각과 충돌. "오늘 아직 post-close 안 돌렸고 현재 시각 ≥ 16:00 이면 trigger 후 today 표시" 로 변경.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`ai_trade/tests/test_scheduler.py` 파일 끝에 추가:
|
||||
|
||||
```python
|
||||
from datetime import date as _date
|
||||
from ai_trade.scheduler import _is_post_close_trigger
|
||||
|
||||
|
||||
def test_post_close_trigger_fires_at_1601_if_not_yet_today():
|
||||
"""16:01에 깬 cycle도 오늘 아직 안 돌렸으면 trigger (F3)."""
|
||||
now = _kst(2026, 5, 18, 16, 1)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is True
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_if_already_today():
|
||||
"""이미 오늘 돌렸으면 trigger 안 함."""
|
||||
now = _kst(2026, 5, 18, 16, 5)
|
||||
today = _date(2026, 5, 18)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=today) is False
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_before_1600():
|
||||
"""16:00 전에는 trigger 안 함."""
|
||||
now = _kst(2026, 5, 18, 15, 59)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||
|
||||
|
||||
def test_post_close_trigger_fires_next_day_after_reset():
|
||||
"""다음 영업일이 되면 last_post_close_date < today.date() 이므로 다시 trigger."""
|
||||
now = _kst(2026, 5, 19, 16, 0)
|
||||
yesterday = _date(2026, 5, 18)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=yesterday) is True
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_on_holiday():
|
||||
"""휴장일에는 trigger 안 함 (2026-05-05 어린이날)."""
|
||||
now = _kst(2026, 5, 5, 16, 30)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_scheduler.py -v -k post_close
|
||||
```
|
||||
|
||||
Expected: FAIL — `_is_post_close_trigger`가 신규 시그니처(`last_post_close_date` 인자) 미지원.
|
||||
|
||||
- [ ] **Step 3: Modify scheduler.py:79-84**
|
||||
|
||||
```python
|
||||
def _is_post_close_trigger(now: datetime, last_post_close_date) -> bool:
|
||||
"""16:00 KST 이후 오늘 아직 post-close cycle 안 돌렸으면 True (F3 상태기반).
|
||||
|
||||
Args:
|
||||
now: 현재 KST datetime.
|
||||
last_post_close_date: 마지막 post-close 실행 영업일 date 객체 (None=미실행).
|
||||
"""
|
||||
if not _is_market_day(now):
|
||||
return False
|
||||
if now.time() < time(16, 0):
|
||||
return False
|
||||
today = now.date()
|
||||
return last_post_close_date != today
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run scheduler tests**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_scheduler.py -v
|
||||
```
|
||||
|
||||
Expected: 신규 5개 PASS. 기존 test도 PASS (다른 함수 영향 없음).
|
||||
|
||||
- [ ] **Step 5: Update pull_worker.py to track last_post_close_date**
|
||||
|
||||
`ai_trade/pull_worker.py` 의 `poll_loop` (L18-58)을 다음으로 교체:
|
||||
|
||||
```python
|
||||
async def poll_loop(
|
||||
client: StockClient, state: PollState, shutdown: asyncio.Event,
|
||||
kis_client: KISClient | None = None,
|
||||
chronos=None,
|
||||
dedup=None,
|
||||
settings=None,
|
||||
) -> None:
|
||||
"""FastAPI lifespan 에서 asyncio.create_task 로 시작."""
|
||||
logger.info("poll_loop started")
|
||||
last_post_close_date = None
|
||||
while not shutdown.is_set():
|
||||
now = datetime.now(KST)
|
||||
if _is_market_day(now) and _is_polling_window(now):
|
||||
try:
|
||||
await _run_polling_cycle(client, state, kis_client=kis_client)
|
||||
except Exception:
|
||||
logger.exception("poll cycle failed")
|
||||
# Minute momentum 갱신 (매 cycle)
|
||||
try:
|
||||
update_minute_momentum_for_all(state)
|
||||
except Exception:
|
||||
logger.exception("minute momentum update failed")
|
||||
# Post-close trigger (상태기반: 16:00 이후 + 오늘 미실행)
|
||||
if (
|
||||
_is_post_close_trigger(now, last_post_close_date)
|
||||
and chronos is not None and kis_client is not None
|
||||
):
|
||||
try:
|
||||
await _run_post_close_cycle(kis_client, chronos, state)
|
||||
last_post_close_date = now.date()
|
||||
except Exception:
|
||||
logger.exception("post-close cycle failed")
|
||||
# Phase 4: generate signals
|
||||
if dedup is not None and settings is not None:
|
||||
try:
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
generate_signals(state, dedup, settings)
|
||||
except Exception:
|
||||
logger.exception("generate_signals failed")
|
||||
interval = _next_interval(now)
|
||||
try:
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=interval)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
logger.info("poll_loop ended")
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add pull_worker test**
|
||||
|
||||
`ai_trade/tests/test_pull_worker.py` 파일 끝에 추가:
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
import asyncio as _asyncio
|
||||
|
||||
|
||||
async def test_post_close_fires_at_1601_when_not_yet_today(monkeypatch):
|
||||
"""16:01에 깬 cycle도 post_close 안 돌렸으면 호출됨 (F3 회귀)."""
|
||||
from ai_trade import pull_worker
|
||||
|
||||
_kst = _ZI("Asia/Seoul")
|
||||
now_at_1601 = _dt(2026, 5, 18, 16, 1, tzinfo=_kst)
|
||||
|
||||
real_dt = _dt
|
||||
|
||||
class FrozenDateTime:
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
return now_at_1601
|
||||
|
||||
monkeypatch.setattr(pull_worker, "datetime", FrozenDateTime)
|
||||
monkeypatch.setattr(
|
||||
pull_worker, "_is_market_day", lambda n: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pull_worker, "_is_polling_window", lambda n: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pull_worker, "_next_interval", lambda n: 0.01,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pull_worker, "_run_polling_cycle", AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pull_worker, "update_minute_momentum_for_all", lambda s: None,
|
||||
)
|
||||
post_close = AsyncMock()
|
||||
monkeypatch.setattr(pull_worker, "_run_post_close_cycle", post_close)
|
||||
|
||||
state = MagicMock()
|
||||
chronos = MagicMock()
|
||||
kis = MagicMock()
|
||||
shutdown = _asyncio.Event()
|
||||
|
||||
async def _stop_soon():
|
||||
await _asyncio.sleep(0.05)
|
||||
shutdown.set()
|
||||
|
||||
_asyncio.create_task(_stop_soon())
|
||||
await pull_worker.poll_loop(
|
||||
client=MagicMock(),
|
||||
state=state,
|
||||
shutdown=shutdown,
|
||||
kis_client=kis,
|
||||
chronos=chronos,
|
||||
dedup=None,
|
||||
settings=None,
|
||||
)
|
||||
|
||||
assert post_close.await_count >= 1, "post-close가 16:01에 호출되지 않음 (F3 회귀)"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run pull_worker test**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_pull_worker.py::test_post_close_fires_at_1601_when_not_yet_today -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Run full ai_trade suite**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -q
|
||||
```
|
||||
|
||||
Expected: 모두 PASS.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/scheduler.py ai_trade/pull_worker.py ai_trade/tests/test_scheduler.py ai_trade/tests/test_pull_worker.py
|
||||
git commit -m "fix(ai_trade): post-close trigger를 상태기반으로 변경 (F3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: F4 — Chronos confidence를 absolute spread 기반으로 통일
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/chronos_predictor.py:106, 127`
|
||||
- Test: `ai_trade/tests/test_chronos_predictor.py` (add 2 tests)
|
||||
|
||||
**Why absolute:** Phase 4 spec amendment (web-ui commit 534ded5)가 absolute spread로 hard gate를 결정. confidence도 같은 철학으로. 새 산식: `conf = max(0, min(1, 1 - spread / SPREAD_THRESHOLD))` — spread가 0.6에 도달하면 conf=0, 0이면 conf=1.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
기존 `ai_trade/tests/test_chronos_predictor.py` 끝에 추가 (파일이 없거나 비어있으면 신규):
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_pipeline():
|
||||
"""predict_quantiles만 stub하는 가짜 pipeline."""
|
||||
class FakePipeline:
|
||||
def __init__(self, q10_price, q50_price, q90_price):
|
||||
self._q10, self._q50, self._q90 = q10_price, q50_price, q90_price
|
||||
def predict_quantiles(self, contexts, prediction_length, quantile_levels):
|
||||
n = len(contexts)
|
||||
tensor = torch.tensor(
|
||||
[[[self._q10, self._q50, self._q90]]] * n,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
return tensor, None
|
||||
return FakePipeline
|
||||
|
||||
|
||||
def _make_predictor_with(pipeline_obj):
|
||||
"""ChronosPredictor 인스턴스 (실제 모델 안 부르고 pipeline만 주입)."""
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
p = ChronosPredictor.__new__(ChronosPredictor)
|
||||
p._pipeline = pipeline_obj
|
||||
p._device = "cpu"
|
||||
return p
|
||||
|
||||
|
||||
def test_confidence_high_when_spread_near_zero(fake_pipeline):
|
||||
"""median≈0, spread≈0 (q10=q90=last_close)일 때 conf≈1 (F4)."""
|
||||
last_close = 100000.0
|
||||
p = _make_predictor_with(fake_pipeline(last_close, last_close, last_close))
|
||||
ohlcv = {"A": [{"close": last_close}] * 30}
|
||||
out = p.predict_batch(ohlcv)
|
||||
assert out["A"].conf > 0.95, (
|
||||
f"median≈0 + spread≈0인데 conf={out['A'].conf} (F4 회귀: relative spread로 폭증)"
|
||||
)
|
||||
|
||||
|
||||
def test_confidence_drops_with_spread(fake_pipeline):
|
||||
"""spread 0.3일 때 conf≈0.5 (1 - 0.3/0.6 = 0.5)."""
|
||||
last_close = 100000.0
|
||||
# q10=85000 → -0.15, q90=115000 → 0.15, spread=0.30
|
||||
p = _make_predictor_with(fake_pipeline(85000.0, 100000.0, 115000.0))
|
||||
ohlcv = {"A": [{"close": last_close}] * 30}
|
||||
out = p.predict_batch(ohlcv)
|
||||
# 1 - 0.30/0.60 = 0.50
|
||||
assert 0.45 < out["A"].conf < 0.55, (
|
||||
f"absolute spread 0.30에서 conf={out['A'].conf} (expected ≈0.5)"
|
||||
)
|
||||
|
||||
|
||||
def test_confidence_zero_at_threshold_spread(fake_pipeline):
|
||||
"""spread가 threshold(0.6) 이상이면 conf=0."""
|
||||
last_close = 100000.0
|
||||
# q10=70000 → -0.30, q90=130000 → 0.30, spread=0.60
|
||||
p = _make_predictor_with(fake_pipeline(70000.0, 100000.0, 130000.0))
|
||||
ohlcv = {"A": [{"close": last_close}] * 30}
|
||||
out = p.predict_batch(ohlcv)
|
||||
assert out["A"].conf < 0.05, (
|
||||
f"spread=threshold에서 conf={out['A'].conf} (expected ≈0)"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_chronos_predictor.py -v -k confidence
|
||||
```
|
||||
|
||||
Expected: `test_confidence_high_when_spread_near_zero` 가 FAIL — 현행 relative spread 산식 때문에 median≈0에서 conf가 0으로 폭락.
|
||||
|
||||
- [ ] **Step 3: Fix chronos_predictor.py**
|
||||
|
||||
`ai_trade/chronos_predictor.py` 상단에 상수 추가 (L13 근처):
|
||||
|
||||
```python
|
||||
_SPREAD_THRESHOLD = 0.6 # F4: signal_generator hard gate와 동일 (absolute return spread)
|
||||
```
|
||||
|
||||
L106 (modern API 경로) 변경:
|
||||
|
||||
```python
|
||||
# shape: [num_series, prediction_length, 3]
|
||||
for i, ticker in enumerate(tickers):
|
||||
q10_price, q50_price, q90_price = quantiles_np[i, 0, :]
|
||||
last_close = daily_ohlcv_dict[ticker][-1]["close"]
|
||||
median = float((q50_price - last_close) / last_close)
|
||||
q10 = float((q10_price - last_close) / last_close)
|
||||
q90 = float((q90_price - last_close) / last_close)
|
||||
# F4: absolute spread (q90-q10) 기반 — signal_generator hard gate와 통일.
|
||||
# median≈0 zero-shot 케이스에서 conf가 0으로 폭락하던 relative 산식 제거.
|
||||
spread = q90 - q10
|
||||
conf = float(max(0.0, min(1.0, 1.0 - spread / _SPREAD_THRESHOLD)))
|
||||
results[ticker] = ChronosPrediction(
|
||||
median=median, q10=q10, q90=q90, conf=conf, as_of=now_iso,
|
||||
)
|
||||
return results
|
||||
```
|
||||
|
||||
L127 (legacy API 경로) 동일하게 변경:
|
||||
|
||||
```python
|
||||
spread = q90 - q10
|
||||
conf = float(max(0.0, min(1.0, 1.0 - spread / _SPREAD_THRESHOLD)))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_chronos_predictor.py -v
|
||||
```
|
||||
|
||||
Expected: 신규 3개 모두 PASS. 기존 test도 PASS.
|
||||
|
||||
- [ ] **Step 5: Run full ai_trade suite**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -q
|
||||
```
|
||||
|
||||
Expected: 모두 PASS. signal_generator 테스트(`_compute_buy_confidence` 가 `pred["conf"]` 사용) 도 영향 받을 수 있으니 주시.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/chronos_predictor.py ai_trade/tests/test_chronos_predictor.py
|
||||
git commit -m "fix(ai_trade): Chronos confidence를 absolute spread 기반으로 통일 (F4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 전체 회귀 확인 + push
|
||||
|
||||
- [ ] **Step 1: Run full ai_trade suite + count**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -v
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 기존 56 tests + 신규 (config 2 + kis_client 1 + scheduler 5 + pull_worker 1 + chronos_predictor 3) = **68 tests** 정도 PASS.
|
||||
|
||||
- [ ] **Step 2: Quick sanity — server boot smoke test (시간 허용 시)**
|
||||
|
||||
```
|
||||
cd ai_trade && python -c "from main import app; print('app import OK')"
|
||||
```
|
||||
|
||||
Expected: no import errors.
|
||||
|
||||
- [ ] **Step 3: Push**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
이 plan을 다 작성한 뒤 다음을 확인:
|
||||
|
||||
1. **F1**: config.py default + 2 test (default + env override) ✅
|
||||
2. **F2**: `_throttle_lock` 추가 + 1 concurrent test ✅
|
||||
3. **F3**: `_is_post_close_trigger(now, last_post_close_date)` 시그니처 변경 + `poll_loop` 상태 추적 + 5 scheduler test + 1 pull_worker test ✅
|
||||
4. **F4**: `_SPREAD_THRESHOLD=0.6` 상수 + 두 분기(modern + legacy) 모두 absolute spread 적용 + 3 chronos_predictor test ✅
|
||||
|
||||
**누락 가능 항목**:
|
||||
- `test_main.py` 가 `v1_token_path` default를 직접 검증한다면 Task 1에서 같이 갱신. 위 patch는 Settings 객체 통해서만 다루므로 영향 없음(검증 완료).
|
||||
- Task 3 pull_worker test의 `FrozenDateTime.now`는 `datetime.now(KST)` 호출만 stub함. 다른 datetime 사용 부분 영향 없음 (verified L28).
|
||||
- Task 4 test는 ChronosPredictor `__new__`로 우회 — 실제 HuggingFace 모델 로딩 안 함.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-25-ai-trade-hotfix.md`.**
|
||||
|
||||
두 가지 실행 옵션:
|
||||
|
||||
**1. Subagent-Driven (recommended)** — task 별 fresh subagent dispatch + two-stage review. F2/F3 같이 미묘한 동시성/상태 변경에 유리.
|
||||
|
||||
**2. Inline Execution** — 현 세션에서 직접 task별 진행 + checkpoint.
|
||||
|
||||
박재오 결정 대기.
|
||||
704
docs/superpowers/plans/2026-05-25-render-queue-reliability.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# Render Queue Reliability — Code Review F6 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 4개 render worker(insta/music/video/image-render)가 BLPOP 직후 crash 시 작업 손실되는 문제 해결. BLMOVE(또는 BRPOPLPUSH)로 atomic dequeue + processing list 패턴 + startup recovery + retry/dead-letter.
|
||||
|
||||
**Architecture:**
|
||||
1. 각 worker가 unique `worker_id` 보유: `<queue>-<hostname>-<pid>` (env로 override 가능).
|
||||
2. atomic dequeue: `BLMOVE queue:<x>-render processing:<x>-render:<worker_id> RIGHT LEFT 5` — 5초 timeout. (`BRPOPLPUSH`는 Redis 6.2+ deprecated, `BLMOVE`가 후속).
|
||||
3. 작업 성공: `LREM processing:<x>-render:<worker_id> 1 <payload>` — 정확 1개 제거.
|
||||
4. 작업 실패: payload에 `attempts` counter 증가시켜 main queue 끝으로 LPUSH; 한계(기본 3) 초과 시 `dead_letter:<queue>` 로 이동.
|
||||
5. **Startup recovery**: worker 시작 시 자신의 processing list가 비어있지 않으면 → 모두 main queue로 되돌림 (재시도). attempts 증가.
|
||||
6. NAS측 producer는 무변경 (LPUSH 그대로). 단, payload schema에 `attempts: int` (optional) 필드 명시 — producer는 안 채워도 worker가 default 0으로.
|
||||
|
||||
**Shared module 전략:** 4개 worker가 동일 패턴이므로 `services/_shared/reliable_queue.py` 1개 만들고 각 Dockerfile에서 `COPY services/_shared /app/_shared` 후 `from _shared.reliable_queue import ReliableQueue`. compose entry/dockerfile 변경 4건. (DRY > inline 4중복.)
|
||||
|
||||
**Tech Stack:** Python 3.12, redis.asyncio 5.x, fakeredis (pytest dep), pytest-asyncio.
|
||||
|
||||
**Working directory:** `C:\Users\jaeoh\Desktop\workspace\web-ai`.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `services/_shared/__init__.py` | Create | namespace package |
|
||||
| `services/_shared/reliable_queue.py` | Create | `ReliableQueue` 클래스 — dequeue, ack, fail, recover |
|
||||
| `services/_shared/tests/test_reliable_queue.py` | Create | fakeredis 단위 테스트 6개 |
|
||||
| `services/_shared/requirements.txt` | Create | redis>=5.0, fakeredis (test only) |
|
||||
| `services/insta-render/Dockerfile` | Modify | `COPY services/_shared /app/_shared` + PYTHONPATH |
|
||||
| `services/insta-render/worker.py` | Modify L1~ | BLPOP → ReliableQueue 사용 |
|
||||
| `services/insta-render/tests/test_worker.py` | Append | 1 integration test (recovery) |
|
||||
| `services/music-render/Dockerfile` | Modify | shared copy |
|
||||
| `services/music-render/worker.py` | Modify | ReliableQueue 사용 |
|
||||
| `services/music-render/tests/test_worker.py` | Append | recovery test |
|
||||
| `services/video-render/Dockerfile` | Modify | shared copy |
|
||||
| `services/video-render/worker.py` | Modify | ReliableQueue 사용 |
|
||||
| `services/video-render/tests/test_worker.py` | Append | recovery test |
|
||||
| `services/image-render/Dockerfile` | Modify | shared copy |
|
||||
| `services/image-render/worker.py` | Modify | ReliableQueue 사용 |
|
||||
| `services/image-render/tests/test_worker.py` | Append | recovery test |
|
||||
| `services/docker-compose.yml` (있다면) | Verify | build context가 services/ 루트 포함하는지 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ReliableQueue 공유 모듈 작성
|
||||
|
||||
**Files:**
|
||||
- Create: `services/_shared/__init__.py`
|
||||
- Create: `services/_shared/reliable_queue.py`
|
||||
- Create: `services/_shared/tests/__init__.py`
|
||||
- Create: `services/_shared/tests/test_reliable_queue.py`
|
||||
- Create: `services/_shared/requirements.txt`
|
||||
|
||||
- [ ] **Step 1: Create namespace package**
|
||||
|
||||
```python
|
||||
# services/_shared/__init__.py
|
||||
```
|
||||
(빈 파일)
|
||||
|
||||
```python
|
||||
# services/_shared/tests/__init__.py
|
||||
```
|
||||
(빈 파일)
|
||||
|
||||
- [ ] **Step 2: Write failing tests first**
|
||||
|
||||
```python
|
||||
# services/_shared/tests/test_reliable_queue.py
|
||||
"""F6 — ReliableQueue: atomic dequeue + recovery + retry."""
|
||||
import json
|
||||
|
||||
import fakeredis.aioredis
|
||||
import pytest
|
||||
|
||||
from _shared.reliable_queue import ReliableQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def redis():
|
||||
r = fakeredis.aioredis.FakeRedis(decode_responses=False)
|
||||
yield r
|
||||
await r.flushall()
|
||||
await r.aclose()
|
||||
|
||||
|
||||
async def test_dequeue_atomically_moves_to_processing(redis):
|
||||
"""BLMOVE: queue → processing 원자적 이동."""
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
await redis.lpush("queue:test", json.dumps({"task_id": "t1"}).encode())
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
assert payload["task_id"] == "t1"
|
||||
# main queue는 비어있고, processing list에 들어있어야 함
|
||||
assert await redis.llen("queue:test") == 0
|
||||
assert await redis.llen("processing:queue:test:w1") == 1
|
||||
|
||||
|
||||
async def test_dequeue_returns_none_on_timeout(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
result = await q.dequeue(timeout=1)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_ack_removes_from_processing(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
await redis.lpush("queue:test", json.dumps({"task_id": "t1"}).encode())
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
await q.ack(raw)
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
|
||||
|
||||
async def test_recover_returns_orphaned_to_main_queue(redis):
|
||||
"""startup recovery: 잔존 processing list 항목을 main queue로 되돌림."""
|
||||
# 이전 crash 시뮬레이션: processing list에 잔존
|
||||
orphan = json.dumps({"task_id": "t1", "attempts": 0}).encode()
|
||||
await redis.lpush("processing:queue:test:w1", orphan)
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
recovered = await q.recover()
|
||||
assert recovered == 1
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
# 다시 dequeue 가능
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
assert payload["task_id"] == "t1"
|
||||
assert payload["attempts"] == 1 # incremented on recover
|
||||
|
||||
|
||||
async def test_fail_below_max_attempts_returns_to_main_queue(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1", max_attempts=3)
|
||||
await redis.lpush("queue:test", json.dumps({"task_id": "t1", "attempts": 0}).encode())
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
await q.fail(raw, payload)
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
assert await redis.llen("queue:test") == 1
|
||||
# attempts 증가됐는지
|
||||
requeued_raw = await redis.lindex("queue:test", 0)
|
||||
requeued = json.loads(requeued_raw)
|
||||
assert requeued["attempts"] == 1
|
||||
|
||||
|
||||
async def test_fail_at_max_attempts_moves_to_dead_letter(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1", max_attempts=3)
|
||||
await redis.lpush(
|
||||
"queue:test", json.dumps({"task_id": "t1", "attempts": 2}).encode()
|
||||
)
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
await q.fail(raw, payload)
|
||||
# attempts 2 → 3 (== max) → dead-letter
|
||||
assert await redis.llen("queue:test") == 0
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
assert await redis.llen("dead_letter:queue:test") == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add requirements**
|
||||
|
||||
```text
|
||||
# services/_shared/requirements.txt
|
||||
redis>=5.0.0
|
||||
```
|
||||
|
||||
별도 dev requirements (test):
|
||||
|
||||
```text
|
||||
# services/_shared/tests/requirements-dev.txt (optional)
|
||||
fakeredis>=2.20.0
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they fail**
|
||||
|
||||
```
|
||||
cd services/_shared && python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: ImportError — reliable_queue.py 미존재.
|
||||
|
||||
- [ ] **Step 5: Write reliable_queue.py**
|
||||
|
||||
```python
|
||||
# services/_shared/reliable_queue.py
|
||||
"""F6 — Reliable Redis queue with processing list + recovery + retry.
|
||||
|
||||
Pattern: BLMOVE main → processing (atomic), then either ack (LREM processing) or
|
||||
fail (LREM processing + re-enqueue or dead-letter).
|
||||
|
||||
Startup recovery: any items left in the worker's processing list from a previous
|
||||
crash are pushed back to main queue (with attempts incremented).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def default_worker_id(queue_key: str) -> str:
|
||||
"""env > hostname-pid."""
|
||||
explicit = os.getenv("WORKER_ID")
|
||||
if explicit:
|
||||
return explicit
|
||||
return f"{queue_key}-{socket.gethostname()}-{os.getpid()}"
|
||||
|
||||
|
||||
class ReliableQueue:
|
||||
"""Wraps a redis client to provide BLMOVE-backed atomic dequeue +
|
||||
processing list + retry/dead-letter.
|
||||
|
||||
Producer side stays unchanged: LPUSH queue:<x> <json payload>.
|
||||
Worker side: dequeue() → process → ack(raw) on success or fail(raw, payload) on error.
|
||||
Startup: await queue.recover() to re-enqueue orphans.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis,
|
||||
queue_key: str,
|
||||
worker_id: Optional[str] = None,
|
||||
max_attempts: int = 3,
|
||||
):
|
||||
self._redis = redis
|
||||
self._queue_key = queue_key
|
||||
self._worker_id = worker_id or default_worker_id(queue_key)
|
||||
self._processing_key = f"processing:{queue_key}:{self._worker_id}"
|
||||
self._dead_letter_key = f"dead_letter:{queue_key}"
|
||||
self._max_attempts = max_attempts
|
||||
|
||||
@property
|
||||
def processing_key(self) -> str:
|
||||
return self._processing_key
|
||||
|
||||
async def dequeue(self, timeout: int = 5) -> Optional[tuple[dict, bytes]]:
|
||||
"""Atomically move 1 item from main queue tail to processing head.
|
||||
|
||||
Returns (parsed_dict, raw_bytes) or None on timeout.
|
||||
Caller MUST call ack(raw) on success or fail(raw, payload) on error.
|
||||
"""
|
||||
raw = await self._redis.blmove(
|
||||
self._queue_key, self._processing_key,
|
||||
timeout=timeout, src="RIGHT", dest="LEFT",
|
||||
)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("invalid payload on dequeue, moving to dead-letter: %r", raw[:200])
|
||||
await self._redis.lrem(self._processing_key, 1, raw)
|
||||
await self._redis.lpush(self._dead_letter_key, raw)
|
||||
return None
|
||||
return payload, raw
|
||||
|
||||
async def ack(self, raw: bytes) -> None:
|
||||
"""Successful processing — remove from processing list."""
|
||||
removed = await self._redis.lrem(self._processing_key, 1, raw)
|
||||
if removed == 0:
|
||||
logger.warning("ack on missing payload (already removed?): %r", raw[:100])
|
||||
|
||||
async def fail(self, raw: bytes, payload: dict) -> None:
|
||||
"""Failed processing — remove from processing list and either re-enqueue or dead-letter."""
|
||||
await self._redis.lrem(self._processing_key, 1, raw)
|
||||
attempts = int(payload.get("attempts", 0)) + 1
|
||||
if attempts >= self._max_attempts:
|
||||
payload["attempts"] = attempts
|
||||
await self._redis.lpush(self._dead_letter_key, json.dumps(payload).encode())
|
||||
logger.error(
|
||||
"task moved to dead-letter after %d attempts: task_id=%s",
|
||||
attempts, payload.get("task_id"),
|
||||
)
|
||||
return
|
||||
payload["attempts"] = attempts
|
||||
await self._redis.lpush(self._queue_key, json.dumps(payload).encode())
|
||||
logger.info(
|
||||
"task re-enqueued (attempt %d/%d): task_id=%s",
|
||||
attempts, self._max_attempts, payload.get("task_id"),
|
||||
)
|
||||
|
||||
async def recover(self) -> int:
|
||||
"""Startup: move all orphans from this worker's processing list back to main queue.
|
||||
|
||||
Increments attempts counter (orphan == implicit failure).
|
||||
Returns count of recovered items.
|
||||
"""
|
||||
count = 0
|
||||
while True:
|
||||
raw = await self._redis.lpop(self._processing_key)
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await self._redis.lpush(self._dead_letter_key, raw)
|
||||
count += 1
|
||||
continue
|
||||
payload["attempts"] = int(payload.get("attempts", 0)) + 1
|
||||
if payload["attempts"] >= self._max_attempts:
|
||||
await self._redis.lpush(self._dead_letter_key, json.dumps(payload).encode())
|
||||
else:
|
||||
await self._redis.lpush(self._queue_key, json.dumps(payload).encode())
|
||||
count += 1
|
||||
if count:
|
||||
logger.info("recovered %d orphaned items for worker %s", count, self._worker_id)
|
||||
return count
|
||||
```
|
||||
|
||||
**참고: redis-py blmove API**: `client.blmove(first_list, second_list, timeout, src=..., dest=...)`. timeout=0 은 block forever. payload는 bytes로 받음 (`decode_responses=False` 가정).
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
```
|
||||
cd services/_shared && python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 6 PASS.
|
||||
|
||||
만약 ImportError (`fakeredis` 미설치) 발생 시:
|
||||
|
||||
```
|
||||
python -m pip install fakeredis pytest-asyncio
|
||||
```
|
||||
|
||||
또한 `pytest.ini` 또는 `conftest.py`에 `asyncio_mode = "auto"` 필요. 신규 conftest:
|
||||
|
||||
```python
|
||||
# services/_shared/tests/conftest.py
|
||||
import pytest
|
||||
pytest_plugins = ["pytest_asyncio"]
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
for item in items:
|
||||
if "asyncio" in item.fixturenames or item.get_closest_marker("asyncio") is not None:
|
||||
continue
|
||||
# auto-mark all async tests
|
||||
if item.function.__name__.startswith("test_"):
|
||||
import asyncio, inspect
|
||||
if inspect.iscoroutinefunction(item.function):
|
||||
item.add_marker(pytest.mark.asyncio)
|
||||
```
|
||||
|
||||
또는 더 간단히 `services/_shared/pytest.ini`:
|
||||
|
||||
```ini
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add services/_shared/
|
||||
git commit -m "feat(services): _shared/reliable_queue 신설 — BLMOVE + processing list + retry (F6 part 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: insta-render에 ReliableQueue 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/insta-render/Dockerfile`
|
||||
- Modify: `services/insta-render/worker.py`
|
||||
- Modify: `services/insta-render/tests/test_worker.py` (append)
|
||||
|
||||
- [ ] **Step 1: Update Dockerfile**
|
||||
|
||||
`services/insta-render/Dockerfile` 에 `_shared` 복사 추가. 기존 Dockerfile 패턴을 먼저 읽고, `COPY services/insta-render /app` 같은 라인이 있다면 그 위 또는 옆에:
|
||||
|
||||
```dockerfile
|
||||
COPY services/_shared /app/_shared
|
||||
ENV PYTHONPATH=/app:/app/_shared:${PYTHONPATH}
|
||||
```
|
||||
|
||||
build context가 `services/` 루트여야 함. compose에서 `build: { context: ./services, dockerfile: insta-render/Dockerfile }` 인지 확인 — 아니라면 context 조정 필요.
|
||||
|
||||
- [ ] **Step 2: Modify worker.py — failing test first**
|
||||
|
||||
`services/insta-render/tests/test_worker.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_calls_ack_on_success():
|
||||
"""성공 시 ack() 호출 (F6)."""
|
||||
import worker
|
||||
fake_payload = {"task_id": "t1", "job_type": "card_generation", "params": {}}
|
||||
fake_raw = json.dumps(fake_payload).encode()
|
||||
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(side_effect=[(fake_payload, fake_raw), None])
|
||||
fake_queue.ack = AsyncMock()
|
||||
fake_queue.fail = AsyncMock()
|
||||
fake_queue.recover = AsyncMock(return_value=0)
|
||||
|
||||
with patch.object(worker, "ReliableQueue", return_value=fake_queue), \
|
||||
patch.object(worker, "_dispatch") as disp:
|
||||
# poll_once로 1 cycle만 실행 (실제 loop 끊기 위해)
|
||||
await worker.poll_once(fake_queue)
|
||||
disp.assert_called_once()
|
||||
fake_queue.ack.assert_called_once_with(fake_raw)
|
||||
fake_queue.fail.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_calls_fail_on_dispatch_exception():
|
||||
"""dispatch 예외 시 fail() 호출 — 작업 손실 안 됨 (F6)."""
|
||||
import worker
|
||||
fake_payload = {"task_id": "t2", "job_type": "card_generation", "params": {}}
|
||||
fake_raw = json.dumps(fake_payload).encode()
|
||||
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(return_value=(fake_payload, fake_raw))
|
||||
fake_queue.ack = AsyncMock()
|
||||
fake_queue.fail = AsyncMock()
|
||||
|
||||
with patch.object(worker, "_dispatch", side_effect=RuntimeError("boom")):
|
||||
await worker.poll_once(fake_queue)
|
||||
fake_queue.fail.assert_called_once_with(fake_raw, fake_payload)
|
||||
fake_queue.ack.assert_not_called()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to fail**
|
||||
|
||||
```
|
||||
cd services/insta-render && python -m pytest tests/ -v -k "ack_on_success or fail_on_dispatch"
|
||||
```
|
||||
|
||||
Expected: AttributeError (`worker.poll_once` 미존재, `worker.ReliableQueue` 미존재).
|
||||
|
||||
- [ ] **Step 4: Rewrite insta-render worker.py**
|
||||
|
||||
```python
|
||||
"""Redis 기반 worker — F6 신뢰성 패턴 적용 (BLMOVE + processing list + retry)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from _shared.reliable_queue import ReliableQueue
|
||||
from nas_client import webhook_update_task
|
||||
# 기존 dispatch 대상 import 유지
|
||||
from card_renderer import render_card
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||
QUEUE_KEY = "queue:insta-render"
|
||||
PAUSED_KEY = "queue:paused"
|
||||
|
||||
|
||||
_DISPATCH_TABLE = {
|
||||
"card_generation": "render_card",
|
||||
}
|
||||
|
||||
|
||||
def _dispatch(payload: dict) -> None:
|
||||
job_type = payload.get("job_type", "")
|
||||
task_id = payload.get("task_id", "")
|
||||
params = payload.get("params", {})
|
||||
fn_name = _DISPATCH_TABLE.get(job_type)
|
||||
if fn_name is None:
|
||||
logger.error("unknown job_type=%s task=%s", job_type, task_id)
|
||||
webhook_update_task(task_id, "failed", 0, "", error=f"unknown job_type: {job_type}")
|
||||
return
|
||||
try:
|
||||
fn = getattr(sys.modules[__name__], fn_name)
|
||||
except AttributeError:
|
||||
webhook_update_task(task_id, "failed", 0, "", error=f"internal dispatch error: {fn_name}")
|
||||
return
|
||||
fn(task_id, params)
|
||||
|
||||
|
||||
async def poll_once(queue: ReliableQueue) -> bool:
|
||||
"""1 cycle: dequeue → dispatch → ack/fail. Returns True if a job was handled."""
|
||||
result = await queue.dequeue(timeout=5)
|
||||
if result is None:
|
||||
return False
|
||||
payload, raw = result
|
||||
try:
|
||||
await asyncio.to_thread(_dispatch, payload)
|
||||
except Exception:
|
||||
logger.exception("dispatch failed task_id=%s", payload.get("task_id"))
|
||||
await queue.fail(raw, payload)
|
||||
return True
|
||||
await queue.ack(raw)
|
||||
return True
|
||||
|
||||
|
||||
async def worker_loop():
|
||||
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||
queue = ReliableQueue(redis, queue_key=QUEUE_KEY)
|
||||
logger.info("insta-render worker started worker_id=%s", queue._worker_id)
|
||||
# F6: startup recovery
|
||||
try:
|
||||
recovered = await queue.recover()
|
||||
if recovered:
|
||||
logger.info("recovered %d orphaned items at startup", recovered)
|
||||
except Exception:
|
||||
logger.exception("startup recover failed")
|
||||
while True:
|
||||
try:
|
||||
paused = await redis.get(PAUSED_KEY)
|
||||
if paused == b"1":
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
await poll_once(queue)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("worker_loop cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("worker_loop iteration 실패, 5초 후 재시도")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(worker_loop())
|
||||
```
|
||||
|
||||
**NOTE**: 기존 `insta-render/worker.py`의 dispatch table·import는 실제 파일을 보고 매핑 유지. 위 예시는 minimal — job_type / function 이름은 기존 파일과 맞춰야 함. 변경 전 `Read services/insta-render/worker.py`로 정확한 dispatch table 확인할 것.
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
```
|
||||
cd services/insta-render && python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 신규 2 PASS, 기존 PASS (dispatch table test 등).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/insta-render/
|
||||
git commit -m "fix(insta-render): F6 ReliableQueue 적용 — BLMOVE + ack/fail (F6 part 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: music-render에 동일 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/music-render/Dockerfile`, `worker.py`
|
||||
- Modify: `services/music-render/tests/test_worker.py` (append)
|
||||
|
||||
- [ ] **Step 1: Dockerfile에 `COPY services/_shared` 추가**
|
||||
- [ ] **Step 2: Test 추가 (Task 2 패턴 동일, 단 dispatch target은 `run_suno_generation` 등 기존 패턴)**
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_music_worker_ack_on_success():
|
||||
import worker
|
||||
payload = {"task_id": "t1", "job_type": "suno_generation", "params": {}}
|
||||
raw = json.dumps(payload).encode()
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(return_value=(payload, raw))
|
||||
fake_queue.ack = AsyncMock()
|
||||
with patch.object(worker, "_dispatch"):
|
||||
await worker.poll_once(fake_queue)
|
||||
fake_queue.ack.assert_called_once_with(raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_music_worker_fail_on_exception():
|
||||
import worker
|
||||
payload = {"task_id": "t2", "job_type": "suno_generation", "params": {}}
|
||||
raw = json.dumps(payload).encode()
|
||||
fake_queue = AsyncMock()
|
||||
fake_queue.dequeue = AsyncMock(return_value=(payload, raw))
|
||||
fake_queue.fail = AsyncMock()
|
||||
with patch.object(worker, "_dispatch", side_effect=RuntimeError("x")):
|
||||
await worker.poll_once(fake_queue)
|
||||
fake_queue.fail.assert_called_once_with(raw, payload)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to fail**
|
||||
- [ ] **Step 4: Rewrite music-render worker.py — `worker_loop` 구조는 insta-render와 동일, `_dispatch` + `_DISPATCH_TABLE`은 기존 12개 함수 그대로 유지**
|
||||
- [ ] **Step 5: Run tests**
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/music-render/
|
||||
git commit -m "fix(music-render): F6 ReliableQueue 적용 (F6 part 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: video-render에 동일 적용
|
||||
|
||||
(Task 3와 동일 패턴 — sora/veo/kling/seedance 4 provider table 유지)
|
||||
|
||||
- [ ] **Step 1: Dockerfile 수정**
|
||||
- [ ] **Step 2: 신규 test 2개 추가 (`test_video_worker_ack_on_success`, `test_video_worker_fail_on_exception`) — job_type은 `sora_generation`**
|
||||
- [ ] **Step 3: Run failing test**
|
||||
- [ ] **Step 4: Rewrite worker.py — 동일 패턴**
|
||||
- [ ] **Step 5: Run tests**
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/video-render/
|
||||
git commit -m "fix(video-render): F6 ReliableQueue 적용 (F6 part 4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: image-render에 동일 적용
|
||||
|
||||
(gpt_image / nano_banana / flux 3 provider table 유지)
|
||||
|
||||
- [ ] **Step 1-6: Task 3/4 동일 패턴**
|
||||
|
||||
```bash
|
||||
git add services/image-render/
|
||||
git commit -m "fix(image-render): F6 ReliableQueue 적용 (F6 part 5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 운영 검증 + push
|
||||
|
||||
- [ ] **Step 1: 전체 services test 실행**
|
||||
|
||||
```
|
||||
cd services && for d in _shared insta-render music-render video-render image-render; do
|
||||
echo "--- $d ---"
|
||||
(cd $d && python -m pytest tests/ -q) || true
|
||||
done
|
||||
```
|
||||
|
||||
(또는 PowerShell:)
|
||||
|
||||
```powershell
|
||||
foreach ($d in @("_shared","insta-render","music-render","video-render","image-render")) {
|
||||
Write-Output "--- $d ---"
|
||||
Push-Location services/$d
|
||||
python -m pytest tests/ -q
|
||||
Pop-Location
|
||||
}
|
||||
```
|
||||
|
||||
Expected: 4개 worker 각 신규 2개 + _shared 6개 + 기존 test 전부 PASS.
|
||||
|
||||
- [ ] **Step 2: Docker build 시뮬 (옵션, 시간 허용 시)**
|
||||
|
||||
```
|
||||
cd services && docker compose build insta-render music-render video-render image-render
|
||||
```
|
||||
|
||||
Expected: build context에 `_shared` 포함됨 검증.
|
||||
|
||||
- [ ] **Step 3: Push**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 운영 deploy 시 주의사항 (수동)**
|
||||
|
||||
NAS에서 컨테이너 재배포 시:
|
||||
1. `redis-cli -h 192.168.45.54 KEYS 'processing:*'` 로 기존 orphan 확인 — 있다면 worker_id 다르면 안 잡힘. 수동으로 `LMOVE` 해야 할 수도 있음.
|
||||
2. `redis-cli -h 192.168.45.54 KEYS 'dead_letter:*'` 로 dead-letter 모니터 — 누적되면 alerting 필요.
|
||||
3. WORKER_ID env로 unique 하게 (`WORKER_ID=insta-render-prod-1` 등) 권장 — hostname이 컨테이너 재기동 시 바뀌면 orphan 추적 안 됨.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **atomic dequeue**: `BLMOVE` 단일 명령 — Redis 단일 트랜잭션 ✅
|
||||
2. **ack on success**: `LREM processing 1 raw` — 정확 1개 ✅
|
||||
3. **fail with retry**: attempts < max → 재큐, attempts >= max → dead-letter ✅
|
||||
4. **startup recovery**: orphan 자동 재큐 (attempts 증가) ✅
|
||||
5. **4 worker 적용**: insta/music/video/image 동일 패턴 ✅
|
||||
6. **NAS producer 호환**: LPUSH 그대로, payload schema에 attempts 선택적 ✅
|
||||
|
||||
**미커버 (의도적)**:
|
||||
- dead-letter monitor/alert — 운영 작업 (CHECK_POINT 백로그)
|
||||
- worker_id env 미설정 시 hostname 변경 시 orphan 분실 — 운영 가이드에 명시
|
||||
|
||||
**가정 검증**:
|
||||
- `redis-py.aioredis.blmove` 시그니처: `(first_list, second_list, timeout, src='LEFT', dest='RIGHT')`. redis>=5.0 권장.
|
||||
- fakeredis: `fakeredis.aioredis.FakeRedis` (>=2.20.0) 가 BLMOVE 지원함 — 미지원 시 plan 적용 전 검증.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-25-render-queue-reliability.md`.**
|
||||
|
||||
**1. Subagent-Driven (recommended)** — Task 별 fresh subagent. 4개 worker는 패턴 같으나 dispatch table은 각 worker 고유 — subagent가 정확히 일관성 유지하도록 review checkpoint.
|
||||
|
||||
**2. Inline Execution** — 현 세션 실행.
|
||||
|
||||
박재오 결정 대기. Plan 1·2 마친 후 진입 권장 (작업량 가장 큼 — 4개 worker × 약 1시간 = 4시간).
|
||||
593
docs/superpowers/plans/2026-05-25-state-signals-lifecycle.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# state.signals Lifecycle — Code Review F5 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `state.signals` 가 무한 dict 누적되는 문제를 해결. expires_at + cycle_id 부착해서 Phase 5 consumer (agent-office `/signal`) 가 stale 신호를 안전하게 무시할 수 있게.
|
||||
|
||||
**Architecture:**
|
||||
1. `Signal` dict에 `expires_at: ISO str`, `cycle_id: int` 필드 추가.
|
||||
2. `PollState.signal_cycle_id: int` (process 단위 auto-increment).
|
||||
3. `generate_signals(state, dedup, settings)` 진입마다 `cycle_id += 1`.
|
||||
4. emit하는 모든 signal에 `expires_at = as_of + SIGNAL_TTL_SECONDS`, `cycle_id = state.signal_cycle_id` 부착.
|
||||
5. `state.purge_expired_signals(now)` helper — 매 cycle 끝에 호출하여 만료된 항목 제거.
|
||||
6. `state.get_active_signals(now) → list[dict]` — Phase 5 consumer가 호출할 read API. 만료된 것 제외.
|
||||
|
||||
**Tech Stack:** Python 3.12, asyncio, pytest. 기존 cycle 흐름과 호환되도록 generate_signals 인터페이스는 그대로.
|
||||
|
||||
**Why expires_at + cycle_id (not pop-on-read):** consumer가 polling 실패해도 신호 손실 없음. cycle_id로 "이번 cycle에 새로 emit된 신호" 식별 가능 → Phase 5에서 incremental fetch 가능.
|
||||
|
||||
**Working directory:** `C:\Users\jaeoh\Desktop\workspace\web-ai`.
|
||||
|
||||
**Test runner:** `python -m pytest ai_trade/tests -q` (또는 `py -3.12 -m`). 환경 부재 시 plan 진행 중단.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 파일 | 변경 | 책임 |
|
||||
|------|------|------|
|
||||
| `ai_trade/config.py` | Add 1 field | `signal_ttl_seconds: int` (default 300) |
|
||||
| `ai_trade/state.py` | Modify | `signal_cycle_id: int`, helper 2개 (`get_active_signals`, `purge_expired_signals`) |
|
||||
| `ai_trade/signal_generator.py` | Modify L22-50, 133, 99-111, 174-186 | cycle_id 증가 + expires_at/cycle_id 부착 |
|
||||
| `ai_trade/pull_worker.py` | Modify L46-51 근처 | cycle 끝에 purge 호출 |
|
||||
| `ai_trade/tests/test_state_signals_lifecycle.py` | Create | 5 test (expires, cycle_id, purge, active list) |
|
||||
| `ai_trade/tests/test_signal_generator.py` | Modify | 기존 emit test에 expires_at/cycle_id 필드 검증 추가 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: PollState에 cycle_id + lifecycle helper 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/state.py`
|
||||
- Test: `ai_trade/tests/test_state_signals_lifecycle.py` (Create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# ai_trade/tests/test_state_signals_lifecycle.py
|
||||
"""F5 — state.signals lifecycle (expires_at + cycle_id)."""
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_trade.state import PollState
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
|
||||
def test_initial_signal_cycle_id_is_zero():
|
||||
state = PollState()
|
||||
assert state.signal_cycle_id == 0
|
||||
|
||||
|
||||
def test_get_active_signals_excludes_expired():
|
||||
state = PollState()
|
||||
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||
future = (now + timedelta(seconds=300)).isoformat()
|
||||
past = (now - timedelta(seconds=60)).isoformat()
|
||||
state.signals = {
|
||||
"A": {"ticker": "A", "expires_at": future, "cycle_id": 1, "action": "buy"},
|
||||
"B": {"ticker": "B", "expires_at": past, "cycle_id": 1, "action": "buy"},
|
||||
}
|
||||
active = state.get_active_signals(now)
|
||||
tickers = [s["ticker"] for s in active]
|
||||
assert "A" in tickers
|
||||
assert "B" not in tickers
|
||||
|
||||
|
||||
def test_get_active_signals_treats_missing_expires_as_expired():
|
||||
"""expires_at 없는 legacy 신호는 expired로 간주."""
|
||||
state = PollState()
|
||||
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||
state.signals = {"C": {"ticker": "C", "action": "buy"}}
|
||||
assert state.get_active_signals(now) == []
|
||||
|
||||
|
||||
def test_purge_expired_signals_removes_expired():
|
||||
state = PollState()
|
||||
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||
future = (now + timedelta(seconds=300)).isoformat()
|
||||
past = (now - timedelta(seconds=60)).isoformat()
|
||||
state.signals = {
|
||||
"A": {"ticker": "A", "expires_at": future, "cycle_id": 1},
|
||||
"B": {"ticker": "B", "expires_at": past, "cycle_id": 1},
|
||||
}
|
||||
state.purge_expired_signals(now)
|
||||
assert "A" in state.signals
|
||||
assert "B" not in state.signals
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v
|
||||
```
|
||||
|
||||
Expected: `AttributeError: signal_cycle_id` 또는 `get_active_signals` 미구현.
|
||||
|
||||
- [ ] **Step 3: Modify state.py**
|
||||
|
||||
```python
|
||||
"""PollState — process-wide singleton."""
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollState:
|
||||
portfolio: dict | None = None
|
||||
news_sentiment: dict | None = None
|
||||
screener_preview: dict | None = None
|
||||
minute_bars: dict[str, deque] = field(default_factory=dict)
|
||||
asking_price: dict[str, dict] = field(default_factory=dict)
|
||||
# Phase 3b additions
|
||||
daily_ohlcv: dict[str, list[dict]] = field(default_factory=dict)
|
||||
chronos_predictions: dict[str, dict] = field(default_factory=dict)
|
||||
minute_momentum: dict[str, str] = field(default_factory=dict)
|
||||
signals: dict[str, dict] = field(default_factory=dict)
|
||||
# F5 lifecycle
|
||||
signal_cycle_id: int = 0
|
||||
last_updated: dict[str, str] = field(default_factory=dict)
|
||||
fetch_errors: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def get_active_signals(self, now: datetime) -> list[dict]:
|
||||
"""expires_at > now 인 신호만 반환. expires_at 없으면 expired 취급."""
|
||||
active: list[dict] = []
|
||||
for sig in self.signals.values():
|
||||
expires_at = sig.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires_at)
|
||||
except ValueError:
|
||||
continue
|
||||
if exp_dt > now:
|
||||
active.append(sig)
|
||||
return active
|
||||
|
||||
def purge_expired_signals(self, now: datetime) -> int:
|
||||
"""만료된 signal 제거. 제거된 개수 반환."""
|
||||
to_drop = []
|
||||
for ticker, sig in self.signals.items():
|
||||
expires_at = sig.get("expires_at")
|
||||
if not expires_at:
|
||||
to_drop.append(ticker)
|
||||
continue
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires_at)
|
||||
except ValueError:
|
||||
to_drop.append(ticker)
|
||||
continue
|
||||
if exp_dt <= now:
|
||||
to_drop.append(ticker)
|
||||
for t in to_drop:
|
||||
del self.signals[t]
|
||||
return len(to_drop)
|
||||
|
||||
|
||||
state = PollState()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v
|
||||
```
|
||||
|
||||
Expected: 4 PASS.
|
||||
|
||||
- [ ] **Step 5: Verify full suite still passes**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -q
|
||||
```
|
||||
|
||||
Expected: 기존 test 전부 PASS (state.signals dict 인터페이스 그대로).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/state.py ai_trade/tests/test_state_signals_lifecycle.py
|
||||
git commit -m "feat(ai_trade): state.signals에 expires_at + cycle_id lifecycle 추가 (F5 part 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: config에 SIGNAL_TTL_SECONDS 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/config.py`
|
||||
- Test: `ai_trade/tests/test_state_signals_lifecycle.py` (append)
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
`test_state_signals_lifecycle.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
def test_signal_ttl_seconds_default(monkeypatch):
|
||||
monkeypatch.delenv("SIGNAL_TTL_SECONDS", raising=False)
|
||||
from ai_trade.config import Settings
|
||||
s = Settings()
|
||||
assert s.signal_ttl_seconds == 300
|
||||
|
||||
|
||||
def test_signal_ttl_seconds_env_override(monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_TTL_SECONDS", "60")
|
||||
from ai_trade.config import Settings
|
||||
s = Settings()
|
||||
assert s.signal_ttl_seconds == 60
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to fail**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v -k signal_ttl
|
||||
```
|
||||
|
||||
Expected: AttributeError.
|
||||
|
||||
- [ ] **Step 3: Add field to config.py**
|
||||
|
||||
`Settings` 클래스 안에 추가 (다른 *_threshold 옆):
|
||||
|
||||
```python
|
||||
signal_ttl_seconds: int = field(
|
||||
default_factory=lambda: int(os.getenv("SIGNAL_TTL_SECONDS", "300"))
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_state_signals_lifecycle.py -v -k signal_ttl
|
||||
```
|
||||
|
||||
Expected: 2 PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/config.py ai_trade/tests/test_state_signals_lifecycle.py
|
||||
git commit -m "feat(ai_trade): SIGNAL_TTL_SECONDS env 추가 (F5 part 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: signal_generator에 cycle_id + expires_at 부착
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/signal_generator.py`
|
||||
- Test: `ai_trade/tests/test_signal_generator.py` (append)
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
기존 `test_signal_generator.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
|
||||
_KST_TEST = _ZI("Asia/Seoul")
|
||||
|
||||
|
||||
def test_emit_attaches_cycle_id_and_expires_at(
|
||||
state_with_buy_setup, dedup_clean, settings_default,
|
||||
):
|
||||
"""매 emit 시 cycle_id + expires_at 부착 (F5)."""
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
before = datetime.now(_KST_TEST)
|
||||
generate_signals(state_with_buy_setup, dedup_clean, settings_default)
|
||||
after = datetime.now(_KST_TEST)
|
||||
sig = state_with_buy_setup.signals["005930"]
|
||||
assert sig["cycle_id"] == 1
|
||||
assert "expires_at" in sig
|
||||
exp_dt = datetime.fromisoformat(sig["expires_at"])
|
||||
# as_of + 300s (default) — tolerance 5s
|
||||
assert before + timedelta(seconds=295) < exp_dt < after + timedelta(seconds=305)
|
||||
|
||||
|
||||
def test_cycle_id_increments_each_call(
|
||||
state_with_buy_setup, dedup_clean, settings_default,
|
||||
):
|
||||
"""generate_signals 호출마다 cycle_id += 1."""
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
generate_signals(state_with_buy_setup, dedup_clean, settings_default)
|
||||
assert state_with_buy_setup.signal_cycle_id == 1
|
||||
# 2번째 호출 — dedup이 막아도 cycle_id는 증가해야 함
|
||||
generate_signals(state_with_buy_setup, dedup_clean, settings_default)
|
||||
assert state_with_buy_setup.signal_cycle_id == 2
|
||||
```
|
||||
|
||||
**NOTE:** 기존 test_signal_generator.py에 `state_with_buy_setup` / `dedup_clean` / `settings_default` 같은 fixture가 있을 것. 만약 이름이 다르면 실제 fixture 이름에 맞춰 조정. 검증: `grep -n "@pytest.fixture" ai_trade/tests/test_signal_generator.py`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_signal_generator.py -v -k "cycle_id or expires"
|
||||
```
|
||||
|
||||
Expected: KeyError 또는 AttributeError.
|
||||
|
||||
- [ ] **Step 3: Modify signal_generator.py**
|
||||
|
||||
`generate_signals` 함수 (L22-25)를 변경:
|
||||
|
||||
```python
|
||||
def generate_signals(state, dedup, settings) -> None:
|
||||
"""Phase 4 entry — state-mutating. F5: cycle_id += 1 + expires_at 부착."""
|
||||
state.signal_cycle_id += 1
|
||||
_evaluate_sell_signals(state, dedup, settings)
|
||||
_evaluate_buy_signals(state, dedup, settings)
|
||||
```
|
||||
|
||||
`_build_buy_signal` (L99-111)에 두 필드 추가:
|
||||
|
||||
```python
|
||||
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict:
|
||||
ap = state.asking_price[ticker]
|
||||
as_of_dt = datetime.now(KST)
|
||||
expires_at = (as_of_dt + timedelta(seconds=getattr(_current_settings(), "signal_ttl_seconds", 300))).isoformat()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"name": name,
|
||||
"action": "buy",
|
||||
"confidence_webai": confidence,
|
||||
"current_price": ap["current_price"],
|
||||
"avg_price": None,
|
||||
"pnl_pct": None,
|
||||
"context": _build_context(state, ticker, rank),
|
||||
"as_of": as_of_dt.isoformat(),
|
||||
"cycle_id": state.signal_cycle_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
```
|
||||
|
||||
같이 `_build_sell_signal` (L174-186):
|
||||
|
||||
```python
|
||||
def _build_sell_signal(state, holding: dict, confidence: float, reason: str, settings=None) -> dict:
|
||||
ticker = holding["ticker"]
|
||||
as_of_dt = datetime.now(KST)
|
||||
ttl = getattr(settings, "signal_ttl_seconds", 300) if settings else 300
|
||||
expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"name": holding.get("name", ticker),
|
||||
"action": "sell",
|
||||
"confidence_webai": confidence,
|
||||
"current_price": holding.get("current_price"),
|
||||
"avg_price": holding.get("avg_price"),
|
||||
"pnl_pct": holding.get("pnl_pct"),
|
||||
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
|
||||
"as_of": as_of_dt.isoformat(),
|
||||
"cycle_id": state.signal_cycle_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
```
|
||||
|
||||
`_build_buy_signal`이 settings를 안 받고 있으니, 호출부도 갱신해야 함. 현실적으로 두 함수에 `settings` 인자를 추가하는 것이 깔끔. 변경:
|
||||
|
||||
```python
|
||||
def _evaluate_buy_signals(state, dedup, settings) -> None:
|
||||
candidates = _buy_candidates(state)
|
||||
for ticker, name, rank in candidates:
|
||||
existing = state.signals.get(ticker)
|
||||
if existing is not None and existing.get("action") == "sell":
|
||||
logger.debug("buy %s skipped: same-cycle sell precedence", ticker)
|
||||
continue
|
||||
if not _check_buy_hard_gate(state, ticker, settings):
|
||||
logger.debug("buy %s skipped: hard gate failed", ticker)
|
||||
continue
|
||||
confidence = _compute_buy_confidence(state, ticker, rank)
|
||||
if confidence <= settings.confidence_threshold:
|
||||
logger.debug("buy %s skipped: confidence %.3f <= %.3f",
|
||||
ticker, confidence, settings.confidence_threshold)
|
||||
continue
|
||||
if dedup.is_recent(ticker, "buy", within_hours=24):
|
||||
logger.debug("buy %s skipped: dedup 24h", ticker)
|
||||
continue
|
||||
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence, settings)
|
||||
dedup.record(ticker, "buy", confidence=confidence)
|
||||
logger.info("signal emit %s buy conf=%.3f rank=%s cycle=%d",
|
||||
ticker, confidence, rank, state.signal_cycle_id)
|
||||
|
||||
|
||||
def _build_buy_signal(state, ticker, name, rank, confidence, settings) -> dict:
|
||||
ap = state.asking_price[ticker]
|
||||
as_of_dt = datetime.now(KST)
|
||||
ttl = settings.signal_ttl_seconds
|
||||
expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"name": name,
|
||||
"action": "buy",
|
||||
"confidence_webai": confidence,
|
||||
"current_price": ap["current_price"],
|
||||
"avg_price": None,
|
||||
"pnl_pct": None,
|
||||
"context": _build_context(state, ticker, rank),
|
||||
"as_of": as_of_dt.isoformat(),
|
||||
"cycle_id": state.signal_cycle_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
```
|
||||
|
||||
매도 측도 마찬가지로 `settings`를 통과시킴. `_try_stop_loss` 등은 이미 `settings`를 받으므로 `_build_sell_signal(..., settings=settings)` 로 호출.
|
||||
|
||||
import 추가 (signal_generator.py 상단):
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
```
|
||||
|
||||
(기존 import에 `timedelta` 만 추가)
|
||||
|
||||
`_current_settings()` 같은 헬퍼는 만들지 않음 — settings를 명시적으로 전달.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_signal_generator.py -v
|
||||
```
|
||||
|
||||
Expected: 신규 2개 PASS, 기존 PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/signal_generator.py ai_trade/tests/test_signal_generator.py
|
||||
git commit -m "feat(ai_trade): emit signal에 cycle_id + expires_at 부착 (F5 part 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: pull_worker가 cycle 끝에 purge 호출
|
||||
|
||||
**Files:**
|
||||
- Modify: `ai_trade/pull_worker.py`
|
||||
- Test: `ai_trade/tests/test_pull_worker.py` (append)
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
`test_pull_worker.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
async def test_poll_loop_purges_expired_signals(monkeypatch):
|
||||
"""매 cycle 끝에 expired signal이 제거됨 (F5)."""
|
||||
from ai_trade import pull_worker
|
||||
from ai_trade.state import PollState
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import asyncio as _asyncio
|
||||
|
||||
_kst = _ZI("Asia/Seoul")
|
||||
now = _dt(2026, 5, 18, 10, 0, tzinfo=_kst)
|
||||
|
||||
class FrozenDT:
|
||||
@staticmethod
|
||||
def now(tz=None): return now
|
||||
|
||||
state = PollState()
|
||||
state.signals = {
|
||||
"OLD": {"ticker": "OLD", "expires_at": _dt(2026, 5, 18, 9, 0, tzinfo=_kst).isoformat(), "cycle_id": 1},
|
||||
"FRESH": {"ticker": "FRESH", "expires_at": _dt(2026, 5, 18, 10, 30, tzinfo=_kst).isoformat(), "cycle_id": 1},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(pull_worker, "datetime", FrozenDT)
|
||||
monkeypatch.setattr(pull_worker, "_is_market_day", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_is_polling_window", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_next_interval", lambda n: 0.01)
|
||||
monkeypatch.setattr(pull_worker, "_run_polling_cycle", AsyncMock())
|
||||
monkeypatch.setattr(pull_worker, "update_minute_momentum_for_all", lambda s: None)
|
||||
monkeypatch.setattr(pull_worker, "_is_post_close_trigger", lambda *a, **k: False)
|
||||
|
||||
shutdown = _asyncio.Event()
|
||||
async def stop_soon():
|
||||
await _asyncio.sleep(0.05)
|
||||
shutdown.set()
|
||||
_asyncio.create_task(stop_soon())
|
||||
|
||||
await pull_worker.poll_loop(
|
||||
client=MagicMock(), state=state, shutdown=shutdown,
|
||||
kis_client=MagicMock(), chronos=MagicMock(),
|
||||
dedup=None, settings=None,
|
||||
)
|
||||
assert "OLD" not in state.signals
|
||||
assert "FRESH" in state.signals
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to fail**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_pull_worker.py::test_poll_loop_purges_expired_signals -v
|
||||
```
|
||||
|
||||
Expected: FAIL — OLD가 남아있음.
|
||||
|
||||
- [ ] **Step 3: Add purge call in poll_loop**
|
||||
|
||||
`ai_trade/pull_worker.py` `poll_loop` 안, signals 생성 이후 (또는 cycle 끝 직전) 한 줄 추가:
|
||||
|
||||
```python
|
||||
# Phase 4: generate signals
|
||||
if dedup is not None and settings is not None:
|
||||
try:
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
generate_signals(state, dedup, settings)
|
||||
except Exception:
|
||||
logger.exception("generate_signals failed")
|
||||
# F5: 만료된 signal purge (consumer 미사용 케이스 보호)
|
||||
try:
|
||||
state.purge_expired_signals(datetime.now(KST))
|
||||
except Exception:
|
||||
logger.exception("purge_expired_signals failed")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests/test_pull_worker.py::test_poll_loop_purges_expired_signals -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run full suite**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -q
|
||||
```
|
||||
|
||||
Expected: 모두 PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ai_trade/pull_worker.py ai_trade/tests/test_pull_worker.py
|
||||
git commit -m "feat(ai_trade): poll_loop가 매 cycle 끝에 expired signal purge (F5 part 4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 전체 회귀 + push
|
||||
|
||||
- [ ] **Step 1: Final pytest**
|
||||
|
||||
```
|
||||
python -m pytest ai_trade/tests -v
|
||||
```
|
||||
|
||||
Expected: 모두 PASS (총 신규 약 9개 + 기존 56개).
|
||||
|
||||
- [ ] **Step 2: Push**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **expires_at + cycle_id 부착**: `_build_buy_signal`, `_build_sell_signal` 양쪽 ✅
|
||||
2. **cycle_id 증가**: `generate_signals` 진입에서 단 1회 ✅
|
||||
3. **purge**: poll_loop cycle 마지막에 1회 호출 ✅
|
||||
4. **get_active_signals**: Phase 5 consumer가 호출할 read API 존재 ✅
|
||||
5. **legacy 신호 호환**: `expires_at` 없는 신호는 expired 취급 → 안전 ✅
|
||||
|
||||
**미커버 항목 (의도적)**:
|
||||
- Phase 5 consumer가 처리 후 explicit drain하는 API는 이 plan에서 안 다룸 (consumer가 read-only로도 충분 — expires_at + dedup으로 idempotent).
|
||||
- agent-office `/signal` HTTP endpoint는 Phase 5 plan 영역.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-25-state-signals-lifecycle.md`.**
|
||||
|
||||
**1. Subagent-Driven (recommended)** — Task 별 fresh subagent.
|
||||
**2. Inline Execution** — 현 세션 실행.
|
||||
|
||||
박재오 결정 대기. Plan 1 (hotfix) 마친 뒤 진입 권장.
|
||||
765
docs/superpowers/plans/2026-06-11-agent-oversight-timeline.md
Normal file
@@ -0,0 +1,765 @@
|
||||
# 에이전트 횡단 오버사이트 타임라인 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** AgentOffice 우측 패널(에이전트 미선택 시)에 전 에이전트 활동을 시간순으로 보여주는 횡단 오버사이트 타임라인을 추가한다.
|
||||
|
||||
**Architecture:** 백엔드 `GET /api/agent-office/activity`(필터 지원)를 소비. `useActivityFeed` 훅이 페이지네이션·필터·WS refreshTrigger 재조회를 담당하고, `ActivityTimeline`이 `ActivityFilters` + `ActivityItem` 리스트 + IntersectionObserver 무한스크롤을 조립한다. AgentOffice는 `selectedAgent===null`일 때 기존 `EmptyDetailPanel`을 `ActivityTimeline`으로 교체한다.
|
||||
|
||||
**Tech Stack:** React 18, vitest + @testing-library/react(v16, `renderHook` 사용), 기존 `ao-*` CSS 컨벤션, `AGENT_META` 색상/표시명 재사용.
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `src/api.js` (수정) | `agentActivity({agent_id,type,status,days,limit,offset})` 헬퍼 추가 |
|
||||
| `src/pages/agent-office/hooks/useActivityFeed.js` (생성) | items/total/loading/error/hasMore 상태, 필터·refreshTrigger 재조회, loadMore append |
|
||||
| `src/pages/agent-office/components/ActivityItem.jsx` (생성) | 한 행: agent 색·표시명 + 메시지 + 상태/level 뱃지 + 시간/duration, 클릭 → onSelectAgent |
|
||||
| `src/pages/agent-office/components/ActivityFilters.jsx` (생성) | agent/type/status/days select 4종, type=log 시 status 비활성 |
|
||||
| `src/pages/agent-office/components/ActivityTimeline.jsx` (생성) | 컨테이너: 헤더 + 필터 + 리스트 + sentinel + 상태 |
|
||||
| `src/pages/agent-office/AgentOffice.jsx` (수정) | null 분기를 ActivityTimeline으로 교체 |
|
||||
| `src/pages/agent-office/AgentOffice.css` (수정) | 타임라인 baseline 스타일 (Task 7) → designer 마감 (Task 8) |
|
||||
| 각 `*.test.{js,jsx}` | hook/Item/Filters 단위 테스트 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `agentActivity` API 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api.js` (기존 `getActivityFeed` 줄 근처, 596라인 부근)
|
||||
|
||||
- [ ] **Step 1: 헬퍼 추가**
|
||||
|
||||
`src/api.js`에서 기존 줄
|
||||
```js
|
||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||
```
|
||||
바로 아래에 추가:
|
||||
```js
|
||||
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (agent_id) p.set('agent_id', agent_id);
|
||||
if (type) p.set('type', type);
|
||||
if (status) p.set('status', status);
|
||||
if (days) p.set('days', String(days));
|
||||
p.set('limit', String(limit));
|
||||
p.set('offset', String(offset));
|
||||
return apiGet(`/api/agent-office/activity?${p.toString()}`);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: lint 통과 확인**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 에러 없음 (no-unused-vars 등)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api.js
|
||||
git commit -m "feat(agent-office): agentActivity API 헬퍼 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `useActivityFeed` 훅 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/hooks/useActivityFeed.js`
|
||||
- Test: `src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/hooks/useActivityFeed.test.js`:
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useActivityFeed } from './useActivityFeed.js';
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => mockAgentActivity.mockReset());
|
||||
|
||||
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||
|
||||
describe('useActivityFeed', () => {
|
||||
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
|
||||
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { result.current.loadMore(); });
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||
});
|
||||
|
||||
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||
rerender({ rt: 1 });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('hasMore는 items.length < total', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||
Expected: FAIL — "Failed to resolve import './useActivityFeed.js'" 또는 useActivityFeed undefined
|
||||
|
||||
- [ ] **Step 3: 훅 구현**
|
||||
|
||||
`src/pages/agent-office/hooks/useActivityFeed.js`:
|
||||
```js
|
||||
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { agentActivity } from '../../../api';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const offsetRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const filterKey = JSON.stringify(filters);
|
||||
|
||||
const fetchPage = useCallback(async (offset, replace) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||
setTotal(data?.total || 0);
|
||||
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||
offsetRef.current = offset + newItems.length;
|
||||
} catch (e) {
|
||||
setError(e.message || '불러오기 실패');
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [filterKey, refreshTrigger, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingRef.current) return;
|
||||
if (offsetRef.current >= total) return;
|
||||
fetchPage(offsetRef.current, false);
|
||||
}, [fetchPage, total]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [fetchPage]);
|
||||
|
||||
const hasMore = items.length < total;
|
||||
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||
Expected: PASS (5 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/hooks/useActivityFeed.js src/pages/agent-office/hooks/useActivityFeed.test.js
|
||||
git commit -m "feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `ActivityItem` 컴포넌트 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/components/ActivityItem.jsx`
|
||||
- Test: `src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/components/ActivityItem.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
describe('ActivityItem', () => {
|
||||
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||
});
|
||||
|
||||
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||
Expected: FAIL — import 해결 실패
|
||||
|
||||
- [ ] **Step 3: 컴포넌트 구현**
|
||||
|
||||
`src/pages/agent-office/components/ActivityItem.jsx`:
|
||||
```jsx
|
||||
// src/pages/agent-office/components/ActivityItem.jsx
|
||||
import { AGENT_META } from '../constants.js';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
|
||||
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
|
||||
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
|
||||
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
|
||||
};
|
||||
|
||||
const LEVEL_STYLE = {
|
||||
error: { icon: '❌', cls: 'level-error' },
|
||||
warning: { icon: '⚠️', cls: 'level-warning' },
|
||||
info: { icon: '·', cls: 'level-info' },
|
||||
};
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
export default function ActivityItem({ item, onSelectAgent }) {
|
||||
const meta = AGENT_META[item.agent_id];
|
||||
const color = meta?.color || '#6b7280';
|
||||
const name = meta?.displayName || item.agent_id;
|
||||
const isTask = item.type === 'task';
|
||||
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
|
||||
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
|
||||
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||
onClick={() => onSelectAgent(item.agent_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||
<div className="ao-activity-body">
|
||||
<div className="ao-activity-line">
|
||||
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||
{isTask
|
||||
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||
</div>
|
||||
<div className="ao-activity-msg">{item.message}</div>
|
||||
</div>
|
||||
<div className="ao-activity-meta">
|
||||
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||
{isTask && item.duration_seconds != null && (
|
||||
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/components/ActivityItem.jsx src/pages/agent-office/components/ActivityItem.test.jsx
|
||||
git commit -m "feat(agent-office): ActivityItem (task/log 행 + 상태 뱃지)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ActivityFilters` 컴포넌트 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/components/ActivityFilters.jsx`
|
||||
- Test: `src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/components/ActivityFilters.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
|
||||
const base = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
describe('ActivityFilters', () => {
|
||||
it('type=log이면 상태 필터가 비활성화된다', () => {
|
||||
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||
});
|
||||
|
||||
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||
Expected: FAIL — import 해결 실패
|
||||
|
||||
- [ ] **Step 3: 컴포넌트 구현**
|
||||
|
||||
`src/pages/agent-office/components/ActivityFilters.jsx`:
|
||||
```jsx
|
||||
// src/pages/agent-office/components/ActivityFilters.jsx
|
||||
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'succeeded', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'pending', label: '대기' },
|
||||
];
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1일' },
|
||||
{ value: 7, label: '7일' },
|
||||
{ value: 30, label: '30일' },
|
||||
];
|
||||
|
||||
export default function ActivityFilters({ filters, onChange }) {
|
||||
const set = (patch) => onChange({ ...filters, ...patch });
|
||||
const statusDisabled = filters.type === 'log';
|
||||
return (
|
||||
<div className="ao-activity-filters">
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="에이전트 필터"
|
||||
value={filters.agent_id || ''}
|
||||
onChange={e => set({ agent_id: e.target.value })}
|
||||
>
|
||||
<option value="">모든 에이전트</option>
|
||||
{ACTIVE_AGENT_IDS.map(id => (
|
||||
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="타입 필터"
|
||||
value={filters.type || ''}
|
||||
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="상태 필터"
|
||||
value={filters.status || ''}
|
||||
disabled={statusDisabled}
|
||||
onChange={e => set({ status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="기간 필터"
|
||||
value={filters.days}
|
||||
onChange={e => set({ days: Number(e.target.value) })}
|
||||
>
|
||||
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/components/ActivityFilters.jsx src/pages/agent-office/components/ActivityFilters.test.jsx
|
||||
git commit -m "feat(agent-office): ActivityFilters (agent/type/status/days)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `ActivityTimeline` 컨테이너 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/components/ActivityTimeline.jsx`
|
||||
- Test: `src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||
|
||||
> 참고: jsdom에는 IntersectionObserver가 없으므로 테스트 setup에서 stub이 필요하다. Step 1에서 테스트 파일 상단에 직접 stub을 둔다(전역 test-setup 수정 없이 국소 처리).
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/components/ActivityTimeline.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||
|
||||
// jsdom IntersectionObserver stub
|
||||
beforeEach(() => {
|
||||
global.IntersectionObserver = class {
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
describe('ActivityTimeline', () => {
|
||||
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
const row = await screen.findByText('signal_check');
|
||||
fireEvent.click(row.closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||
Expected: FAIL — import 해결 실패
|
||||
|
||||
- [ ] **Step 3: 컴포넌트 구현**
|
||||
|
||||
`src/pages/agent-office/components/ActivityTimeline.jsx`:
|
||||
```jsx
|
||||
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '120px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/components/ActivityTimeline.jsx src/pages/agent-office/components/ActivityTimeline.test.jsx
|
||||
git commit -m "feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: AgentOffice 우측 패널 배선
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/agent-office/AgentOffice.jsx`
|
||||
|
||||
- [ ] **Step 1: import 추가**
|
||||
|
||||
`src/pages/agent-office/AgentOffice.jsx`에서
|
||||
```js
|
||||
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
||||
```
|
||||
바로 아래에 추가:
|
||||
```js
|
||||
import ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: null 분기 교체**
|
||||
|
||||
같은 파일에서
|
||||
```js
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = <EmptyDetailPanel variant="initial" />;
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
```
|
||||
를 아래로 변경:
|
||||
```js
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = (
|
||||
<ActivityTimeline
|
||||
refreshTrigger={refreshTrigger}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
);
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 전체 테스트 통과 확인 (회귀 없음)**
|
||||
|
||||
Run: `npm run test:run`
|
||||
Expected: PASS — 신규 테스트 포함 전부 통과, 기존 테스트 회귀 없음
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/AgentOffice.jsx
|
||||
git commit -m "feat(agent-office): 우측 기본 패널을 횡단 타임라인으로 교체"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: baseline CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/agent-office/AgentOffice.css` (파일 끝에 append)
|
||||
|
||||
- [ ] **Step 1: 스타일 추가**
|
||||
|
||||
`src/pages/agent-office/AgentOffice.css` 맨 끝에 추가:
|
||||
```css
|
||||
/* ── 횡단 오버사이트 타임라인 ── */
|
||||
.ao-activity { display: flex; flex-direction: column; min-height: 0; }
|
||||
.ao-activity-header { display: flex; align-items: center; }
|
||||
|
||||
.ao-activity-filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
.ao-activity-select {
|
||||
background: #111827; color: #e5e7eb;
|
||||
border: 1px solid #374151; border-radius: 6px;
|
||||
padding: 4px 8px; font-size: 12px;
|
||||
}
|
||||
.ao-activity-select:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
|
||||
.ao-activity-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid #161b25;
|
||||
cursor: pointer; transition: background .12s;
|
||||
}
|
||||
.ao-activity-item:hover { background: #161b25; }
|
||||
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, .08); }
|
||||
.ao-activity-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; }
|
||||
.ao-activity-body { flex: 1; min-width: 0; }
|
||||
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||
.ao-activity-agent { font-size: 12px; font-weight: 600; }
|
||||
.ao-activity-badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
|
||||
.ao-activity-level { font-size: 12px; }
|
||||
.ao-activity-msg {
|
||||
font-size: 13px; color: #cbd5e1; margin-top: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||
.ao-activity-time { font-size: 11px; color: #6b7280; }
|
||||
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||
|
||||
.ao-activity-loading,
|
||||
.ao-activity-end { text-align: center; padding: 12px; font-size: 12px; color: #6b7280; }
|
||||
.ao-activity-sentinel { height: 1px; }
|
||||
.ao-activity-error { padding: 12px; font-size: 13px; color: #fca5a5; }
|
||||
.ao-activity-error button {
|
||||
margin-left: 8px; background: #374151; color: #e5e7eb;
|
||||
border: none; border-radius: 6px; padding: 2px 10px; cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 개발 서버에서 시각 확인**
|
||||
|
||||
Run: `npm run dev` 후 브라우저에서 `http://localhost:3007/agent-office` 접속 → 우측 패널에 타임라인/필터/항목이 보이는지 확인 (에이전트 미선택 상태).
|
||||
Expected: 필터 4종 + 활동 항목 리스트 표시, 항목 클릭 시 SidePanel 전환
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/AgentOffice.css
|
||||
git commit -m "style(agent-office): 횡단 타임라인 baseline 스타일"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: designer 스킬 비주얼 마감 + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/agent-office/AgentOffice.css` (+ 필요 시 컴포넌트 className 미세 조정)
|
||||
|
||||
- [ ] **Step 1: designer 스킬 적용**
|
||||
|
||||
`designer` 스킬을 invoke하여 AgentOffice 다크 미감과 일관된 타임라인 비주얼로 마감 (에이전트 색 강조, 상태 뱃지 가독성, 펄스 애니메이션, 밀도/여백). 기능/마크업 구조는 유지하고 스타일만 개선.
|
||||
|
||||
- [ ] **Step 2: lint + 전체 테스트 + 빌드 검증**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run test:run
|
||||
npm run build
|
||||
```
|
||||
Expected: lint 0 error, 전체 테스트 PASS, build 성공
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "style(agent-office): designer 마감 — 횡단 오버사이트 타임라인"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 체크리스트 (작성자 검증 완료)
|
||||
|
||||
- **Spec coverage:** agentActivity 헬퍼(T1) ✓ / useActivityFeed 필터·페이지네이션·refreshTrigger(T2) ✓ / 상태·level 뱃지 + agent 색 + 클릭(T3) ✓ / 필터 4종 + log시 status 비활성(T4) ✓ / 무한스크롤·empty·error·end(T5) ✓ / AgentOffice 배선(T6) ✓ / 비주얼(T7·T8) ✓ — spec 전 항목 커버.
|
||||
- **Placeholder scan:** 모든 step에 실제 코드/명령/기대출력 포함, TBD 없음.
|
||||
- **Type consistency:** `useActivityFeed(filters, refreshTrigger)` 반환 `{items,total,loading,error,hasMore,loadMore,retry}` — T5에서 동일 사용. `onSelectAgent(agent_id)` 시그니처 T3/T5/T6 일치. `AGENT_META`/`ACTIVE_AGENT_IDS` import 경로 `../constants.js` 일치. `agentActivity({...})` 객체 인자 T1 정의 ↔ T2 호출 일치.
|
||||
- **Known caveat:** jsdom IntersectionObserver 없음 → T5 테스트 상단 stub으로 처리(전역 setup 미수정).
|
||||
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
@@ -0,0 +1,906 @@
|
||||
# 관심종목 탭 (Watchlist Tab) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `/stock/trade` 거래 데스크에 관심종목 CRUD + 최근 매매 시그널 알림 이력을 보여주는 "관심종목" 탭을 추가한다.
|
||||
|
||||
**Architecture:** 순수 헬퍼(`watchlistUtils.js`) → API 헬퍼(`api.js`) → 상태 훅(`useWatchlist.js`) → 표현 컴포넌트(`WatchlistTab.jsx`) → 탭 등재(`StockTrade.jsx`). 기존 `HoldingsIntelTab`/`usePortfolio` 패턴(훅을 `StockTrade`에서 인스턴스화해 탭에 props로 전달)을 그대로 따른다.
|
||||
|
||||
**Tech Stack:** React 18 (함수형 + hooks), Vite, Vitest + @testing-library/react, 기존 `apiGet/apiPost/apiDelete` 헬퍼.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API는 항상 상대경로** (`/api/...`). 절대 URL 금지 (Mixed Content).
|
||||
- **모든 fetch는 `src/api.js`의 `apiGet/apiPost/apiDelete` 경유.**
|
||||
- 테스트: `import { describe, it, expect } from 'vitest'`. 실행 `npm run test:run`. 파일 컨벤션 `*.test.js(x)` 동일 디렉토리 배치.
|
||||
- 색상: 매수 `#22c55e`, 매도 `#ef4444` (기존 `ACTION_MAP` 팔레트 일치).
|
||||
- CSS 토큰 재사용: `--line`, `--surface`, `--radius-lg`, `--muted`, `--accent-stock`. 카드 관례: `background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.12); border-radius: 10px`.
|
||||
- 커밋은 `web-ui` 경로에서만. `.env`·무관 파일 커밋 금지 (변경 파일만 명시적 `git add`).
|
||||
- BE 계약 (소비 대상):
|
||||
- `GET /api/stock/watchlist` → `{ watchlist: [{ ticker, name, note, params, added_at }] }`
|
||||
- `POST /api/stock/watchlist` body `{ ticker, name?, note? }` → `{ ok: true }`
|
||||
- `DELETE /api/stock/watchlist/{ticker}` → 200/404
|
||||
- `GET /api/stock/trade-alerts?days=N` → `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }`
|
||||
- `kind`: `buy`|`sell`. `condition`: `buy_ma20_pullback`/`buy_breakout`/`buy_rsi_bounce`/`sell_stop_loss`/`sell_ma_break`/`sell_take_profit`/`sell_climax`/`sell_trailing_stop`.
|
||||
- 응답은 방어적 파싱: 배열 직접 반환 / 래핑(`watchlist`·`alerts`) 둘 다 허용.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 순수 헬퍼 `watchlistUtils.js` (라벨/색/시간 매핑)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/watchlistUtils.js`
|
||||
- Test: `src/pages/stock/watchlistUtils.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `KIND_META: { buy: {label,color,bg}, sell: {label,color,bg} }`
|
||||
- `kindMeta(kind: string) => { label, color, bg }` (미정의 → 회색 폴백 + 원문 label)
|
||||
- `CONDITION_LABEL: Record<string,string>`
|
||||
- `conditionLabel(cond: string) => string` (미정의 → 원문 폴백)
|
||||
- `normalizeTicker(str) => string` (trim만)
|
||||
- `relativeTime(iso: string, now?: number) => string`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/pages/stock/watchlistUtils.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js';
|
||||
|
||||
describe('kindMeta', () => {
|
||||
it('buy/sell 라벨과 색을 반환', () => {
|
||||
expect(kindMeta('buy').label).toBe('매수');
|
||||
expect(kindMeta('buy').color).toBe('#22c55e');
|
||||
expect(kindMeta('sell').label).toBe('매도');
|
||||
expect(kindMeta('sell').color).toBe('#ef4444');
|
||||
});
|
||||
it('미정의 kind는 회색 폴백 + 원문 label', () => {
|
||||
const m = kindMeta('weird');
|
||||
expect(m.label).toBe('weird');
|
||||
expect(m.color).toBe('#94a3b8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionLabel', () => {
|
||||
it('정의된 8종을 한글로 매핑', () => {
|
||||
expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등');
|
||||
expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파');
|
||||
expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등');
|
||||
expect(conditionLabel('sell_stop_loss')).toBe('손절 라인');
|
||||
expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈');
|
||||
expect(conditionLabel('sell_take_profit')).toBe('목표가 도달');
|
||||
expect(conditionLabel('sell_climax')).toBe('과열 소진');
|
||||
expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱');
|
||||
});
|
||||
it('미정의 condition은 원문 폴백', () => {
|
||||
expect(conditionLabel('buy_unknown')).toBe('buy_unknown');
|
||||
expect(conditionLabel(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTicker', () => {
|
||||
it('공백 trim', () => {
|
||||
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
||||
expect(normalizeTicker(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const now = new Date('2026-07-03T12:00:00Z').getTime();
|
||||
it('60초 미만은 방금', () => {
|
||||
expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금');
|
||||
});
|
||||
it('분/시간/어제/일 경계', () => {
|
||||
expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전');
|
||||
expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전');
|
||||
expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제');
|
||||
expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전');
|
||||
});
|
||||
it('잘못된/빈 값은 빈 문자열', () => {
|
||||
expect(relativeTime('', now)).toBe('');
|
||||
expect(relativeTime('not-a-date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./watchlistUtils.js"` (파일 없음).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `src/pages/stock/watchlistUtils.js`:
|
||||
|
||||
```js
|
||||
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
|
||||
|
||||
export const KIND_META = {
|
||||
buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
|
||||
sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
};
|
||||
|
||||
const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
|
||||
|
||||
export const kindMeta = (kind) => {
|
||||
const meta = KIND_META[kind];
|
||||
if (meta) return meta;
|
||||
return { ...FALLBACK_KIND, label: kind ?? '' };
|
||||
};
|
||||
|
||||
export const CONDITION_LABEL = {
|
||||
buy_ma20_pullback: 'MA20 눌림 반등',
|
||||
buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등',
|
||||
sell_stop_loss: '손절 라인',
|
||||
sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달',
|
||||
sell_climax: '과열 소진',
|
||||
sell_trailing_stop: '트레일링 스톱',
|
||||
};
|
||||
|
||||
export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? '';
|
||||
|
||||
export const normalizeTicker = (str) => String(str ?? '').trim();
|
||||
|
||||
export const relativeTime = (iso, now = Date.now()) => {
|
||||
if (!iso) return '';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return '방금';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return '방금';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}시간 전`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return '어제';
|
||||
if (day < 7) return `${day}일 전`;
|
||||
return new Date(iso).toLocaleDateString('ko-KR');
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js`
|
||||
Expected: PASS (4 describe 블록 전부 통과).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/watchlistUtils.js src/pages/stock/watchlistUtils.test.js
|
||||
git commit -m "feat(stock): 관심종목 탭 순수 헬퍼(watchlistUtils) + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: API 헬퍼 + `useWatchlist` 훅
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api.js` (파일 끝에 추가)
|
||||
- Create: `src/pages/stock/hooks/useWatchlist.js`
|
||||
- Test: `src/pages/stock/hooks/useWatchlist.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `normalizeTicker`
|
||||
- Produces:
|
||||
- `api.js`: `getWatchlist()`, `addWatchlist(body)`, `removeWatchlist(ticker)`, `getTradeAlerts(days=7)`
|
||||
- `useWatchlist() => { items, alerts, alertDays, setAlertDays, loading, error, alertError, adding, add, remove, reload }`
|
||||
- `add({ ticker, name?, note? })` — 낙관적 추가 후 `reload`, 실패 시 롤백
|
||||
- `remove(ticker)` — 낙관적 제거, 실패 시 롤백
|
||||
|
||||
- [ ] **Step 1: Add API helpers**
|
||||
|
||||
`src/api.js` 파일 맨 끝(마지막 `compatDeleteReading` 함수 뒤)에 추가:
|
||||
|
||||
```js
|
||||
// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ──
|
||||
// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] }
|
||||
// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true }
|
||||
// DELETE /api/stock/watchlist/{ticker} → 200/404
|
||||
// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing hook test**
|
||||
|
||||
Create `src/pages/stock/hooks/useWatchlist.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getWatchlist: vi.fn(),
|
||||
addWatchlist: vi.fn(),
|
||||
removeWatchlist: vi.fn(),
|
||||
getTradeAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import useWatchlist from './useWatchlist';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] });
|
||||
getTradeAlerts.mockResolvedValue({ alerts: [] });
|
||||
addWatchlist.mockResolvedValue({ ok: true });
|
||||
removeWatchlist.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe('useWatchlist', () => {
|
||||
it('마운트 시 watchlist를 로드', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('005930');
|
||||
});
|
||||
|
||||
it('배열 직접 반환도 방어적으로 파싱', async () => {
|
||||
getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]);
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('000660');
|
||||
});
|
||||
|
||||
it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => {
|
||||
getWatchlist
|
||||
.mockResolvedValueOnce({ watchlist: [] })
|
||||
.mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] });
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
|
||||
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
|
||||
await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true));
|
||||
});
|
||||
|
||||
it('add 실패 시 롤백 + error', async () => {
|
||||
getWatchlist.mockResolvedValue({ watchlist: [] });
|
||||
addWatchlist.mockRejectedValue(new Error('HTTP 500 err'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { await result.current.add({ ticker: '000660' }); });
|
||||
await waitFor(() => expect(result.current.error).toContain('HTTP 500'));
|
||||
expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false);
|
||||
});
|
||||
|
||||
it('중복 ticker는 add 차단', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.add({ ticker: '005930' }); });
|
||||
expect(addWatchlist).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toContain('이미');
|
||||
});
|
||||
|
||||
it('remove: 낙관적 제거 + DELETE 호출', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.remove('005930'); });
|
||||
expect(removeWatchlist).toHaveBeenCalledWith('005930');
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => {
|
||||
getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await waitFor(() => expect(result.current.alertError).toContain('HTTP 404'));
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./useWatchlist"` (파일 없음).
|
||||
|
||||
- [ ] **Step 4: Write the hook**
|
||||
|
||||
Create `src/pages/stock/hooks/useWatchlist.js`:
|
||||
|
||||
```js
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import { normalizeTicker } from '../watchlistUtils';
|
||||
|
||||
const asArray = (data, key) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data[key])) return data[key];
|
||||
return [];
|
||||
};
|
||||
|
||||
const byFiredAtDesc = (a, b) =>
|
||||
new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime();
|
||||
|
||||
export default function useWatchlist() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [alertDays, setAlertDays] = useState(7);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [alertError, setAlertError] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getWatchlist();
|
||||
setItems(asArray(data, 'watchlist'));
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAlerts = useCallback(async (days) => {
|
||||
setAlertError('');
|
||||
try {
|
||||
const data = await getTradeAlerts(days);
|
||||
setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc));
|
||||
} catch (e) {
|
||||
setAlertError(e?.message ?? String(e));
|
||||
setAlerts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWatchlist(); }, [loadWatchlist]);
|
||||
useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]);
|
||||
|
||||
const add = useCallback(async ({ ticker, name, note }) => {
|
||||
const t = normalizeTicker(ticker);
|
||||
if (!t) return;
|
||||
if (items.some((it) => it.ticker === t)) {
|
||||
setError(`이미 관심종목에 있습니다: ${t}`);
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError('');
|
||||
const cleanName = (name ?? '').trim();
|
||||
const cleanNote = (note ?? '').trim();
|
||||
const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() };
|
||||
setItems((prev) => [optimistic, ...prev]);
|
||||
try {
|
||||
await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined });
|
||||
await loadWatchlist();
|
||||
} catch (e) {
|
||||
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [items, loadWatchlist]);
|
||||
|
||||
const remove = useCallback(async (ticker) => {
|
||||
const prev = items;
|
||||
setItems((cur) => cur.filter((it) => it.ticker !== ticker));
|
||||
setError('');
|
||||
try {
|
||||
await removeWatchlist(ticker);
|
||||
} catch (e) {
|
||||
setItems(prev); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
items, alerts, alertDays, setAlertDays,
|
||||
loading, error, alertError, adding,
|
||||
add, remove, reload: loadWatchlist,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js`
|
||||
Expected: PASS (7 케이스 통과).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api.js src/pages/stock/hooks/useWatchlist.js src/pages/stock/hooks/useWatchlist.test.js
|
||||
git commit -m "feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `WatchlistTab.jsx` 컴포넌트 + 스타일
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/WatchlistTab.jsx`
|
||||
- Modify: `src/pages/stock/Stock.css` (파일 끝에 `wl-*` 섹션 추가)
|
||||
- Test: `src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `kindMeta`, `conditionLabel`, `relativeTime`; (stockUtils) `formatNumber`; (Task 2) `useWatchlist` 반환 형태 — 단, 컴포넌트는 훅 결과를 `wl` **prop**으로 받는다(테스트/뱃지 용이).
|
||||
- Produces: `WatchlistTab({ wl })` 기본 export (React 컴포넌트).
|
||||
|
||||
- [ ] **Step 1: Write the failing smoke test**
|
||||
|
||||
Create `src/pages/stock/components/WatchlistTab.test.jsx`:
|
||||
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import WatchlistTab from './WatchlistTab.jsx';
|
||||
|
||||
const baseWl = {
|
||||
items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(),
|
||||
loading: false, error: '', alertError: '', adding: false,
|
||||
add: vi.fn(), remove: vi.fn(), reload: vi.fn(),
|
||||
};
|
||||
|
||||
describe('WatchlistTab', () => {
|
||||
it('빈 상태: 헤딩과 빈 안내 노출', () => {
|
||||
render(<WatchlistTab wl={baseWl} />);
|
||||
expect(screen.getByText('관심종목 관리')).toBeInTheDocument();
|
||||
expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('종목·알림이 있으면 렌더', () => {
|
||||
const wl = {
|
||||
...baseWl,
|
||||
items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }],
|
||||
alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }],
|
||||
};
|
||||
render(<WatchlistTab wl={wl} />);
|
||||
expect(screen.getByText('삼성전자')).toBeInTheDocument();
|
||||
expect(screen.getByText('매수')).toBeInTheDocument();
|
||||
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> 참고: `toBeInTheDocument` 매처는 `@testing-library/jest-dom`(devDependency)에서 제공된다. 기존 테스트 셋업에서 전역 등록이 안 되어 있으면 테스트 파일 상단에 `import '@testing-library/jest-dom';` 한 줄을 추가한다.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
Expected: FAIL — `Failed to resolve import "./WatchlistTab.jsx"` (파일 없음).
|
||||
|
||||
- [ ] **Step 3: Write the component**
|
||||
|
||||
Create `src/pages/stock/components/WatchlistTab.jsx`:
|
||||
|
||||
```jsx
|
||||
import React, { useState } from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils';
|
||||
import { formatNumber } from '../stockUtils';
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1D' },
|
||||
{ value: 7, label: '7D' },
|
||||
{ value: 30, label: '30D' },
|
||||
];
|
||||
|
||||
const AlertCard = ({ a }) => {
|
||||
const meta = kindMeta(a.kind);
|
||||
return (
|
||||
<div className="wl-alert">
|
||||
<div className="wl-alert__head">
|
||||
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
|
||||
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
|
||||
<span className="wl-alert__ticker">{a.ticker}</span>
|
||||
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
|
||||
</div>
|
||||
<div className="wl-alert__body">
|
||||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||||
</div>
|
||||
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchlistTab = ({ wl }) => {
|
||||
const [form, setForm] = useState({ ticker: '', name: '', note: '' });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.ticker.trim()) return;
|
||||
await wl.add(form);
|
||||
setForm({ ticker: '', name: '', note: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 관심종목 관리 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">관심종목</p>
|
||||
<h3>관심종목 관리</h3>
|
||||
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
|
||||
</div>
|
||||
|
||||
<form className="wl-form" onSubmit={submit}>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목코드 (예: 005930)"
|
||||
value={form.ticker}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목명 (선택)"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="메모 (선택)"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
|
||||
{wl.adding ? '추가 중…' : '추가'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{wl.error && <p className="stock-error">{wl.error}</p>}
|
||||
|
||||
{wl.items.length === 0 ? (
|
||||
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
|
||||
) : (
|
||||
<ul className="wl-list">
|
||||
{wl.items.map((it) => (
|
||||
<li key={it.ticker} className="wl-row">
|
||||
<div className="wl-row__meta">
|
||||
<strong className="wl-row__name">{it.name || it.ticker}</strong>
|
||||
<span className="wl-row__ticker">{it.ticker}</span>
|
||||
{it.note && <span className="wl-row__note">{it.note}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="wl-del"
|
||||
type="button"
|
||||
aria-label={`${it.ticker} 삭제`}
|
||||
onClick={() => wl.remove(it.ticker)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 최근 시그널 알림 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">시그널</p>
|
||||
<h3>최근 매매 알림</h3>
|
||||
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
|
||||
</div>
|
||||
<div className="wl-period-toggle">
|
||||
{DAYS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
|
||||
onClick={() => wl.setAlertDays(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
|
||||
|
||||
{wl.alerts.length === 0 ? (
|
||||
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
|
||||
) : (
|
||||
<div className="wl-alerts">
|
||||
{wl.alerts.map((a) => (
|
||||
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hi-disclaimer">※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchlistTab;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Append styles to `Stock.css`**
|
||||
|
||||
`src/pages/stock/Stock.css` 파일 맨 끝에 추가:
|
||||
|
||||
```css
|
||||
/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */
|
||||
.wl-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.wl-form__input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wl-form__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-stock);
|
||||
}
|
||||
|
||||
.wl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.wl-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wl-row__name { font-size: 14px; }
|
||||
.wl-row__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; }
|
||||
.wl-del {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.wl-period-toggle { display: flex; gap: 4px; }
|
||||
.wl-period {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wl-period.is-active {
|
||||
color: var(--accent-stock);
|
||||
border-color: var(--accent-stock);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.wl-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.wl-alert {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wl-alert__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-kind-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wl-alert__name { font-size: 14px; }
|
||||
.wl-alert__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.wl-alert__body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-cond { font-size: 13px; font-weight: 600; }
|
||||
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
||||
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
Expected: PASS (2 케이스 통과).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/components/WatchlistTab.jsx src/pages/stock/components/WatchlistTab.test.jsx src/pages/stock/Stock.css
|
||||
git commit -m "feat(stock): WatchlistTab 컴포넌트 + wl-* 스타일 + 스모크 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `StockTrade`에 탭 등재 + 문서 갱신
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/stock/stockUtils.js:152` (TAB 상수 추가)
|
||||
- Modify: `src/pages/stock/StockTrade.jsx` (import·훅·탭 배열·렌더)
|
||||
- Modify: `CLAUDE.md` (API 엔드포인트 테이블 — web-ui 루트가 아닌 `web-ui/CLAUDE.md`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 2·3): `useWatchlist`, `WatchlistTab`
|
||||
- Produces: 없음 (통합 지점, 최종 배선)
|
||||
|
||||
- [ ] **Step 1: Add TAB constant**
|
||||
|
||||
`src/pages/stock/stockUtils.js` 맨 끝(`export const TAB_HOLDINGS_INTEL = 'holdings_intel';` 뒤)에 추가:
|
||||
|
||||
```js
|
||||
export const TAB_WATCHLIST = 'watchlist';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire into StockTrade.jsx**
|
||||
|
||||
`src/pages/stock/StockTrade.jsx` 수정 — 4곳:
|
||||
|
||||
(a) stockUtils import에 `TAB_WATCHLIST` 추가 (기존 import 블록 line 6-10):
|
||||
|
||||
```js
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
|
||||
} from './stockUtils';
|
||||
```
|
||||
|
||||
(b) 탭 컴포넌트 import 추가 (기존 `import HoldingsIntelTab ...` 뒤, line 25 근처):
|
||||
|
||||
```js
|
||||
import HoldingsIntelTab from './components/HoldingsIntelTab';
|
||||
import WatchlistTab from './components/WatchlistTab';
|
||||
```
|
||||
|
||||
(c) 훅 인스턴스화 + `TAB_ORDER`/`tabLabels` 확장. `const [activeTab, ...]` 아래(line 31 근처)와 hooks 블록에 추가:
|
||||
|
||||
```js
|
||||
const wl = useWatchlist();
|
||||
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목'];
|
||||
```
|
||||
|
||||
그리고 파일 상단 hooks import 목록에 훅 import 추가 (line 19 `import useAdvisor ...` 뒤):
|
||||
|
||||
```js
|
||||
import useAdvisor from './hooks/useAdvisor';
|
||||
import useWatchlist from './hooks/useWatchlist';
|
||||
```
|
||||
|
||||
`const wl = useWatchlist();` 는 다른 훅들(`const advisor = useAdvisor({...});`) 뒤에 배치.
|
||||
|
||||
(d) 모바일 SwipeableView content 분기에 watchlist 추가. 기존 `: <HoldingsIntelTab />,` 를 다음으로 교체:
|
||||
|
||||
```js
|
||||
content: tabId === TAB_PORTFOLIO
|
||||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
: tabId === TAB_REPORT
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: tabId === TAB_ADVISOR
|
||||
? <AdvisorTab pf={pf} advisor={advisor} />
|
||||
: tabId === TAB_HOLDINGS_INTEL
|
||||
? <HoldingsIntelTab />
|
||||
: <WatchlistTab wl={wl} />,
|
||||
```
|
||||
|
||||
(e) 데스크탑 탭 버튼 배열에 항목 추가. 기존 `{ id: TAB_HOLDINGS_INTEL, ... }` 항목 뒤에 추가:
|
||||
|
||||
```js
|
||||
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
|
||||
{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null },
|
||||
```
|
||||
|
||||
(f) 데스크탑 조건부 렌더 추가. 기존 `{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}` 뒤에 추가:
|
||||
|
||||
```js
|
||||
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
|
||||
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `npm run test:run`
|
||||
Expected: PASS — 신규 3개 테스트 파일 포함 전체 통과 (기존 테스트 회귀 없음).
|
||||
|
||||
- [ ] **Step 4: Lint + build**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 신규 파일 관련 에러 0. (기존 코드의 사전 경고는 무시하되, 신규 파일이 새 에러를 만들지 않을 것.)
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 빌드 성공 (`dist/` 생성, 에러 없음).
|
||||
|
||||
- [ ] **Step 5: Manual verification (dev server)**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
브라우저에서 `http://localhost:3007/stock/trade` 접속 → "관심종목" 탭이 데스크탑 탭바(⭐)와 모바일 스와이프에 노출되는지 확인. 종목코드 입력 후 추가 → 목록 반영, 삭제 버튼 동작, 기간 토글(1D/7D/30D) 확인. (BE 미배포 시 알림 패널은 에러/빈 상태로 표시되고 CRUD는 독립 동작해야 함.)
|
||||
|
||||
- [ ] **Step 6: Update `web-ui/CLAUDE.md` API 테이블**
|
||||
|
||||
`CLAUDE.md` (web-ui 프로젝트 루트) 의 "API 엔드포인트 목록" 테이블에 행 추가 (스크리너 관련 행 근처):
|
||||
|
||||
```markdown
|
||||
| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } |
|
||||
| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } |
|
||||
| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` |
|
||||
| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } |
|
||||
```
|
||||
|
||||
그리고 페이지 구조 표의 `/stock/trade` 행 설명에 "(포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭)" 취지를 반영.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/stockUtils.js src/pages/stock/StockTrade.jsx CLAUDE.md
|
||||
git commit -m "feat(stock): 거래 데스크에 관심종목 탭 등재 + API 문서 갱신"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§10 대비):
|
||||
- §2 계약 4종 → Task 2 (api 헬퍼) ✅
|
||||
- §3 탭 등재 → Task 4 ✅
|
||||
- §4 컴포넌트 구조(훅+자립형 탭+utils) → Task 1/2/3 ✅
|
||||
- §5 API 레이어 → Task 2 ✅
|
||||
- §6 UX(낙관적 갱신·중복 차단·기간 토글·정렬) → Task 2(훅)·Task 3(뷰) ✅
|
||||
- §7 스타일 `wl-*` → Task 3 ✅
|
||||
- §8 테스트 → Task 1(utils)·Task 2(훅)·Task 3(컴포넌트) ✅
|
||||
- §9 완료 기준 → Task 4 Step 3–6 ✅
|
||||
- §10 리스크(방어적 파싱·알림 독립) → `asArray` + `alertError` 분리 ✅
|
||||
|
||||
**Placeholder scan:** 모든 코드/명령/기대출력 구체값 명시. TBD/TODO 없음. ✅
|
||||
|
||||
**Type consistency:** `kindMeta`/`conditionLabel`/`relativeTime`/`normalizeTicker` (Task1) ↔ 훅/컴포넌트 사용처 일치. `useWatchlist` 반환 키(`items/alerts/alertDays/setAlertDays/loading/error/alertError/adding/add/remove/reload`) ↔ `WatchlistTab` prop 사용처 일치. `getWatchlist/addWatchlist/removeWatchlist/getTradeAlerts` (api) ↔ 훅 import 일치. ✅
|
||||
|
||||
**참고 — StockTrade 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.
|
||||
@@ -0,0 +1,368 @@
|
||||
# RealEstate.jsx 분해 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `RealEstate.jsx`(909줄)를 기능 모듈 구조(shell + hooks/useComplexes + realEstateUtils + components/5)로 **동작 보존 분해**한다.
|
||||
|
||||
**Architecture:** 순수 추출. 상수·데이터·순수헬퍼→`realEstateUtils.js`, 표현 컴포넌트 5개→`components/*.jsx`(verbatim), 데이터 로직(complexes+CRUD)→`hooks/useComplexes.js`, `RealEstate.jsx`는 UI 상태+조합 shell(~150줄)로 축소. 각 Task는 코드 이동 후 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
|
||||
|
||||
**Tech Stack:** React 18 + Vite, react-leaflet/leaflet, recharts, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼 본문(JSX·로직)은 verbatim. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
|
||||
- 경로: `RealEstate.jsx`(`src/pages/realestate/`) api `'../../api'`; `components/*`·`hooks/*`는 api `'../../../api'`, utils `'../realEstateUtils'`; shell → utils `'./realEstateUtils'`·hook `'./hooks/useComplexes'`·components `'./components/*'`·CSS `'./RealEstate.css'`. `realEstateUtils.js`는 `leaflet`의 `L`만 import(createMarkerIcon), api import 없음.
|
||||
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
|
||||
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js` 동일 디렉토리.
|
||||
- 각 컴포넌트/파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 커밋은 web-ui, 변경 파일만 `git add`. 커밋 trailer:
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `realEstateUtils.js` — 상수·데이터·순수헬퍼 추출 + 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/realestate/realEstateUtils.js`
|
||||
- Test: `src/pages/realestate/realEstateUtils.test.js`
|
||||
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SAMPLE_COMPLEXES`, `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS`, `formatDate(d)`, `formatPrice(v)`, `getDDays(dateStr)`, `createMarkerIcon(status, isSelected)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** — Create `src/pages/realestate/realEstateUtils.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate, formatPrice, getDDays } from './realEstateUtils.js';
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('만원 표기 / falsy → -', () => {
|
||||
expect(formatPrice(4500)).toBe('4,500만원');
|
||||
expect(formatPrice(0)).toBe('-');
|
||||
expect(formatPrice(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDDays (오늘 기준)', () => {
|
||||
const iso = (off) => {
|
||||
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + off);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||
};
|
||||
it('오늘=D-Day, 미래=D-N, 과거=D+N, null→null', () => {
|
||||
expect(getDDays(iso(0))).toBe('D-Day');
|
||||
expect(getDDays(iso(4))).toBe('D-4');
|
||||
expect(getDDays(iso(-2))).toBe('D+2');
|
||||
expect(getDDays(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('유효 날짜 한국어 포맷 / falsy → -', () => {
|
||||
expect(formatDate('2026-04-10')).toContain('2026');
|
||||
expect(formatDate(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/realestate/realEstateUtils.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./realEstateUtils.js"`.
|
||||
|
||||
- [ ] **Step 3: Create realEstateUtils.js** — 상단 `import L from 'leaflet';` 후, 현 `RealEstate.jsx`의 아래 항목을 **verbatim** 이동하고 각각 `export`:
|
||||
- `SAMPLE_COMPLEXES` (현 RealEstate.jsx의 4개 단지 배열 전체를 그대로 복사) — `export const SAMPLE_COMPLEXES = [...]`
|
||||
- `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS` (verbatim) — 각 `export const`
|
||||
- `formatDate`, `formatPrice`, `getDDays` (verbatim) — 각 `export const`
|
||||
- `createMarkerIcon` (verbatim, `L.divIcon` 사용) — `export const createMarkerIcon = (status, isSelected = false) => {...}`
|
||||
|
||||
명시적 내용(작은 것들, verbatim):
|
||||
```js
|
||||
export const STATUS_CONFIG = {
|
||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||
};
|
||||
export const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||
export const EMPTY_FORM = {
|
||||
name: '', address: '', lat: '', lng: '',
|
||||
units: '', types: '', avgPricePerPyeong: '',
|
||||
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||
status: '청약예정', priority: 'normal',
|
||||
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||
};
|
||||
export const TABS = ['목록', '일정', '분석'];
|
||||
|
||||
export const formatDate = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
export const formatPrice = (v) => {
|
||||
if (!v) return '-';
|
||||
return `${v.toLocaleString()}만원`;
|
||||
};
|
||||
export const getDDays = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
const target = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||
if (diff === 0) return 'D-Day';
|
||||
if (diff > 0) return `D-${diff}`;
|
||||
return `D+${Math.abs(diff)}`;
|
||||
};
|
||||
export const createMarkerIcon = (status, isSelected = false) => {
|
||||
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||
const size = isSelected ? 18 : 12;
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${cfg.color};
|
||||
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||
transition:all 0.2s;
|
||||
"></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -(size / 2 + 4)],
|
||||
});
|
||||
};
|
||||
```
|
||||
`SAMPLE_COMPLEXES`는 현 RealEstate.jsx:14-91의 배열을 그대로 옮겨 `export const SAMPLE_COMPLEXES = [ ... ];`로.
|
||||
|
||||
- [ ] **Step 4: Wire RealEstate.jsx** — 위 항목들의 기존 정의(SAMPLE_COMPLEXES/STATUS_CONFIG/PRIORITY_LABELS/EMPTY_FORM/TABS/formatDate/formatPrice/getDDays/createMarkerIcon)를 삭제하고, import 추가:
|
||||
```js
|
||||
import {
|
||||
SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS,
|
||||
formatDate, formatPrice, getDDays, createMarkerIcon,
|
||||
} from './realEstateUtils';
|
||||
```
|
||||
(이 시점엔 컴포넌트들이 아직 RealEstate.jsx 내부 → import한 심볼 사용. `import L from 'leaflet'`는 createMarkerIcon이 utils로 갔으니 RealEstate.jsx에서 더 이상 직접 안 쓰면 제거; 단 다른 곳(예: 없음)에서 안 쓰면 제거. `import 'leaflet/dist/leaflet.css'`는 지도 렌더 위해 shell 또는 RightPanel에 유지 필요 → 이번 단계는 RealEstate.jsx에 유지.)
|
||||
|
||||
- [ ] **Step 5: Run tests + build**
|
||||
|
||||
Run: `npm run test:run -- src/pages/realestate/realEstateUtils.test.js` → PASS.
|
||||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규/수정 파일 0 에러(미사용 import 없도록).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add src/pages/realestate/realEstateUtils.js src/pages/realestate/realEstateUtils.test.js src/pages/realestate/RealEstate.jsx
|
||||
git commit -m "refactor(realestate): 상수·데이터·순수헬퍼를 realEstateUtils로 추출 + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 표현 컴포넌트 5개 추출 (ComplexCard/RightPanel/ScheduleView/PriceAnalysis/ComplexModal)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/realestate/components/ComplexCard.jsx`
|
||||
- Create: `src/pages/realestate/components/RightPanel.jsx`
|
||||
- Create: `src/pages/realestate/components/ScheduleView.jsx`
|
||||
- Create: `src/pages/realestate/components/PriceAnalysis.jsx`
|
||||
- Create: `src/pages/realestate/components/ComplexModal.jsx`
|
||||
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): utils 심볼.
|
||||
- Produces: 각 기본 export — `ComplexCard({complex,isSelected,onClick})`, `RightPanel({complexes,selectedComplex,onSelectComplex,onEdit,onDelete})`, `ScheduleView({complexes})`, `PriceAnalysis({complexes})`, `ComplexModal({complex,onClose,onSave})`.
|
||||
|
||||
- [ ] **Step 1: Create the five components (본문 verbatim + 상단 import)**
|
||||
|
||||
각 새 파일 = 상단 import 블록 + 현 RealEstate.jsx의 해당 함수 본문 verbatim + `export default <Name>;`. **`MapFlyTo`는 RightPanel.jsx 안에 내부 컴포넌트로 함께 이동**(현 RealEstate.jsx의 MapFlyTo 정의를 RightPanel.jsx 상단에 그대로 두고 RightPanel이 사용). `EventItem`은 ScheduleView 내부, `CustomTooltip`은 PriceAnalysis 내부에 이미 중첩되어 있으므로 그대로.
|
||||
|
||||
import 블록(각 컴포넌트가 실제 사용하는 심볼만 — 아래는 현 코드 사용 기준):
|
||||
|
||||
`ComplexCard.jsx`:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';
|
||||
```
|
||||
`RightPanel.jsx` (MapFlyTo 내부 포함):
|
||||
```jsx
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import { STATUS_CONFIG, PRIORITY_LABELS, formatDate, formatPrice, createMarkerIcon } from '../realEstateUtils';
|
||||
```
|
||||
`ScheduleView.jsx`:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';
|
||||
```
|
||||
`PriceAnalysis.jsx`:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';
|
||||
```
|
||||
`ComplexModal.jsx`:
|
||||
```jsx
|
||||
import React, { useState } from 'react';
|
||||
import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';
|
||||
```
|
||||
> 실제 본문이 참조하는 심볼만 포함할 것(빌드/lint가 누락·미사용 검출). RightPanel은 `leaflet/dist/leaflet.css`를 shell에서 로드하므로 별도 import 불필요.
|
||||
|
||||
- [ ] **Step 2: Wire RealEstate.jsx** — 5개 컴포넌트 + MapFlyTo 정의 삭제, import 추가:
|
||||
```js
|
||||
import ComplexCard from './components/ComplexCard';
|
||||
import RightPanel from './components/RightPanel';
|
||||
import ScheduleView from './components/ScheduleView';
|
||||
import PriceAnalysis from './components/PriceAnalysis';
|
||||
import ComplexModal from './components/ComplexModal';
|
||||
```
|
||||
RealEstate.jsx 상단의 이제-미사용 import 정리: `MapContainer/TileLayer/Marker/Popup/useMap`(react-leaflet), `BarChart~Cell`(recharts)가 shell에서 더 이상 안 쓰이면 제거(shell은 지도/차트를 직접 렌더하지 않음). `import 'leaflet/dist/leaflet.css'`는 지도 스타일 위해 **shell에 유지**(RightPanel이 렌더될 때 필요). `useMemo`는 shell의 filteredComplexes/stats에서 계속 사용 → 유지.
|
||||
|
||||
- [ ] **Step 3: Run tests + build + lint**
|
||||
|
||||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 5파일 + RealEstate.jsx 0 에러.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/pages/realestate/components/ComplexCard.jsx src/pages/realestate/components/RightPanel.jsx src/pages/realestate/components/ScheduleView.jsx src/pages/realestate/components/PriceAnalysis.jsx src/pages/realestate/components/ComplexModal.jsx src/pages/realestate/RealEstate.jsx
|
||||
git commit -m "refactor(realestate): 카드/지도패널/일정/분석/모달 표현 컴포넌트 추출"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `useComplexes` 훅 추출 → RealEstate를 shell 컨테이너로 + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/realestate/hooks/useComplexes.js`
|
||||
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `useComplexes()` 기본 export → `{ complexes, addComplex, updateComplex, deleteComplex }`.
|
||||
|
||||
- [ ] **Step 1: Create useComplexes.js** — 현 메인의 데이터 로직을 그대로 옮김(UI 관심사는 제외):
|
||||
|
||||
```js
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
|
||||
import { SAMPLE_COMPLEXES } from '../realEstateUtils';
|
||||
|
||||
export default function useComplexes() {
|
||||
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/realestate/complexes')
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const addComplex = async (data) => {
|
||||
const newComplex = { ...data, id: Date.now() };
|
||||
setComplexes((prev) => [...prev, newComplex]);
|
||||
try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
const updateComplex = async (data) => {
|
||||
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
const deleteComplex = async (id) => {
|
||||
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
return { complexes, addComplex, updateComplex, deleteComplex };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Reduce RealEstate.jsx to the shell container** — 컴포넌트 본문 최상단 부분(상태 선언 ~ 핸들러 ~ memo)을 아래로 교체. **`return (...)` JSX 블록은 verbatim 유지**(이미 `complexes`/`filteredComplexes`/`stats`/`selectedComplex`/`activeTab`/`filterStatus`/`handleDelete`/`handleModalSave`/`setSelectedComplex`/`setEditingComplex`/`setShowModal`/`setFilterStatus`/`setActiveTab`를 참조 — 이들은 아래 재구성으로 동일하게 제공됨).
|
||||
|
||||
RealEstate.jsx 상단 import는 다음으로 정리(현 사용 기준): React 훅(`useState`, `useMemo`), `Link`, utils(SAMPLE_COMPLEXES는 이제 훅이 씀 → shell에서 제거 가능; STATUS_CONFIG/TABS는 shell 렌더에서 사용 → 유지, 그 외 유틸 중 shell이 직접 쓰는 것만), `useComplexes`, 5개 컴포넌트, `import 'leaflet/dist/leaflet.css'`, `import './RealEstate.css'`. (미사용 import 제거로 lint 0.)
|
||||
|
||||
컴포넌트 함수 본문(선언부) 교체:
|
||||
```js
|
||||
const RealEstate = () => {
|
||||
const { complexes, addComplex, updateComplex, deleteComplex } = useComplexes();
|
||||
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('목록');
|
||||
const [filterStatus, setFilterStatus] = useState('전체');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingComplex, setEditingComplex] = useState(null);
|
||||
|
||||
const handleAdd = (data) => {
|
||||
addComplex(data);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateComplex(data);
|
||||
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||
setEditingComplex(null);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
deleteComplex(id);
|
||||
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||
};
|
||||
|
||||
const handleModalSave = (data) => {
|
||||
if (editingComplex) {
|
||||
handleUpdate({ ...editingComplex, ...data });
|
||||
} else {
|
||||
handleAdd(data);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredComplexes = useMemo(() => {
|
||||
if (filterStatus === '전체') return complexes;
|
||||
return complexes.filter((c) => c.status === filterStatus);
|
||||
}, [complexes, filterStatus]);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: complexes.length,
|
||||
upcoming: complexes.filter((c) => c.status === '청약예정').length,
|
||||
active: complexes.filter((c) => c.status === '청약중').length,
|
||||
avgPrice: complexes.length
|
||||
? Math.round(complexes.reduce((s, c) => s + c.avgPricePerPyeong, 0) / complexes.length)
|
||||
: 0,
|
||||
}), [complexes]);
|
||||
|
||||
return (
|
||||
// ── 현 RealEstate.jsx의 return JSX 전체를 verbatim 유지 ──
|
||||
);
|
||||
};
|
||||
```
|
||||
> `handleAdd`/`handleUpdate`/`handleDelete`는 비동기 await를 훅으로 옮겼으므로 shell에선 동기(비-async). 원래도 optimistic 갱신 후 UI(setShowModal/setSelected)를 처리하고 API는 fire-and-forget이었으므로 동작 동일(setComplexes→UI→API 순서 보존). `handleModalSave`/`filteredComplexes`/`stats`와 return JSX는 원본 그대로.
|
||||
|
||||
- [ ] **Step 3: Run full suite + lint + build**
|
||||
|
||||
Run: `npm run test:run` → 전체 green(회귀 0). `npm run lint` → 0 새 에러. `npm run build` → `✓ built`. `wc -l src/pages/realestate/RealEstate.jsx` 로 shell 축소(~150줄) 확인.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/pages/realestate/hooks/useComplexes.js src/pages/realestate/RealEstate.jsx
|
||||
git commit -m "refactor(realestate): 데이터 로직 useComplexes 훅 추출 → RealEstate shell 축소"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manual verification (dev server)**
|
||||
|
||||
`npm run dev` → `http://localhost:3007/realestate/property` 접속. 3탭(목록/일정/분석) 전환, 목록 카드 선택→우측 지도 flyTo+상세, 필터, 단지 추가/편집/삭제 모달, 지도 마커 클릭이 리팩토링 전과 **동일 동작**인지 확인. 청약 대시보드 링크(`/realestate`) 동작 확인.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§7):
|
||||
- §3 file map(utils/hook/components 5/shell) → Task 1/2/3 ✅
|
||||
- §4 useComplexes(데이터만, UI 경계는 shell) → Task 3 ✅ (confirm·selectedComplex 동기화 shell 유지)
|
||||
- §5 테스트(realEstateUtils.test + build/lint/수동) → Task 1 + 각 Task 검증 + Task 3 수동 ✅
|
||||
- §6 완료기준 → 각 Task 검증 ✅
|
||||
|
||||
**Placeholder scan:** 신규 코드(utils 작은 것들/훅/테스트/shell 선언부)는 전체 코드 명시. SAMPLE_COMPLEXES·표현 컴포넌트·return JSX는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 원본 존재, import 심볼 명시). ✅
|
||||
|
||||
**Type consistency:** utils export(Task1) ↔ Task2/3 import 일치. 컴포넌트 기본 export ↔ shell import 일치. `useComplexes` 반환 `{complexes,addComplex,updateComplex,deleteComplex}` ↔ shell 사용 일치. 경로(shell `../../api` 아님— shell은 api 직접 안 씀; components/hooks `../../../api`) 일치. ✅
|
||||
|
||||
**참고:** Task 순서 의존(utils→components→hook/shell). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0.
|
||||
@@ -0,0 +1,552 @@
|
||||
# FE 모듈 구조 컨벤션 + Subscription 분해 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `Subscription.jsx`(1647줄)를 기능 모듈 구조(shell + components/ + utils)로 **동작 보존 분해**하고, 컨벤션을 CLAUDE.md/README에 기록한다.
|
||||
|
||||
**Architecture:** 순수 추출(코드 이동)만. 상수·순수헬퍼→`subscriptionUtils.js`, 각 sub-component→`components/*.jsx`, `Subscription.jsx`는 shell로 축소. 각 Task는 코드를 옮기고 import를 배선한 뒤 build+전체 테스트 green으로 동작 보존을 확인. 로직/JSX/스타일 한 줄도 바꾸지 않음.
|
||||
|
||||
**Tech Stack:** React 18 + Vite, Vitest + @testing-library/react.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼의 본문(JSX·로직)은 **verbatim(그대로)**. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
|
||||
- 경로: `Subscription.jsx`(`src/pages/subscription/`)는 api를 `'../../api'`로 import. `components/*.jsx`는 `'../../../api'`, utils는 `'../subscriptionUtils'`. shell은 CSS `'./Subscription.css'` import 유지.
|
||||
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
|
||||
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js(x)` 동일 디렉토리.
|
||||
- `/realestate/listings` 교차링크(shell의 `<Link>`)와 `PullToRefresh`/`FAB`/탭 동작 유지.
|
||||
- 커밋은 web-ui 경로, 변경 파일만 `git add`. 커밋 trailer:
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `subscriptionUtils.js` — 상수·순수헬퍼 추출 + 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/subscription/subscriptionUtils.js`
|
||||
- Test: `src/pages/subscription/subscriptionUtils.test.js`
|
||||
- Modify: `src/pages/subscription/Subscription.jsx` (해당 정의 제거 + import 추가)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`, `extractTier(reasons)`, `fmt(d)`, `fmtFull(d)`, `getDDays(d)`, `getDDayColor(d)`, `fmtDateTime(d)`, `apiPatch(path,body)`, `fmtPrice(v)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** — Create `src/pages/subscription/subscriptionUtils.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractTier, getDDays, getDDayColor, fmtPrice } from './subscriptionUtils.js';
|
||||
|
||||
describe('extractTier', () => {
|
||||
it('자치구 티어 문자열에서 등급 추출', () => {
|
||||
expect(extractTier(['자치구 S티어: 강남구 (+25)'])).toBe('S');
|
||||
expect(extractTier(['면적 +10', '자치구 B티어: 노원구 (+15)'])).toBe('B');
|
||||
});
|
||||
it('미매치/빈 입력 → null', () => {
|
||||
expect(extractTier(['면적 적합'])).toBe(null);
|
||||
expect(extractTier(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDDays / getDDayColor (오늘 기준 경계)', () => {
|
||||
const iso = (offsetDays) => {
|
||||
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + offsetDays);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||
};
|
||||
it('오늘=D-Day, 미래=D-N, 과거=D+N', () => {
|
||||
expect(getDDays(iso(0))).toBe('D-Day');
|
||||
expect(getDDays(iso(5))).toBe('D-5');
|
||||
expect(getDDays(iso(-3))).toBe('D+3');
|
||||
expect(getDDays(null)).toBe(null);
|
||||
});
|
||||
it('색 임계: 지남=빨강, ≤3=주황, ≤7=파랑, 그 외=dim', () => {
|
||||
expect(getDDayColor(iso(-1))).toBe('#f87171');
|
||||
expect(getDDayColor(iso(2))).toBe('#f59e0b');
|
||||
expect(getDDayColor(iso(6))).toBe('#00d4ff');
|
||||
expect(getDDayColor(iso(30))).toBe('var(--text-dim)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtPrice (현 구현 동작 고정)', () => {
|
||||
it('억/만 변환', () => {
|
||||
expect(fmtPrice(10000)).toBe('1억');
|
||||
expect(fmtPrice(15000)).toBe('1.5억');
|
||||
expect(fmtPrice(9999)).toBe('9,999만');
|
||||
expect(fmtPrice(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./subscriptionUtils.js"`.
|
||||
|
||||
- [ ] **Step 3: Create subscriptionUtils.js** (아래 내용 — `Subscription.jsx` 현재 상수/헬퍼를 그대로 옮김; `_diffDays`는 내부 유지·미export):
|
||||
|
||||
```js
|
||||
/* 청약(Subscription) 모듈 상수·순수 헬퍼 */
|
||||
|
||||
export const STATUS_CONFIG = {
|
||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
||||
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
|
||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
|
||||
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||
};
|
||||
|
||||
export const HOUSE_TYPE_LABELS = {
|
||||
'01': '국민주택',
|
||||
'02': '민영주택',
|
||||
'03': '도시형생활주택',
|
||||
};
|
||||
|
||||
export const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
|
||||
|
||||
export const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
|
||||
|
||||
export const DEFAULT_PROFILE = {
|
||||
name: '', age: '', is_homeless: false, is_householder: false,
|
||||
subscription_months: '', subscription_amount: '',
|
||||
family_members: '', has_dependents: false, children_count: '',
|
||||
is_newlywed: false, marriage_months: '',
|
||||
has_newborn: false, is_first_home: false, income_level: '',
|
||||
preferred_regions: '', preferred_types: '',
|
||||
min_area: '', max_area: '', max_price: '',
|
||||
preferred_districts: {},
|
||||
min_match_score: 70,
|
||||
notify_enabled: true,
|
||||
};
|
||||
|
||||
export function extractTier(reasons) {
|
||||
for (const r of reasons || []) {
|
||||
const m = r.match(/자치구 ([SABCD])티어/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const fmt = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
export const fmtFull = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
const _diffDays = (d) => {
|
||||
if (!d) return null;
|
||||
const [y, m, day] = d.split('-').map(Number);
|
||||
const target = new Date(y, m - 1, day);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.round((target - today) / 86400000);
|
||||
};
|
||||
|
||||
export const getDDays = (d) => {
|
||||
const diff = _diffDays(d);
|
||||
if (diff === null) return null;
|
||||
if (diff === 0) return 'D-Day';
|
||||
return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
|
||||
};
|
||||
|
||||
export const getDDayColor = (d) => {
|
||||
const diff = _diffDays(d);
|
||||
if (diff === null) return 'var(--text-dim)';
|
||||
if (diff <= 0) return '#f87171';
|
||||
if (diff <= 3) return '#f59e0b';
|
||||
if (diff <= 7) return '#00d4ff';
|
||||
return 'var(--text-dim)';
|
||||
};
|
||||
|
||||
export const fmtDateTime = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export async function apiPatch(path, body) {
|
||||
const opts = {
|
||||
method: 'PATCH',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const fmtPrice = (v) => {
|
||||
if (v == null) return null;
|
||||
if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
|
||||
return `${v.toLocaleString()}만`;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire Subscription.jsx** — `src/pages/subscription/Subscription.jsx`에서: 위 상수/헬퍼의 **기존 정의(현 파일 상단 STATUS_CONFIG~fmtPrice, 로컬 apiPatch 포함)를 삭제**하고, 최상단 import 블록에 추가:
|
||||
|
||||
```js
|
||||
import {
|
||||
STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE,
|
||||
extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice,
|
||||
} from './subscriptionUtils';
|
||||
```
|
||||
(이 시점엔 컴포넌트들이 아직 Subscription.jsx 내부에 있으므로 import한 심볼을 그대로 사용. 나머지 컴포넌트 정의는 유지.)
|
||||
|
||||
- [ ] **Step 5: Run tests + build**
|
||||
|
||||
Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js` → PASS.
|
||||
Run: `npm run test:run` → 전체 green.
|
||||
Run: `npm run build` → `✓ built` (Subscription 동작 보존).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add src/pages/subscription/subscriptionUtils.js src/pages/subscription/subscriptionUtils.test.js src/pages/subscription/Subscription.jsx
|
||||
git commit -m "refactor(subscription): 상수·순수헬퍼를 subscriptionUtils로 추출 + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `StatusBadge` 컴포넌트 추출 + 스모크
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/subscription/components/StatusBadge.jsx`
|
||||
- Test: `src/pages/subscription/components/StatusBadge.test.jsx`
|
||||
- Modify: `src/pages/subscription/Subscription.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `STATUS_CONFIG`.
|
||||
- Produces: `StatusBadge` 기본 export — `({ status, size })`.
|
||||
|
||||
- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/subscription/components/StatusBadge.test.jsx`:
|
||||
|
||||
```jsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import StatusBadge from './StatusBadge.jsx';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it('status 라벨 렌더', () => {
|
||||
render(<StatusBadge status="청약중" />);
|
||||
expect(screen.getByText('청약중')).toBeInTheDocument();
|
||||
});
|
||||
it('미정의 status → "알 수 없음" 폴백', () => {
|
||||
render(<StatusBadge status={null} />);
|
||||
expect(screen.getByText('알 수 없음')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx`
|
||||
Expected: FAIL — `Failed to resolve import "./StatusBadge.jsx"`.
|
||||
|
||||
- [ ] **Step 3: Create StatusBadge.jsx** (현 `Subscription.jsx`의 StatusBadge 정의를 그대로 옮기고 상단 import 추가):
|
||||
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||
|
||||
function StatusBadge({ status, size }) {
|
||||
const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
|
||||
const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
|
||||
return (
|
||||
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{status || '알 수 없음'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBadge;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire Subscription.jsx** — StatusBadge 정의 삭제 + import 추가:
|
||||
```js
|
||||
import StatusBadge from './components/StatusBadge';
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests + build**
|
||||
|
||||
Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx` → PASS.
|
||||
Run: `npm run test:run` → green. `npm run build` → `✓ built`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add src/pages/subscription/components/StatusBadge.jsx src/pages/subscription/components/StatusBadge.test.jsx src/pages/subscription/Subscription.jsx
|
||||
git commit -m "refactor(subscription): StatusBadge 컴포넌트 추출 + 스모크"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 표현 컴포넌트 추출 (AnnouncementCard / AnnouncementDetail / CalendarView)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/subscription/components/AnnouncementCard.jsx`
|
||||
- Create: `src/pages/subscription/components/AnnouncementDetail.jsx`
|
||||
- Create: `src/pages/subscription/components/CalendarView.jsx`
|
||||
- Modify: `src/pages/subscription/Subscription.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1/2): utils 헬퍼, `StatusBadge`.
|
||||
- Produces: 각 기본 export — `AnnouncementCard({item,isSelected,onClick,onBookmark})`, `AnnouncementDetail({item,onBookmark})`, `CalendarView({items,onDaySelect})`.
|
||||
|
||||
- [ ] **Step 1: Create the three components (본문은 현 Subscription.jsx 정의 verbatim, 상단 import만 추가)**
|
||||
|
||||
`AnnouncementCard.jsx` — 상단:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||
import StatusBadge from './StatusBadge';
|
||||
```
|
||||
이어서 현 `Subscription.jsx`의 `function AnnouncementCard(...) { ... }` 본문을 그대로 붙이고 파일 끝에 `export default AnnouncementCard;`.
|
||||
|
||||
`AnnouncementDetail.jsx` — 상단:
|
||||
```jsx
|
||||
import React, { useState } from 'react';
|
||||
import { HOUSE_TYPE_LABELS, fmt, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||
import StatusBadge from './StatusBadge';
|
||||
```
|
||||
이어서 현 `AnnouncementDetail(...)` 본문 verbatim + `export default AnnouncementDetail;`. (내부 `useState`(detailTab) 사용하므로 useState import 포함.)
|
||||
|
||||
`CalendarView.jsx` — 상단:
|
||||
```jsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||
```
|
||||
이어서 현 `CalendarView(...)` 본문 verbatim + `export default CalendarView;`. (내부 `useState`/`useMemo` 사용.)
|
||||
|
||||
> 각 컴포넌트가 실제로 참조하는 심볼만 import에 포함되어야 함(빌드가 미사용/누락을 검출). 위 목록은 현 코드 사용 기준.
|
||||
|
||||
- [ ] **Step 2: Wire Subscription.jsx** — 세 컴포넌트 정의 삭제 + import 추가:
|
||||
```js
|
||||
import AnnouncementCard from './components/AnnouncementCard';
|
||||
import AnnouncementDetail from './components/AnnouncementDetail';
|
||||
import CalendarView from './components/CalendarView';
|
||||
```
|
||||
(AnnouncementsTab이 아직 Subscription.jsx 내부에 있으므로 import한 세 컴포넌트를 그대로 사용.)
|
||||
|
||||
- [ ] **Step 3: Run tests + build**
|
||||
|
||||
Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run build` → `✓ built`. Run: `npm run lint` → 신규 3파일 0 에러.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/pages/subscription/components/AnnouncementCard.jsx src/pages/subscription/components/AnnouncementDetail.jsx src/pages/subscription/components/CalendarView.jsx src/pages/subscription/Subscription.jsx
|
||||
git commit -m "refactor(subscription): 공고 카드/상세/캘린더 컴포넌트 추출"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/subscription/components/DashboardTab.jsx`
|
||||
- Create: `src/pages/subscription/components/AnnouncementsTab.jsx`
|
||||
- Create: `src/pages/subscription/components/MatchesTab.jsx`
|
||||
- Create: `src/pages/subscription/components/ProfileTab.jsx`
|
||||
- Modify: `src/pages/subscription/Subscription.jsx` (shell로 축소)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: utils 헬퍼, StatusBadge, AnnouncementCard/AnnouncementDetail/CalendarView, 기존 `DistrictTierEditor`/`NotificationSettings`.
|
||||
- Produces: 각 기본 export (자립형, props 없음).
|
||||
|
||||
- [ ] **Step 1: Create the four tab components (본문 verbatim + 상단 import)**
|
||||
|
||||
`DashboardTab.jsx` — 상단:
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiGet, apiPost } from '../../../api';
|
||||
import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils';
|
||||
import StatusBadge from './StatusBadge';
|
||||
```
|
||||
현 `DashboardTab()` 본문 verbatim + `export default DashboardTab;`.
|
||||
|
||||
`AnnouncementsTab.jsx` — 상단:
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiGet, apiDelete } from '../../../api';
|
||||
import { STATUS_FILTERS, apiPatch } from '../subscriptionUtils';
|
||||
import AnnouncementCard from './AnnouncementCard';
|
||||
import AnnouncementDetail from './AnnouncementDetail';
|
||||
import CalendarView from './CalendarView';
|
||||
```
|
||||
현 `AnnouncementsTab()` 본문 verbatim + `export default AnnouncementsTab;`.
|
||||
|
||||
`MatchesTab.jsx` — 상단:
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiGet, apiPost } from '../../../api';
|
||||
import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils';
|
||||
import StatusBadge from './StatusBadge';
|
||||
```
|
||||
현 `MatchesTab()` 본문 verbatim + `export default MatchesTab;`.
|
||||
|
||||
`ProfileTab.jsx` — 상단:
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiGet, apiPut } from '../../../api';
|
||||
import { DEFAULT_PROFILE } from '../subscriptionUtils';
|
||||
import DistrictTierEditor from './DistrictTierEditor';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
```
|
||||
현 `ProfileTab()` 본문 verbatim + `export default ProfileTab;`.
|
||||
|
||||
> import 심볼은 각 탭이 실제 사용하는 것 기준(위 목록). 빌드가 누락/미사용 검출.
|
||||
|
||||
- [ ] **Step 2: Reduce Subscription.jsx to the shell** — 4개 탭 정의 삭제. 파일을 아래로 정리(현 shell 본문 verbatim 유지, import만 재구성):
|
||||
|
||||
```jsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import { TABS } from './subscriptionUtils';
|
||||
import DashboardTab from './components/DashboardTab';
|
||||
import AnnouncementsTab from './components/AnnouncementsTab';
|
||||
import MatchesTab from './components/MatchesTab';
|
||||
import ProfileTab from './components/ProfileTab';
|
||||
import './Subscription.css';
|
||||
|
||||
// ── Subscription (Main) ──
|
||||
function Subscription() {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleFABClick = useCallback(() => {
|
||||
setActiveTab(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="sub">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
|
||||
실매물 · 안전마진 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="sub-header">
|
||||
<div>
|
||||
<p className="sub-kicker">Real Estate</p>
|
||||
<h1>청약 관리</h1>
|
||||
<p className="sub-desc">
|
||||
공공데이터 기반 청약 공고 자동 수집, 내 조건 매칭, 일정 관리를 한곳에서.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-tabs-bar">
|
||||
<div className="sub-tabs">
|
||||
{TABS.map((tab, i) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`sub-tab${activeTab === i ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveTab(i)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-body">
|
||||
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
|
||||
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
|
||||
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
|
||||
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
|
||||
</div>
|
||||
<FAB onClick={handleFABClick} label="공고 목록" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
export default Subscription;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run full suite + lint + build**
|
||||
|
||||
Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run lint` → 신규 파일 0 에러. Run: `npm run build` → `✓ built`. `Subscription.jsx`가 ~70줄 shell로 축소됐는지 확인(`wc -l`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/pages/subscription/components/DashboardTab.jsx src/pages/subscription/components/AnnouncementsTab.jsx src/pages/subscription/components/MatchesTab.jsx src/pages/subscription/components/ProfileTab.jsx src/pages/subscription/Subscription.jsx
|
||||
git commit -m "refactor(subscription): 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manual verification (dev server)**
|
||||
|
||||
`npm run dev` → `http://localhost:3007/realestate` 접속. 4탭(대시보드/공고 목록/매칭 결과/내 프로필) 전환, 공고 카드 선택→상세, 캘린더 뷰, 즐겨찾기 토글, 프로필 저장이 리팩토링 전과 **동일 동작**인지 확인. 실매물 교차링크 동작 확인.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Part A — 기능 모듈 구조 컨벤션 문서화 (CLAUDE.md + README)
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (web-ui 루트)
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: web-ui/CLAUDE.md — "기능 모듈 구조" 섹션 추가** (문서 상단 "주요 파일 위치" 또는 페이지 구조 근처):
|
||||
|
||||
```markdown
|
||||
## 기능 모듈 구조 (컨벤션)
|
||||
|
||||
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`).
|
||||
|
||||
```
|
||||
src/pages/<feature>/
|
||||
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||
├── <Feature>.css # 스타일 (shell에서 1회 import)
|
||||
├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임
|
||||
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||
```
|
||||
|
||||
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
|
||||
```
|
||||
그리고 페이지 구조 표의 `/realestate` 행 설명에 "(모듈 분해: shell + components/8 + subscriptionUtils)" 취지 반영.
|
||||
|
||||
- [ ] **Step 2: README.md — 컨벤션 문단 추가** (프로젝트 통계/구조 근처):
|
||||
|
||||
```markdown
|
||||
### 기능 모듈 구조
|
||||
|
||||
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add CLAUDE.md README.md
|
||||
git commit -m "docs: 기능 모듈 구조 컨벤션 하네스(CLAUDE.md)·README 기록"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§7):
|
||||
- Part A 컨벤션+문서 → Task 5 ✅
|
||||
- Part B file map(utils/StatusBadge/카드3/탭4/shell) → Task 1/2/3/4 ✅
|
||||
- §3 subscriptionUtils 이동 항목 → Task 1 ✅ (local apiPatch 이동 포함)
|
||||
- §4 테스트(utils 유닛 + StatusBadge 스모크 + build/lint/수동) → Task 1/2 + 각 Task build/lint + Task 4 수동 ✅
|
||||
- §6 완료기준 → 각 Task 검증 + Task 5 ✅
|
||||
|
||||
**Placeholder scan:** 신규 코드(utils/StatusBadge/테스트/shell/docs)는 전체 코드 명시. 대형 컴포넌트는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 옮길 원본이 파일에 존재, import 심볼 목록 명시). ✅
|
||||
|
||||
**Type consistency:** utils export 심볼(Task1) ↔ Task2/3/4 import 일치. 컴포넌트 기본 export ↔ shell import 일치. api 경로(shell `../../api`, components `../../../api`, utils `../subscriptionUtils`) 일치. ✅
|
||||
|
||||
**참고:** 각 Task는 이전 Task 산출물(utils→StatusBadge→카드→탭→shell)에 순차 의존. 순서 준수 필수. 리팩토링이라 RED는 "신규 파일 미존재(import 실패)"로 성립, GREEN은 추출 후 통과 + 전체 회귀 0.
|
||||
1071
docs/superpowers/plans/2026-07-09-realestate-listings.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# InstaCards.jsx 분해 리팩토링 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `src/pages/insta/InstaCards.jsx`(1013줄)를 shell(~140줄) + `components/`11 + `hooks/usePollTask` + `instaUtils`(+test)로 동작 보존 분해한다.
|
||||
|
||||
**Architecture:** behavior-preserving 순수 추출. 각 컴포넌트/훅/헬퍼를 현 정의 그대로(verbatim) 새 파일로 옮기고 import로 연결. 로직/JSX/스타일/prop shape 불변. 컴포넌트를 이름으로 찾아(정의 시그니처 기준) 옮기며, 매 task 후 build+lint+전체 test green(회귀 0)을 유지한다.
|
||||
|
||||
**Tech Stack:** React 18 + Vite + Vitest + @testing-library/react. 설계: `docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **behavior-preserving**: 옮기는 코드는 verbatim. 로직/JSX/className/prop 참조/스타일 변경 금지. 리팩토링은 "정의를 파일로 이동 + import 연결"만.
|
||||
- **line 번호는 pre-refactor(1013줄) 기준 힌트**. 추출이 진행되면 InstaCards.jsx 줄이 바뀌므로 **컴포넌트/함수 이름·시그니처로 위치를 찾을 것**(예: `function KeywordsPanel({ onCreateSlate }) {`).
|
||||
- **import 경로**: `components/*.jsx`·`hooks/*.js` → api `'../../../api'`, utils `'../instaUtils'`, 형제 컴포넌트 `'./X'`, 훅 `'../hooks/usePollTask'`. shell(`InstaCards.jsx`) → 컴포넌트 `'./components/X'`, 훅 `'./hooks/usePollTask'`, utils `'./instaUtils'`, `PullToRefresh` `'../../components/PullToRefresh'`, CSS `'./InstaCards.css'`.
|
||||
- **import 최소·완전**: 각 파일은 실제 참조하는 심볼만 import. 매 task에서 shell의 이제 안 쓰는 api/컴포넌트 import 제거. `npm run lint`(no-unused-vars)와 `npm run build`가 누락·미사용을 검출 → 둘 다 0 에러로 몰 것.
|
||||
- **각 컴포넌트는 default export**.
|
||||
- **검증 게이트(매 task)**: `npm run test:run` 전체 green(회귀 0, 시작 165) + `npm run lint` 신규/수정 파일 0 에러 + `npm run build` `✓ built`.
|
||||
- 단일 사용 상수 co-locate: `CATEGORIES`/`KEYWORDS_PER_PAGE` → KeywordsPanel.jsx, `PROMPT_NAMES` → PromptTemplatesEditor.jsx, `CATEGORY_COLORS` → ExternalTrendsPanel.jsx. 다중 사용 `fmtDate` → instaUtils.js.
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조 (분해 후)
|
||||
|
||||
```
|
||||
src/pages/insta/
|
||||
├── InstaCards.jsx # shell(~140줄): 탭바+상태헤더+progress배너+handleCreateSlate+2탭 조합
|
||||
├── InstaCards.css # 변경 없음
|
||||
├── instaUtils.js # fmtDate
|
||||
├── instaUtils.test.js # fmtDate 유닛
|
||||
├── hooks/usePollTask.js # { taskId, task, start, clear }
|
||||
└── components/
|
||||
├── StatusBadge.jsx TaskStatusBox.jsx TriggerPanel.jsx KeywordsPanel.jsx
|
||||
├── SlatesPanel.jsx SlateDetail.jsx PagesStrip.jsx
|
||||
└── AccountFocusPanel.jsx ExternalTrendsPanel.jsx PreferenceImpactPanel.jsx PromptTemplatesEditor.jsx
|
||||
```
|
||||
|
||||
**shell이 직접 렌더하는 7 컴포넌트**: TriggerPanel, KeywordsPanel, SlatesPanel, PromptTemplatesEditor, AccountFocusPanel, ExternalTrendsPanel, PreferenceImpactPanel. (StatusBadge/TaskStatusBox/SlateDetail/PagesStrip은 다른 컴포넌트가 렌더 → shell import 아님.)
|
||||
|
||||
**컴포넌트 참조 그래프**: TriggerPanel→usePollTask+TaskStatusBox / ExternalTrendsPanel→TaskStatusBox+CATEGORY_COLORS+fmtDate / SlatesPanel→SlateDetail+StatusBadge+fmtDate / SlateDetail→PagesStrip+StatusBadge / PromptTemplatesEditor→fmtDate+PROMPT_NAMES.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 기반 — instaUtils(+test) + usePollTask 훅 + 리프 컴포넌트(StatusBadge, TaskStatusBox)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/insta/instaUtils.js`, `src/pages/insta/instaUtils.test.js`
|
||||
- Create: `src/pages/insta/hooks/usePollTask.js`
|
||||
- Create: `src/pages/insta/components/StatusBadge.jsx`, `src/pages/insta/components/TaskStatusBox.jsx`
|
||||
- Modify: `src/pages/insta/InstaCards.jsx` (정의 4개 제거 + import)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `fmtDate(iso) → string`; `usePollTask(onDone?) → { taskId, task, start(id), clear() }`; `StatusBadge({ status })`; `TaskStatusBox({ task })` (모두 default export, usePollTask는 named 아님 default).
|
||||
|
||||
- [ ] **Step 1: instaUtils.test.js 작성(RED)**
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fmtDate } from './instaUtils';
|
||||
|
||||
describe('instaUtils.fmtDate', () => {
|
||||
it('returns empty string for falsy input', () => {
|
||||
expect(fmtDate('')).toBe('');
|
||||
expect(fmtDate(null)).toBe('');
|
||||
expect(fmtDate(undefined)).toBe('');
|
||||
});
|
||||
it('returns a non-empty string for a valid ISO date (locale/tz-dependent format)', () => {
|
||||
const out = fmtDate('2026-07-10T09:30:00Z');
|
||||
expect(typeof out).toBe('string');
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: RED 확인** — Run: `npx vitest run src/pages/insta/instaUtils.test.js`. Expected: FAIL(`fmtDate`/모듈 미존재).
|
||||
|
||||
- [ ] **Step 3: instaUtils.js 작성(GREEN)** — 현 `InstaCards.jsx`의 `fmtDate`(26–34) verbatim, `export` 부여:
|
||||
|
||||
```js
|
||||
// src/pages/insta/instaUtils.js
|
||||
export function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: GREEN 확인** — Run: `npx vitest run src/pages/insta/instaUtils.test.js`. Expected: PASS(2 tests).
|
||||
|
||||
- [ ] **Step 5: hooks/usePollTask.js 작성** — 현 `InstaCards.jsx`의 `usePollTask`(45–82) 함수 본문 verbatim 이동, `import { useState, useEffect, useRef } from 'react';`(실제 사용 훅) + `import { getInstaTask } from '../../../api';` + `export default function usePollTask(onDone) { ... }`. 내부 `getInstaTask` 참조 그대로.
|
||||
|
||||
- [ ] **Step 6: components/StatusBadge.jsx 작성** — 현 `StatusBadge`(36–42) verbatim: `import React from 'react'; const StatusBadge = ({ status }) => (<span className={\`ic-status-badge ic-status-badge--${status || 'draft'}\`}>{status || 'draft'}</span>); export default StatusBadge;` (JSX는 원본과 동일하게).
|
||||
|
||||
- [ ] **Step 7: components/TaskStatusBox.jsx 작성** — 현 `TaskStatusBox`(84–99) verbatim 이동. `import React from 'react';` + 원본이 참조하는 심볼(예: 상태 텍스트/StatusBadge? — 원본 본문 확인해 실제 참조만 import; 순수 표현이면 React만). `export default TaskStatusBox;`.
|
||||
|
||||
- [ ] **Step 8: InstaCards.jsx 수정** — `fmtDate`/`usePollTask`/`StatusBadge`/`TaskStatusBox` 인라인 정의 4개 제거. 상단에 추가:
|
||||
```jsx
|
||||
import { fmtDate } from './instaUtils';
|
||||
import usePollTask from './hooks/usePollTask';
|
||||
import StatusBadge from './components/StatusBadge';
|
||||
import TaskStatusBox from './components/TaskStatusBox';
|
||||
```
|
||||
아직 인라인인 컴포넌트(TriggerPanel/ExternalTrendsPanel/SlatesPanel/SlateDetail/PromptTemplatesEditor)가 이 심볼들을 참조 → 동일 모듈 scope에서 import된 심볼 사용(동작 보존). api import 블록에서 이제 shell 파일 어디서도 안 쓰는 것이 있으면 제거(단 아직 인라인 컴포넌트가 쓰는 것은 유지). `getInstaTask`는 usePollTask로 이동했지만 shell `handleCreateSlate`가 여전히 직접 사용 → 유지.
|
||||
|
||||
- [ ] **Step 9: 검증** — Run: `npm run test:run`(전체 green, ≥165+2=167), `npm run lint`(0 에러), `npm run build`(`✓ built`).
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
```bash
|
||||
git add src/pages/insta/instaUtils.js src/pages/insta/instaUtils.test.js src/pages/insta/hooks/usePollTask.js src/pages/insta/components/StatusBadge.jsx src/pages/insta/components/TaskStatusBox.jsx src/pages/insta/InstaCards.jsx
|
||||
git commit -m "refactor(insta): instaUtils(fmtDate)+usePollTask 훅+StatusBadge/TaskStatusBox 추출"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Cards 탭 좌측 — TriggerPanel + KeywordsPanel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/insta/components/TriggerPanel.jsx`, `src/pages/insta/components/KeywordsPanel.jsx`
|
||||
- Modify: `src/pages/insta/InstaCards.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `usePollTask`(Task1), `TaskStatusBox`(Task1).
|
||||
- Produces: `TriggerPanel()`, `KeywordsPanel({ onCreateSlate })` (default export).
|
||||
|
||||
- [ ] **Step 1: components/TriggerPanel.jsx 작성** — 현 `TriggerPanel`(471–521) 함수 본문 verbatim 이동. imports:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import usePollTask from '../hooks/usePollTask';
|
||||
import TaskStatusBox from './TaskStatusBox';
|
||||
import { instaCollectNews, instaExtractKeywords } from '../../../api';
|
||||
```
|
||||
내부 `usePollTask()`×2, `TaskStatusBox`, `instaCollectNews`/`instaExtractKeywords` 참조 그대로. `export default TriggerPanel;`.
|
||||
|
||||
- [ ] **Step 2: components/KeywordsPanel.jsx 작성** — 현 `KeywordsPanel`(527–637) 함수 본문 verbatim 이동 + **상수 `CATEGORIES`(524)·`KEYWORDS_PER_PAGE`(525)를 이 파일 상단에 co-locate**. imports:
|
||||
```jsx
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { getInstaKeywords } from '../../../api';
|
||||
```
|
||||
(실제 사용 훅/헬퍼만 — 원본이 useState/useEffect/useCallback/useMemo와 getInstaKeywords, onCreateSlate prop 사용.) 상수:
|
||||
```jsx
|
||||
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
||||
const KEYWORDS_PER_PAGE = 10;
|
||||
```
|
||||
`export default KeywordsPanel;`.
|
||||
|
||||
- [ ] **Step 3: InstaCards.jsx 수정** — `TriggerPanel`/`KeywordsPanel` 인라인 정의 + `CATEGORIES`/`KEYWORDS_PER_PAGE` 상수 제거. 추가:
|
||||
```jsx
|
||||
import TriggerPanel from './components/TriggerPanel';
|
||||
import KeywordsPanel from './components/KeywordsPanel';
|
||||
```
|
||||
이제 shell에서 안 쓰는 api import 제거: `instaCollectNews`/`instaExtractKeywords`(TriggerPanel로 이동), `getInstaKeywords`(KeywordsPanel로 이동)가 shell 다른 곳에서 안 쓰이면 shell import에서 삭제. `TaskStatusBox` shell import는 아직 인라인 ExternalTrendsPanel(245)이 사용 → **유지**. `usePollTask` shell import는 이제 shell 인라인에서 미사용(TriggerPanel 이동) → **제거**.
|
||||
|
||||
- [ ] **Step 4: 검증** — `npm run test:run`(green), `npm run lint`(0), `npm run build`(✓).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add src/pages/insta/components/TriggerPanel.jsx src/pages/insta/components/KeywordsPanel.jsx src/pages/insta/InstaCards.jsx
|
||||
git commit -m "refactor(insta): TriggerPanel/KeywordsPanel 추출 (Cards 탭 좌측)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Cards 탭 우측 (중첩 트리) — SlatesPanel + SlateDetail + PagesStrip
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/insta/components/SlatesPanel.jsx`, `src/pages/insta/components/SlateDetail.jsx`, `src/pages/insta/components/PagesStrip.jsx`
|
||||
- Modify: `src/pages/insta/InstaCards.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `StatusBadge`(Task1), `fmtDate`(Task1).
|
||||
- Produces: `SlatesPanel({ selectedId, onSelect })`, `SlateDetail({ slate, onDelete, onRender })`, `PagesStrip({ slateId, pageCount })` (default export).
|
||||
|
||||
- [ ] **Step 1: components/PagesStrip.jsx 작성** — 현 `PagesStrip`(733–817) verbatim 이동. imports: `import React, { useState, useCallback, useEffect, useRef } from 'react';`(실제 사용) + `import { getInstaAssetUrl } from '../../../api';`. `export default PagesStrip;`.
|
||||
|
||||
- [ ] **Step 2: components/SlateDetail.jsx 작성** — 현 `SlateDetail`(818–940) verbatim 이동. imports:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import PagesStrip from './PagesStrip';
|
||||
import { getInstaAssetUrl, instaPackageUrl } from '../../../api';
|
||||
```
|
||||
(실제 사용 심볼만 — 원본 본문이 참조하는 것으로 확정; React 훅 사용 시 추가.) 내부 `<StatusBadge>`(832)·`<PagesStrip>`(845) 참조 그대로. `export default SlateDetail;`.
|
||||
|
||||
- [ ] **Step 3: components/SlatesPanel.jsx 작성** — 현 `SlatesPanel`(638–732) verbatim 이동. imports:
|
||||
```jsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import SlateDetail from './SlateDetail';
|
||||
import { fmtDate } from '../instaUtils';
|
||||
import { getInstaSlates, getInstaSlate, getInstaAssetUrl, renderInstaSlate, deleteInstaSlate } from '../../../api';
|
||||
```
|
||||
(실제 사용만 — 원본이 `<StatusBadge>`(711)·`<SlateDetail>`(722)·`fmtDate`(710)·slate 목록/상세/렌더/삭제 api 사용. onDelete/onRender 핸들러가 renderInstaSlate/deleteInstaSlate를 어디서 호출하는지 원본 따라 배치.) `export default SlatesPanel;`.
|
||||
|
||||
- [ ] **Step 4: InstaCards.jsx 수정** — `SlatesPanel`/`SlateDetail`/`PagesStrip` 인라인 정의 제거. 추가: `import SlatesPanel from './components/SlatesPanel';`. (SlateDetail/PagesStrip은 shell이 직접 렌더 안 함 → shell import 아님.) 이제 shell에서 미사용: `StatusBadge` import(SlatesPanel/SlateDetail 이동으로 shell 인라인 미사용) → **제거**; 관련 api import(getInstaSlates/getInstaSlate/getInstaAssetUrl/renderInstaSlate/deleteInstaSlate/instaPackageUrl 등)가 shell 인라인에서 미사용이면 **제거**.
|
||||
|
||||
- [ ] **Step 5: 검증** — `npm run test:run`(green), `npm run lint`(0), `npm run build`(✓).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add src/pages/insta/components/SlatesPanel.jsx src/pages/insta/components/SlateDetail.jsx src/pages/insta/components/PagesStrip.jsx src/pages/insta/InstaCards.jsx
|
||||
git commit -m "refactor(insta): SlatesPanel/SlateDetail/PagesStrip 추출 (Cards 탭 우측)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Trends 탭 3패널 + PromptTemplatesEditor + shell 확정
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/insta/components/AccountFocusPanel.jsx`, `src/pages/insta/components/ExternalTrendsPanel.jsx`, `src/pages/insta/components/PreferenceImpactPanel.jsx`, `src/pages/insta/components/PromptTemplatesEditor.jsx`
|
||||
- Modify: `src/pages/insta/InstaCards.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TaskStatusBox`(Task1), `fmtDate`(Task1).
|
||||
- Produces: `AccountFocusPanel()`, `ExternalTrendsPanel({ onCreateSlate })`, `PreferenceImpactPanel()`, `PromptTemplatesEditor()` (default export).
|
||||
|
||||
- [ ] **Step 1: components/AccountFocusPanel.jsx 작성** — 현 `AccountFocusPanel`(101–169) verbatim 이동. imports: `import React, { useState, useCallback, useEffect } from 'react';` + `import { getInstaPreferences, putInstaPreferences } from '../../../api';`(실제 사용). `export default AccountFocusPanel;`.
|
||||
|
||||
- [ ] **Step 2: components/ExternalTrendsPanel.jsx 작성** — 현 `ExternalTrendsPanel`(175–267) verbatim 이동 + **상수 `CATEGORY_COLORS`(170)를 이 파일 상단에 co-locate**. imports:
|
||||
```jsx
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import TaskStatusBox from './TaskStatusBox';
|
||||
import { fmtDate } from '../instaUtils';
|
||||
import { getInstaTrends, instaCollectTrends, getInstaTask } from '../../../api';
|
||||
```
|
||||
(실제 사용만 — 원본이 `<TaskStatusBox>`(245)·`fmtDate`(238)·`CATEGORY_COLORS`(221,252)·trends api·인라인 폴링 getInstaTask 사용.) 상수 `const CATEGORY_COLORS = { ... };`(원본 170 블록 verbatim). `export default ExternalTrendsPanel;`.
|
||||
|
||||
- [ ] **Step 3: components/PreferenceImpactPanel.jsx 작성** — 현 `PreferenceImpactPanel`(268–300) verbatim 이동. imports: `import React, { useState, useEffect } from 'react';` + `import { getInstaPreferences } from '../../../api';`(실제 사용). `export default PreferenceImpactPanel;`.
|
||||
|
||||
- [ ] **Step 4: components/PromptTemplatesEditor.jsx 작성** — 현 `PromptTemplatesEditor`(943–1013) verbatim 이동 + **상수 `PROMPT_NAMES`(941)를 이 파일 상단에 co-locate**. imports:
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fmtDate } from '../instaUtils';
|
||||
import { getInstaPrompt, putInstaPrompt } from '../../../api';
|
||||
```
|
||||
(실제 사용만 — 원본이 `fmtDate`(984)·prompt api·`PROMPT_NAMES` 사용.) 상수 `const PROMPT_NAMES = ['slate_writer', 'category_seeds'];`. `export default PromptTemplatesEditor;`.
|
||||
|
||||
- [ ] **Step 5: InstaCards.jsx 최종 확정(shell)** — 4개 인라인 정의 + `CATEGORY_COLORS`/`PROMPT_NAMES` 상수 제거. 추가:
|
||||
```jsx
|
||||
import AccountFocusPanel from './components/AccountFocusPanel';
|
||||
import ExternalTrendsPanel from './components/ExternalTrendsPanel';
|
||||
import PreferenceImpactPanel from './components/PreferenceImpactPanel';
|
||||
import PromptTemplatesEditor from './components/PromptTemplatesEditor';
|
||||
```
|
||||
**shell import 최종 정리** — shell이 실제 쓰는 것만 남긴다:
|
||||
- React: `import React, { useState, useEffect, useCallback } from 'react';`(shell이 실제 사용하는 훅만; useRef/useMemo는 shell 미사용이면 제거)
|
||||
- `import PullToRefresh from '../../components/PullToRefresh';`(유지)
|
||||
- 7 컴포넌트 import(TriggerPanel/KeywordsPanel/SlatesPanel/PromptTemplatesEditor/AccountFocusPanel/ExternalTrendsPanel/PreferenceImpactPanel)
|
||||
- api: shell이 직접 쓰는 것만 — `import { getInstaStatus, createInstaSlate, getInstaTask } from '../../../api';`(loadStatus=getInstaStatus, handleCreateSlate=createInstaSlate+getInstaTask). 나머지 api·`TaskStatusBox`·`StatusBadge`·`usePollTask`·`fmtDate` import 전부 제거(모두 컴포넌트로 이동).
|
||||
- `import './InstaCards.css';`(유지)
|
||||
결과 `InstaCards.jsx`는 shell(~140줄).
|
||||
|
||||
- [ ] **Step 6: 검증** — Run: `npm run test:run`(전체 green, 회귀 0), `npm run lint`(0 에러, 미사용 import 0), `npm run build`(`✓ built`), `wc -l src/pages/insta/InstaCards.jsx`(~140줄 확인).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
git add src/pages/insta/components/AccountFocusPanel.jsx src/pages/insta/components/ExternalTrendsPanel.jsx src/pages/insta/components/PreferenceImpactPanel.jsx src/pages/insta/components/PromptTemplatesEditor.jsx src/pages/insta/InstaCards.jsx
|
||||
git commit -m "refactor(insta): Trends 3패널+PromptTemplatesEditor 추출 → InstaCards shell 확정"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Manual verification (dev server)** — `npm run dev` → `http://localhost:3007/insta`. 확인: 2탭(Cards/Trends) 전환 + URL `?tab=trends` 동기화 / 트리거(뉴스 수집·키워드 추출 버튼 + 스피너 + TaskStatusBox) / 키워드 목록·카테고리·페이지네이션 / 슬레이트 생성(키워드 클릭 → progress 배너 → 완료 시 Cards 탭 자동 전환 + 상세) / 슬레이트 목록·상세(SlateDetail)·페이지 스트립(PagesStrip 스크롤·키보드)·삭제·렌더 / 프롬프트 편집(저장) / Trends 3패널(AccountFocus·ExternalTrends 수집·PreferenceImpact)이 리팩토링 전과 **동일 동작**.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§7):
|
||||
- §3 file map(instaUtils/usePollTask/11 컴포넌트/shell) → Task 1(utils+hook+2리프) / Task 2(2) / Task 3(3) / Task 4(4+shell) ✅ (11 = 2+2+3+4)
|
||||
- §4 shell 책임(탭·progress·handleCreateSlate 유지, 7 컴포넌트 조합) → Task 4 Step 5 ✅
|
||||
- §5 테스트(instaUtils fmtDate 유닛 + build/lint/전체/수동) → Task 1 Step 1–4 + 각 task 검증 + Task 4 Step 8 ✅
|
||||
- §6 완료기준 → 각 task 검증 + Task 4 line count ✅
|
||||
- §3 상수 배치(CATEGORIES/KEYWORDS_PER_PAGE→KeywordsPanel, PROMPT_NAMES→PromptTemplatesEditor, CATEGORY_COLORS→ExternalTrendsPanel co-locate, fmtDate→instaUtils) → 각 Task 명시 ✅
|
||||
|
||||
**Placeholder scan:** fmtDate 테스트/구현 완전 명시. 리프트 컴포넌트는 "현 정의(이름·line 힌트) verbatim + 명시 import/export/상수 co-locate"로 지정(원본 코드 존재 → 재현 대신 위치+연결 지정, 서브프로젝트 1~3 동일 컨벤션). 일부 컴포넌트 import는 "원본이 참조하는 심볼만(build/lint 검출)"으로 위임 — 리프트 특성상 정확 목록은 원본 참조와 동일. ✅
|
||||
|
||||
**Type consistency:** usePollTask 반환 `{ taskId, task, start, clear }` ↔ TriggerPanel 사용 일치. 컴포넌트 props(status/task/onCreateSlate/selectedId/onSelect/slate/onDelete/onRender/slateId/pageCount) ↔ shell/부모 전달 일치. 경로(components→`../../../api`·`../instaUtils`·`./sibling`·`../hooks/usePollTask`) 일관. StatusBadge/TaskStatusBox는 Task1 추출 → 소비처(Task2/3/4)가 직접 import, shell은 소비 인라인 남아있는 동안만 브릿지 import 유지 후 제거. ✅
|
||||
|
||||
**참고:** Task 순서 의존(Task1 리프/훅/유틸 → Task2/3/4가 import). behavior-preserving라 컴포넌트 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. fmtDate만 명시적 TDD(RED→GREEN).
|
||||
@@ -0,0 +1,288 @@
|
||||
# PortfolioTab.jsx 분해 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `PortfolioTab.jsx`(671줄)를 `stock/components/portfolio/` 하위 표현 컴포넌트 7개 + `portfolioUtils`로 **동작 보존 분해**한다.
|
||||
|
||||
**Architecture:** 인라인 JSX 블록을 컴포넌트로 리프트(가드는 컴포넌트 내부 early-return으로 이동, `pf`/`asset`/핸들러를 props로 그대로 전달). `formatPriceTime`→`portfolioUtils.js`. `PortfolioTab.jsx`는 조합 shell(~70줄)로 축소. 각 Task는 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
|
||||
|
||||
**Tech Stack:** React 18 + Vite, recharts, Vitest + @testing-library/react.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Behavior-preserving 순수 추출**: 리프트하는 JSX 본문은 verbatim(참조하는 `pf.*`/`asset.*`은 이제 props). 래핑 가드(`pf.addFormOpen &&`, `pf.portfolioHoldings.length > 0 &&` 등)는 컴포넌트 내부 `if (!cond) return null;`로 이동. `pf`(훅 객체 전체)를 각 하위에 그대로 전달 → prop shape·동작 불변. 스타일/클래스명 불변.
|
||||
- 경로: `portfolio/*.jsx` → stockUtils `'../../stockUtils'`, Loading `'../../../../components/Loading'`, portfolioUtils `'./portfolioUtils'`; `HoldingRow` → PriceSessionBadge `'./PriceSessionBadge'`; `PortfolioTab.jsx` → 하위 `'./portfolio/*'` (기존 `'../stockUtils'`·`'../../../components/Loading'`는 shell이 쓰는 것만 유지).
|
||||
- 각 파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 빌드가 누락 검출.
|
||||
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
|
||||
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js(x)` 동일 디렉토리.
|
||||
- 커밋은 web-ui, 변경 파일만 `git add`. 커밋 trailer:
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `portfolioUtils.js` + `PriceSessionBadge` 추출 + 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/portfolio/portfolioUtils.js`
|
||||
- Create: `src/pages/stock/components/portfolio/portfolioUtils.test.js`
|
||||
- Create: `src/pages/stock/components/portfolio/PriceSessionBadge.jsx`
|
||||
- Create: `src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx`
|
||||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `formatPriceTime(iso)` (portfolioUtils); `PriceSessionBadge({ session, asOf })` (default export).
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — Create `portfolioUtils.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatPriceTime } from './portfolioUtils.js';
|
||||
|
||||
describe('formatPriceTime', () => {
|
||||
it('유효 ISO → HH:MM (제로패드)', () => {
|
||||
expect(formatPriceTime('2026-07-10T09:05:00')).toBe('09:05');
|
||||
expect(formatPriceTime('2026-07-10T18:30:00')).toBe('18:30');
|
||||
});
|
||||
it('falsy/invalid → 빈 문자열', () => {
|
||||
expect(formatPriceTime('')).toBe('');
|
||||
expect(formatPriceTime(null)).toBe('');
|
||||
expect(formatPriceTime('not-a-date')).toBe('');
|
||||
});
|
||||
});
|
||||
```
|
||||
Create `PriceSessionBadge.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import PriceSessionBadge from './PriceSessionBadge.jsx';
|
||||
|
||||
describe('PriceSessionBadge', () => {
|
||||
it('NXT_AFTER → "NXT", NXT_PRE → "NXT 프리"', () => {
|
||||
const { unmount } = render(<PriceSessionBadge session="NXT_AFTER" />);
|
||||
expect(screen.getByText('NXT')).toBeInTheDocument();
|
||||
unmount();
|
||||
render(<PriceSessionBadge session="NXT_PRE" />);
|
||||
expect(screen.getByText('NXT 프리')).toBeInTheDocument();
|
||||
});
|
||||
it('그 외 세션 → 렌더 없음', () => {
|
||||
const { container } = render(<PriceSessionBadge session="REGULAR" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify fail**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx`
|
||||
Expected: FAIL — unresolved imports.
|
||||
|
||||
- [ ] **Step 3: Create files** — `portfolioUtils.js` (formatPriceTime을 현 PortfolioTab.jsx:9-14에서 verbatim):
|
||||
```js
|
||||
export 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')}`;
|
||||
};
|
||||
```
|
||||
`PriceSessionBadge.jsx` (현 PortfolioTab.jsx:16-27 본문 verbatim + import/export):
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { formatPriceTime } from './portfolioUtils';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceSessionBadge;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire PortfolioTab.jsx** — `formatPriceTime`(9-14)와 `PriceSessionBadge`(16-27) 정의 삭제, import 추가:
|
||||
```js
|
||||
import PriceSessionBadge from './portfolio/PriceSessionBadge';
|
||||
```
|
||||
(PortfolioTab 내부 보유행에서 `<PriceSessionBadge .../>` 사용은 그대로 — import한 것을 씀.)
|
||||
|
||||
- [ ] **Step 5: Run tests + build**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx` → PASS.
|
||||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규/수정 0 에러.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add src/pages/stock/components/portfolio/portfolioUtils.js src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.jsx src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx src/pages/stock/components/PortfolioTab.jsx
|
||||
git commit -m "refactor(portfolio): formatPriceTime util + PriceSessionBadge 추출 + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 관리 section 3 컴포넌트 추출 (AddHoldingForm / PortfolioSummary / AssetHistoryChart)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/portfolio/AddHoldingForm.jsx`
|
||||
- Create: `src/pages/stock/components/portfolio/PortfolioSummary.jsx`
|
||||
- Create: `src/pages/stock/components/portfolio/AssetHistoryChart.jsx`
|
||||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AddHoldingForm({ pf })`, `PortfolioSummary({ pf })`, `AssetHistoryChart({ asset, pf, handleSaveSnapshot })` (default exports).
|
||||
|
||||
- [ ] **Step 1: Create the three components (JSX 본문 verbatim, 가드는 내부 early-return)**
|
||||
|
||||
`AddHoldingForm.jsx`:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
```
|
||||
컴포넌트: `const AddHoldingForm = ({ pf }) => { if (!pf.addFormOpen) return null; return ( <form className="pf-add-form" ... > ... </form> ); };` — `return` 내부의 `<form>...</form>`은 현 PortfolioTab.jsx:67-152의 `<form>` JSX를 verbatim(내부의 `pf.addForm`/`pf.setAddForm`/`pf.handleAddSubmit`/`pf.addLoading`/`pf.addError` 참조 그대로). `export default AddHoldingForm;`.
|
||||
|
||||
`PortfolioSummary.jsx`:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../../stockUtils';
|
||||
```
|
||||
컴포넌트: `const PortfolioSummary = ({ pf }) => { if (!(pf.portfolioHoldings.length > 0)) return null; return ( <div className="pf-total-summary"> ... </div> ); };` — 내부는 현 PortfolioTab.jsx:157-195의 `<div className="pf-total-summary">...</div>` JSX verbatim(`pf.portfolioSummary`/`pf.totalCash`/`pf.totalAssets` 참조 그대로). `export default PortfolioSummary;`.
|
||||
|
||||
`AssetHistoryChart.jsx`:
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import Loading from '../../../../components/Loading';
|
||||
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as ChartTooltip } from 'recharts';
|
||||
```
|
||||
컴포넌트: `const AssetHistoryChart = ({ asset, pf, handleSaveSnapshot }) => ( <div className="pf-asset-history"> ... </div> );` — 내부는 현 PortfolioTab.jsx:199-281의 `<div className="pf-asset-history">...</div>` JSX verbatim(`asset.assetHistory*`/`asset.setAssetHistoryDays`/`asset.snapshotSaving`/`handleSaveSnapshot`/`pf.totalAssets` 참조 그대로). `export default AssetHistoryChart;`.
|
||||
|
||||
> 각 파일은 위 import만(실제 사용 심볼). AddHoldingForm은 stockUtils/Loading 불필요(폼만).
|
||||
|
||||
- [ ] **Step 2: Wire PortfolioTab.jsx** — 관리 `<section>` 내부에서 위 3개 인라인 블록을 컴포넌트 호출로 교체:
|
||||
- `{pf.addFormOpen && (<form ...>...</form>)}` → `<AddHoldingForm pf={pf} />`
|
||||
- `{pf.portfolioHoldings.length > 0 && (<div className="pf-total-summary">...</div>)}` → `<PortfolioSummary pf={pf} />`
|
||||
- `<div className="pf-asset-history">...</div>` → `<AssetHistoryChart asset={asset} pf={pf} handleSaveSnapshot={handleSaveSnapshot} />`
|
||||
import 추가:
|
||||
```js
|
||||
import AddHoldingForm from './portfolio/AddHoldingForm';
|
||||
import PortfolioSummary from './portfolio/PortfolioSummary';
|
||||
import AssetHistoryChart from './portfolio/AssetHistoryChart';
|
||||
```
|
||||
PortfolioTab.jsx 상단의 이제-미사용 import 정리: recharts(`ResponsiveContainer/AreaChart/Area/XAxis/YAxis/Tooltip`)가 AssetHistoryChart로 이동했으니 shell에서 제거. `Loading`은 shell 헤더가 계속 사용(`pf.portfolioLoading`) → 유지. stockUtils 심볼 중 shell(브로커 섹션 등 아직 인라인)이 계속 쓰는 것은 유지. (미사용 import 0 되도록 — 빌드/lint 확인.)
|
||||
|
||||
- [ ] **Step 3: Run tests + build + lint**
|
||||
|
||||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 3파일 + PortfolioTab.jsx 0 에러.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/pages/stock/components/portfolio/AddHoldingForm.jsx src/pages/stock/components/portfolio/PortfolioSummary.jsx src/pages/stock/components/portfolio/AssetHistoryChart.jsx src/pages/stock/components/PortfolioTab.jsx
|
||||
git commit -m "refactor(portfolio): 추가폼/총자산요약/자산추이차트 컴포넌트 추출"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `CashPanel` 추출 (예수금 패널)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/portfolio/CashPanel.jsx`
|
||||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `CashPanel({ pf })` (default export).
|
||||
|
||||
- [ ] **Step 1: Create CashPanel.jsx**
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { formatNumber } from '../../stockUtils';
|
||||
```
|
||||
컴포넌트: `const CashPanel = ({ pf }) => ( <section className="stock-panel stock-panel--wide"> ... </section> );` — 내부는 현 PortfolioTab.jsx:285-409의 예수금 `<section>...</section>` JSX verbatim(`pf.cashList`/`pf.cashEditingBroker`/`pf.cashEditingValue`/`pf.setCashEditingValue`/`pf.handleCashInlineSave`/`pf.handleCashInlineCancel`/`pf.handleCashInlineEdit`/`pf.handleCashDelete`/`pf.cashEditSaving`/`pf.cashForm`/`pf.setCashForm`/`pf.handleCashSave`/`pf.cashSaving`/`pf.cashError` 참조 그대로). `export default CashPanel;`.
|
||||
|
||||
- [ ] **Step 2: Wire PortfolioTab.jsx** — 예수금 `<section>...</section>`(285-409) 인라인 블록을 `<CashPanel pf={pf} />`로 교체. import 추가:
|
||||
```js
|
||||
import CashPanel from './portfolio/CashPanel';
|
||||
```
|
||||
(shell이 formatNumber를 예수금 외 다른 곳에서 안 쓰게 되면 정리 — 단 브로커 섹션이 아직 인라인이면 formatNumber 유지. 빌드/lint로 확인.)
|
||||
|
||||
- [ ] **Step 3: Run tests + build + lint**
|
||||
|
||||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 파일 + PortfolioTab.jsx 0 에러.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/pages/stock/components/portfolio/CashPanel.jsx src/pages/stock/components/PortfolioTab.jsx
|
||||
git commit -m "refactor(portfolio): 예수금 패널 CashPanel 컴포넌트 추출"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `HoldingRow` + `BrokerSection` 추출 → PortfolioTab shell 축소 + 검증
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/portfolio/HoldingRow.jsx`
|
||||
- Create: `src/pages/stock/components/portfolio/BrokerSection.jsx`
|
||||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PriceSessionBadge`(Task 1).
|
||||
- Produces: `HoldingRow({ item, pf, handleSell })`, `BrokerSection({ broker, items, pf, handleSell })` (default exports).
|
||||
|
||||
- [ ] **Step 1: Create HoldingRow.jsx**
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../../stockUtils';
|
||||
import PriceSessionBadge from './PriceSessionBadge';
|
||||
```
|
||||
컴포넌트: `const HoldingRow = ({ item, pf, handleSell }) => { ...상수 계산... return ( <div key={item.id} className="stock-holdings__item pf-item"> ... </div> ); };` — 내부는 현 PortfolioTab.jsx:447-654의 `items.map((item) => { ... return (<div ...>...</div>); })` 콜백 본문 verbatim(상수 `profitAmt/profitRate/profitAmtN/profitRateN/isEditing/isDeleting/isSelling/sellPrice/saleAmount` 계산 + `<div>` JSX; `pf.editingId`/`pf.editForm`/`pf.setEditForm`/`pf.handleEditSave`/`pf.editLoading`/`pf.setEditingId`/`pf.handleEditStart`/`pf.sellConfirmId`/`pf.setSellConfirmId`/`pf.sellLoading`/`pf.deleteConfirmId`/`pf.setDeleteConfirmId`/`pf.handleDelete`/`handleSell` 참조 그대로). map 콜백의 `return`을 컴포넌트 `return`으로. `key`는 BrokerSection의 map에서 부여하므로 HoldingRow 내부 최상위 `<div>`의 `key={item.id}`는 제거 가능(무해하나 React는 map 자식에만 key 필요) — **동작 보존 위해 그대로 두거나 제거 무관**; BrokerSection map에서 `<HoldingRow key={item.id} .../>`로 key 부여. `export default HoldingRow;`.
|
||||
|
||||
- [ ] **Step 2: Create BrokerSection.jsx**
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { formatNumber, formatPercent, profitColorClass } from '../../stockUtils';
|
||||
import HoldingRow from './HoldingRow';
|
||||
```
|
||||
컴포넌트: `const BrokerSection = ({ broker, items, pf, handleSell }) => { const bSummary = pf.getBrokerSummary(items); const color = pf.brokerColors[broker]; return ( <section key={broker} className="stock-panel stock-panel--wide pf-broker-section" style={...}> ...헤더... <div className="stock-holdings"> {items.map((item) => <HoldingRow key={item.id} item={item} pf={pf} handleSell={handleSell} />)} </div> </section> ); };` — 섹션 헤더(현 PortfolioTab.jsx:421-445) verbatim, `<div className="stock-holdings">` 내부의 `items.map`을 `HoldingRow` 호출로 교체. `key={broker}`는 PortfolioTab의 map에서 부여하므로 내부 유지/제거 무관. `export default BrokerSection;`.
|
||||
|
||||
- [ ] **Step 3: Reduce PortfolioTab.jsx to shell** — 브로커 섹션 인라인 블록(412-659) `{pf.brokerGroups.map(([broker, items]) => { ... })}`을 다음으로 교체:
|
||||
```jsx
|
||||
{pf.brokerGroups.map(([broker, items]) => (
|
||||
<BrokerSection key={broker} broker={broker} items={items} pf={pf} handleSell={handleSell} />
|
||||
))}
|
||||
```
|
||||
import 추가: `import BrokerSection from './portfolio/BrokerSection';`. (HoldingRow는 BrokerSection이 import.) PortfolioTab.jsx 상단 import 최종 정리: shell이 실제 쓰는 것만 — `React`, `Loading`(관리 section 헤더 spinner), 6개 하위 컴포넌트(PriceSessionBadge는 이제 HoldingRow가 쓰므로 shell import 제거), stockUtils 심볼 중 shell이 직접 쓰는 것(브로커 섹션 이동 후 shell엔 거의 없음 → 미사용 제거). `PriceSessionBadge` import를 shell에서 제거(HoldingRow로 이동). 미사용 import 0 되도록 build/lint 확인. 결과 PortfolioTab.jsx ~70줄 shell.
|
||||
|
||||
- [ ] **Step 4: Run full suite + lint + build**
|
||||
|
||||
Run: `npm run test:run` → 전체 green(회귀 0). `npm run lint` → 0 새 에러. `npm run build` → `✓ built`. `wc -l src/pages/stock/components/PortfolioTab.jsx` ~70줄 확인.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add src/pages/stock/components/portfolio/HoldingRow.jsx src/pages/stock/components/portfolio/BrokerSection.jsx src/pages/stock/components/PortfolioTab.jsx
|
||||
git commit -m "refactor(portfolio): 보유행/브로커섹션 추출 → PortfolioTab shell 축소"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Manual verification (dev server)**
|
||||
|
||||
`npm run dev` → `http://localhost:3007/stock/trade` 포트폴리오 탭. 종목 추가 폼, 총자산 요약, 자산 추이 차트(기간·스냅샷), 예수금(인라인 편집·삭제·추가), 브로커 섹션(요약·예수금 뱃지), 보유행(현재가·NXT 뱃지·편집·매도확인·삭제확인)이 리팩토링 전과 **동일 동작**인지 확인.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§7):
|
||||
- §3 file map(portfolioUtils/7컴포넌트/shell) → Task 1/2/3/4 ✅
|
||||
- §4 컴포넌트별 책임 → 각 Task의 props·JSX 범위 매핑 ✅
|
||||
- §5 테스트(portfolioUtils + PriceSessionBadge 스모크 + build/lint/수동) → Task 1 + 각 Task 검증 + Task 4 수동 ✅
|
||||
- §6 완료기준 → 각 Task 검증 ✅
|
||||
|
||||
**Placeholder scan:** 신규 코드(portfolioUtils/PriceSessionBadge/테스트) 전체 명시. 리프트 컴포넌트는 "현 인라인 JSX(line 범위) verbatim + 가드 early-return + 명시된 import/props"로 지정(플레이스홀더 아님 — 원본 JSX 존재, 참조 심볼·props 명시). ✅
|
||||
|
||||
**Type consistency:** portfolioUtils/PriceSessionBadge(Task1) ↔ HoldingRow import 일치. 각 컴포넌트 props(`pf`/`asset`/`handleSell`/`handleSaveSnapshot`/`item`/`broker`/`items`) ↔ shell/BrokerSection 전달 일치. 경로(portfolio/* → `../../stockUtils`·`../../../../components/Loading`) 일치. ✅
|
||||
|
||||
**참고:** Task 순서 의존(PriceSessionBadge→…→HoldingRow/BrokerSection). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. 리프트 시 가드→early-return, key는 부모 map에서 부여.
|
||||
@@ -0,0 +1,107 @@
|
||||
# 에이전트 횡단 오버사이트 타임라인 — 설계
|
||||
|
||||
작성일: 2026-06-11
|
||||
대상 repo: `web-ui` (프론트엔드)
|
||||
연관 백엔드: ✅ 완료 (`GET /api/agent-office/activity` 필터 지원, main `2c2828c`)
|
||||
|
||||
## 배경 / 목적
|
||||
|
||||
3개 자율 에이전트(stock 보유종목·insta 발급·lotto 진화)가 모두 도는 상태에서
|
||||
"팀이 무엇을·언제·왜 했나"를 **한 화면에서** 보는 에이전트 횡단 오버사이트(CEO 가시화) 기능.
|
||||
|
||||
현재 web-ui에는 `/lotto/evolver` 탭의 lotto 전용 `LottoActivityTimeline`만 존재.
|
||||
통합 `/activity`(전 에이전트 대상)를 소비하는 횡단 뷰가 없다.
|
||||
|
||||
## 백엔드 응답 shape (라이브 검증 완료)
|
||||
|
||||
```
|
||||
GET /api/agent-office/activity?agent_id=&type=task|log&status=&days=&limit=&offset=
|
||||
→ { items: [...], total: N }
|
||||
```
|
||||
|
||||
- **task item**: `{ type:'task', agent_id, task_id, message, created_at, task_type, status, completed_at, duration_seconds }`
|
||||
- **log item**: `{ type:'log', agent_id, task_id, message, created_at, level }`
|
||||
- `status`는 task 전용(`type=log`에 주면 무시). injection 안전(? 바인딩 + 브랜치 선택).
|
||||
|
||||
검증 메모:
|
||||
- 무필터 `total`이 65,599건 → **기본 `days=7` 필터 필수**(task 기준 110건으로 감소).
|
||||
- `requires_approval` 필드는 **존재하지 않음** → `status:'pending'`을 진행/대기 강조로 처리.
|
||||
- `agent_id` 값이 `AGENT_META` 키(stock/music/insta/realestate/lotto)와 일치 → 색상/이미지 재사용.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
AgentOffice는 단일 화면(TopBar + 3×3 AgentGrid + 우측 패널) 구조.
|
||||
우측 패널은 `selectedAgent` 상태로 분기:
|
||||
- `null` → (기존) `EmptyDetailPanel variant="initial"` → **`ActivityTimeline`으로 교체**
|
||||
- `placeholder-N` → `EmptyDetailPanel variant="placeholder"` (유지)
|
||||
- active agent id → `SidePanel` (유지)
|
||||
|
||||
즉 **에이전트 미선택 시 기본 우측 패널이 횡단 타임라인**이 되고, 그리드와 항상 동시 노출.
|
||||
항목/그리드 클릭으로 해당 에이전트 SidePanel로 전환.
|
||||
|
||||
## 신규/변경 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/api.js` | `agentActivity({agent_id,type,status,days,limit,offset})` 추가 — 빈 값 제외 쿼리스트링 빌드 + GET `/api/agent-office/activity` |
|
||||
| `src/pages/agent-office/AgentOffice.jsx` | `selectedAgent===null` 분기를 `EmptyDetailPanel` → `ActivityTimeline`(props: `refreshTrigger`, `onSelectAgent`)로 교체 |
|
||||
| `src/pages/agent-office/hooks/useActivityFeed.js` | items/offset/total/hasMore/loading/error/filters 상태 관리 |
|
||||
| `src/pages/agent-office/components/ActivityTimeline.jsx` | 컨테이너: 헤더 + `ActivityFilters` + 리스트 + 무한스크롤 sentinel + 상태(loading/empty/error/end) |
|
||||
| `src/pages/agent-office/components/ActivityFilters.jsx` | 필터 4종(agent 색칩 / type / status / days). `type==='log'`일 때 status 비활성 |
|
||||
| `src/pages/agent-office/components/ActivityItem.jsx` | 한 행: agent 색·이미지 + message + 상태/level 뱃지 + 상대시간 + duration. 클릭 → `onSelectAgent(agent_id)` |
|
||||
| `src/pages/agent-office/AgentOffice.css` | 타임라인/필터/항목 스타일 (designer 스킬로 마감) |
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
AgentOffice (selectedAgent===null)
|
||||
└─ <ActivityTimeline refreshTrigger={refreshTrigger} onSelectAgent={handleSelectAgent} />
|
||||
└─ useActivityFeed(filters)
|
||||
• mount / 필터 변경 → offset=0 fetch → items 교체
|
||||
• loadMore (sentinel 교차) → offset += limit → items append
|
||||
• refreshTrigger 변경 → offset=0 재조회 → items 교체 (WS 실시간 연동)
|
||||
└─ ActivityItem onClick → onSelectAgent(agent_id) → SidePanel로 전환
|
||||
```
|
||||
|
||||
`handleSelectAgent`는 기존 콜백 재사용(선택 + `clearNotifications`).
|
||||
|
||||
## 필터 기본값
|
||||
|
||||
`days=7`, `type=all`, `status=all`, `agent=all`, `limit=30`(페이지당).
|
||||
|
||||
## 상태 / 비주얼 매핑
|
||||
|
||||
- task `status`: `succeeded` → 초록 ✓ / `failed` → 빨강 ✗ / `pending`·`working` → 앰버 펄스 ⏳(강조)
|
||||
- log `level`: `error` → ❌ / `warning` → ⚠️ / `info` → ·
|
||||
- agent 색상: `AGENT_META[agent_id].color`, 미지정 agent → 회색 `#6b7280`
|
||||
- `offset >= total` → "더 이상 활동 없음" / 무한스크롤은 IntersectionObserver
|
||||
|
||||
## 상태 처리(엣지)
|
||||
|
||||
- 첫 페이지 로딩 → 스피너/스켈레톤
|
||||
- 빈 결과 → "최근 N일 활동 없음"
|
||||
- fetch 실패 → 인라인 에러 + 재시도 버튼
|
||||
- 리스트 끝 → end-of-list 표시, sentinel 관찰 중단
|
||||
|
||||
## 테스트 (TDD, vitest + RTL — 기존 패턴 따름)
|
||||
|
||||
- `useActivityFeed`: 필터 변경 시 offset 리셋 + items 교체 / loadMore append / refreshTrigger 재조회 / `hasMore = items.length < total` 계산 (api mock)
|
||||
- `ActivityItem`: task vs log 렌더 분기, status/level 뱃지 클래스, 클릭 시 `onSelectAgent(agent_id)` 호출
|
||||
- `ActivityFilters`: `type==='log'`일 때 status select 비활성, 필터 변경 시 onChange 호출
|
||||
|
||||
## 비범위 (YAGNI)
|
||||
|
||||
- 별도 라우트(`/agent-office/activity`) 미생성 — 기본 우측 패널 통합으로 충분
|
||||
- 기존 `getActivityFeed(limit, offset)` 헬퍼는 lotto evolver 등에서 사용 여부 확인 후 유지(신규 `agentActivity`와 공존, 무리한 통합 안 함)
|
||||
- `LottoActivityTimeline`(`kind/ts/payload` shape)은 다른 엔드포인트 소비 → 건드리지 않음
|
||||
- CSV/export, 검색어 필터 등 부가기능 제외
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. `agentActivity` api 헬퍼 추가
|
||||
2. `useActivityFeed` 훅 (TDD)
|
||||
3. `ActivityItem` / `ActivityFilters` (TDD)
|
||||
4. `ActivityTimeline` 컨테이너 조립
|
||||
5. `AgentOffice.jsx` 분기 교체
|
||||
6. designer 스킬로 CSS 마감
|
||||
7. lint + 테스트 + 빌드 검증
|
||||
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 관심종목 탭 (Watchlist Tab) — FE 설계
|
||||
|
||||
- **작성일**: 2026-07-03
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **상위 스펙(BE)**: `web-page-backend/docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md` §2·§5.3
|
||||
- **상위 플랜(BE)**: `web-page-backend/docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md`
|
||||
- **범위**: FE(web-ui)만. BE 계약(§5.3)을 소비하는 "관심종목" 탭 구현. 워커(web-ai)·BE는 별도 세션.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
실시간 매매 알림 시스템의 매수 유니버스는 **"watchlist(사용자 관리) ∪ 당일 스크리너 후보"** 로 정의된다(BE 스펙 §2). 관심종목 관리 수단은 **"텔레그램 봇 명령 + web-ui 탭 둘 다"** 로 결정되었다. 본 문서는 그중 **web-ui 탭**을 정의한다.
|
||||
|
||||
목표:
|
||||
1. 사용자가 관심종목을 웹에서 추가/조회/삭제(CRUD)할 수 있다.
|
||||
2. 최근 발생한 매수·매도 시그널 알림 이력을 웹에서 확인할 수 있다.
|
||||
|
||||
비목표(YAGNI, v1 제외):
|
||||
- 종목별 조건 오버라이드(`params_json`: trailing_pct, stop_pct 등) 편집 — BE POST/PUT params 계약 미확정.
|
||||
- 실시간 WebSocket 알림 스트림 — 폴링/수동 새로고침으로 충분.
|
||||
- 텔레그램 설정 UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. 소비할 BE 계약 (§5.3)
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/api/stock/watchlist` | — | `{ watchlist: [{ ticker, name, note, params, added_at }] }` |
|
||||
| POST | `/api/stock/watchlist` | `{ ticker, name?, note? }` | 201 `{ ok: true }` |
|
||||
| DELETE | `/api/stock/watchlist/{ticker}` | — | 200 / 404 |
|
||||
| GET | `/api/stock/trade-alerts?days=N` | — | `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }` |
|
||||
|
||||
**알림 필드 enum (BE 스펙 §5.3):**
|
||||
|
||||
- `kind`: `buy` | `sell`
|
||||
- `condition` (buy): `buy_ma20_pullback` · `buy_breakout` · `buy_rsi_bounce`
|
||||
- `condition` (sell): `sell_stop_loss` · `sell_ma_break` · `sell_take_profit` · `sell_climax` · `sell_trailing_stop`
|
||||
|
||||
> 응답 래핑 키(`watchlist`/`alerts`)와 `params` 필드는 BE 스펙 문구 기준. FE는 방어적으로 파싱한다(배열 직접 반환 / 래핑 둘 다 허용, `params` 미사용이면 무시).
|
||||
|
||||
---
|
||||
|
||||
## 3. 배치 & 탭 등재
|
||||
|
||||
`/stock/trade` (거래 데스크)에 5번째 메인 탭 **"관심종목"** 추가. 기존 탭 등재 패턴을 그대로 확장한다.
|
||||
|
||||
- `src/pages/stock/stockUtils.js`: `export const TAB_WATCHLIST = 'watchlist';`
|
||||
- `src/pages/stock/StockTrade.jsx`:
|
||||
- `TAB_ORDER` 배열에 `TAB_WATCHLIST` 추가
|
||||
- `tabLabels` 에 `'관심종목'` 추가
|
||||
- 데스크탑 탭 버튼 배열에 `{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: <count> }` 추가
|
||||
- 모바일 `SwipeableView` content 분기에 `WatchlistTab` 추가
|
||||
- 데스크탑 조건부 렌더 `{activeTab === TAB_WATCHLIST && <WatchlistTab />}` 추가
|
||||
- 탭 뱃지 = 관심종목 개수(훅에서 노출).
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 구조 (접근안 A: 훅 + 자립형 탭)
|
||||
|
||||
기존 `HoldingsIntelTab` 패턴(자립형 탭 컴포넌트 + api 헬퍼)에 상태 로직을 훅으로 분리한 형태.
|
||||
|
||||
```
|
||||
src/pages/stock/
|
||||
├── hooks/
|
||||
│ └── useWatchlist.js # CRUD + 알림 이력 상태·액션
|
||||
├── components/
|
||||
│ └── WatchlistTab.jsx # 표현 (내부 소형 컴포넌트: WatchlistForm/Row, AlertCard)
|
||||
├── watchlistUtils.js # 순수 헬퍼 (라벨/색/시간 매핑)
|
||||
└── watchlistUtils.test.js # 헬퍼 유닛 테스트
|
||||
```
|
||||
|
||||
### 4.1 `useWatchlist.js` (훅)
|
||||
|
||||
상태:
|
||||
- `items: []` — 관심종목 목록
|
||||
- `alerts: []` — 알림 이력
|
||||
- `alertDays: 7` — 알림 기간 필터(1/7/30)
|
||||
- `loading`, `error`, `adding` (폼 제출 중)
|
||||
|
||||
액션:
|
||||
- `load()` — `getWatchlist()` + `getTradeAlerts(alertDays)` 병렬 로드
|
||||
- `add({ ticker, name, note })` — 낙관적 추가 → 성공 시 `load()` 재조회, 실패 시 롤백 + 에러
|
||||
- `remove(ticker)` — 낙관적 제거 → 실패 시 롤백
|
||||
- `setAlertDays(days)` — 변경 시 알림만 재조회
|
||||
|
||||
노출: `{ items, alerts, alertDays, setAlertDays, loading, error, adding, add, remove, load }`
|
||||
|
||||
### 4.2 `WatchlistTab.jsx` (표현)
|
||||
|
||||
- 마운트 시 `load()`.
|
||||
- **상단 패널 — 관심종목 관리**: 인라인 추가 폼(ticker 필수, name·note 선택) + 목록. 각 행: 종목명/코드/메모/등록일 + 삭제 버튼. 빈 상태 안내.
|
||||
- **하단 패널 — 최근 시그널**: 기간 토글(1D/7D/30D) + 알림 카드. 카드: `kind` 뱃지, `condition` 한글 라벨, `ticker`/`name`, `price`, `detail`, `fired_at` 상대시간.
|
||||
- 로딩/에러/빈 상태: `stock-panel` · `stock-error` · `stock-empty` 등 기존 클래스 재사용.
|
||||
- 하단 면책 문구(`hi-disclaimer` 유사): "※ 어드바이저리 알림이며 자동매매가 아닙니다."
|
||||
|
||||
### 4.3 `watchlistUtils.js` (순수 헬퍼 — 테스트 대상)
|
||||
|
||||
```js
|
||||
KIND_META = { buy: { label: '매수', color, bg }, sell: { label: '매도', color, bg } }
|
||||
CONDITION_LABEL = { buy_ma20_pullback: 'MA20 눌림 반등', buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등', sell_stop_loss: '손절 라인', sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달', sell_climax: '과열 소진', sell_trailing_stop: '트레일링 스톱' }
|
||||
|
||||
kindMeta(kind) // 미정의 → 회색 폴백 + 원문 label
|
||||
conditionLabel(cond) // 미정의 → 원문 그대로 반환
|
||||
normalizeTicker(str) // trim만 수행(한국 종목코드=6자리 숫자, 대문자화 불필요)
|
||||
relativeTime(iso) // '3분 전' / '2시간 전' / '어제' 등, 잘못된 값 → '' 폴백
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 레이어 (`src/api.js` 추가)
|
||||
|
||||
```js
|
||||
// ── Stock Watchlist / Trade Alerts ──
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); // { ticker, name?, note? }
|
||||
export const removeWatchlist= (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7)=> apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
```
|
||||
|
||||
전부 상대경로, 기존 `apiGet/apiPost/apiDelete` 재사용. `getWatchlist`/`getTradeAlerts` 응답은 훅에서 `data.watchlist ?? data ?? []`, `data.alerts ?? data ?? []` 로 방어적 파싱.
|
||||
|
||||
---
|
||||
|
||||
## 6. UX / 상호작용 세부
|
||||
|
||||
- **추가 폼**: ticker 미입력 시 제출 비활성. 제출 중 `adding` → 버튼 로딩. 성공 시 폼 초기화.
|
||||
- **낙관적 갱신**: add/remove 즉시 UI 반영, 실패 시 이전 상태 롤백 + `stock-error` 메시지.
|
||||
- **중복 방지**: 이미 목록에 있는 ticker면 폼에서 안내(추가 차단).
|
||||
- **알림 카드 정렬**: `fired_at` 내림차순(최신 우선).
|
||||
- **빈 상태**: 관심종목 0개 / 알림 0개 각각 안내 문구.
|
||||
- **반응형**: 데스크탑 2열/모바일 1열은 기존 `stock-panel` 그리드 관례 따름.
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일
|
||||
|
||||
`src/pages/stock/Stock.css` 하단에 `wl-*` 프리픽스 섹션 추가 (기존 `hi-*` 패턴과 동일 구성):
|
||||
- `.wl-form`, `.wl-list`, `.wl-row`, `.wl-row__meta`, `.wl-del`
|
||||
- `.wl-alerts`, `.wl-alert`, `.wl-kind-badge`, `.wl-cond`, `.wl-period-toggle`
|
||||
- 색상: 매수 초록 `#22c55e`, 매도 빨강 `#ef4444` (기존 `ACTION_MAP` 팔레트와 일치).
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 (TDD)
|
||||
|
||||
`watchlistUtils.test.js` — 순수 헬퍼 검증:
|
||||
1. `conditionLabel`: 정의된 8종 매핑 정확, 미정의 값은 원문 폴백.
|
||||
2. `kindMeta`: buy/sell 라벨·색, 미정의 kind 회색 폴백.
|
||||
3. `relativeTime`: 방금/분/시간/일 경계, 잘못된 입력 `''` 폴백.
|
||||
4. `normalizeTicker`: 공백 trim.
|
||||
|
||||
컴포넌트/훅은 수동 검증(개발 서버 3007 + BE 계약) + 빌드/lint 통과로 확인. (기존 스크리너 훅 테스트처럼 필요 시 훅 테스트 추가 가능하나 v1 필수 아님.)
|
||||
|
||||
---
|
||||
|
||||
## 9. 완료 기준 (Acceptance)
|
||||
|
||||
- [ ] 거래 데스크에 "관심종목" 탭 노출(데스크탑·모바일), 뱃지에 개수 표시.
|
||||
- [ ] 종목 추가/삭제가 BE 계약대로 동작(낙관적 갱신 + 실패 롤백).
|
||||
- [ ] 최근 알림 이력이 기간 토글별로 조회되고, kind/condition 한글 라벨·색으로 표시.
|
||||
- [ ] `watchlistUtils.test.js` 통과.
|
||||
- [ ] `npm run lint` · `npm run build` 통과.
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크 / 오픈 이슈
|
||||
|
||||
- **응답 래핑 형태 미확정**: BE가 `{ watchlist: [...] }` 인지 배열 직접인지 문구 기준 불확실 → 방어적 파싱으로 흡수.
|
||||
- **알림 엔드포인트 미배포 가능성**: BE 세션 미완 시 GET `/api/stock/trade-alerts` 404/네트워크 오류 → 알림 패널은 에러 상태를 조용히 표시하고 관심종목 CRUD는 독립 동작하도록 분리.
|
||||
- **params 편집**: v1 제외. 추후 BE POST/PUT params 계약 확정 후 별도 스펙으로 확장.
|
||||
@@ -0,0 +1,90 @@
|
||||
# RealEstate.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 2)
|
||||
|
||||
- **작성일**: 2026-07-09
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **범위**: FE 대형 컴포넌트 분해 sweep의 **2번째 서브프로젝트**. 컨벤션은 sub-project 1(Subscription)에서 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 이번은 적용만.
|
||||
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
`src/pages/realestate/RealEstate.jsx`(909줄, `/realestate/property` 관심 단지 페이지)를 기능 모듈 구조로 동작 보존 분해한다. Subscription과 달리 **메인이 데이터 상태(complexes)+CRUD API를 직접 보유**하고 하위는 순수 표현 컴포넌트(props)인 "스마트 컨테이너 + 표현" 구조 → 데이터 로직은 `hooks/useComplexes.js`로 추출(컨벤션: shell은 조합만).
|
||||
|
||||
비목표: 동작/스타일 변경, 신규 기능, MapFlyTo/EventItem/CustomTooltip 같은 소형 내부 컴포넌트의 과분해.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현 구조 맵 (RealEstate.jsx 909줄)
|
||||
|
||||
- 상수/데이터: `SAMPLE_COMPLEXES`(폴백 샘플 4개), `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS`(['목록','일정','분석'])
|
||||
- 순수 헬퍼: `formatDate`, `formatPrice`, `getDDays`, `createMarkerIcon`(leaflet `L.divIcon`)
|
||||
- 컴포넌트: `MapFlyTo`(react-leaflet 훅), `ComplexCard`, `RightPanel`(지도+상세, ~183줄, MapFlyTo 사용), `ScheduleView`(내부 `EventItem`), `PriceAnalysis`(차트+표, 내부 `CustomTooltip`), `ComplexModal`(폼 모달)
|
||||
- 메인 `RealEstate`: 상태(complexes/selectedComplex/activeTab/filterStatus/showModal/editingComplex) + `useEffect` 로드 + `handleAdd/handleUpdate/handleDelete/handleModalSave` + `filteredComplexes`/`stats` memo + 헤더/탭/목록·일정·분석 렌더 + 모달.
|
||||
|
||||
API: `apiGet/apiPost/apiPut/apiDelete` → `/api/realestate/complexes`(+`/:id`).
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표 file map
|
||||
|
||||
```
|
||||
src/pages/realestate/
|
||||
├── RealEstate.jsx # shell/컨테이너 (~150줄): UI 상태(selected/activeTab/filter/modal) + useComplexes 소비 + 조합
|
||||
├── RealEstate.css # 변경 없음 (shell에서 import 유지)
|
||||
├── realEstateUtils.js # SAMPLE_COMPLEXES·STATUS_CONFIG·PRIORITY_LABELS·EMPTY_FORM·TABS + formatDate·formatPrice·getDDays·createMarkerIcon
|
||||
├── realEstateUtils.test.js # 순수헬퍼 유닛 (신규)
|
||||
├── hooks/
|
||||
│ └── useComplexes.js # complexes 상태 + 로드 + add/update/delete (낙관적 UI + API), { complexes, addComplex, updateComplex, deleteComplex } 반환
|
||||
└── components/
|
||||
├── ComplexCard.jsx # ({ complex, isSelected, onClick })
|
||||
├── RightPanel.jsx # ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) — MapFlyTo 내부 유지
|
||||
├── ScheduleView.jsx # ({ complexes }) — EventItem 내부 중첩 유지
|
||||
├── PriceAnalysis.jsx # ({ complexes }) — CustomTooltip 내부 중첩 유지
|
||||
└── ComplexModal.jsx # ({ complex, onClose, onSave })
|
||||
```
|
||||
|
||||
**import 경로 규칙:** components/hooks → api `'../../../api'`; components/hooks → utils `'../realEstateUtils'`; shell → utils `'./realEstateUtils'`, hook `'./hooks/useComplexes'`, components `'./components/*'`, CSS `'./RealEstate.css'`. `realEstateUtils.js`는 `leaflet`의 `L`만 import(createMarkerIcon용), api import 없음. `RightPanel.jsx`는 leaflet/react-leaflet import 필요.
|
||||
|
||||
---
|
||||
|
||||
## 4. `useComplexes` 훅 (데이터 레이어 추출)
|
||||
|
||||
현 메인의 데이터 로직을 그대로 옮김(동작 보존):
|
||||
- 상태 `complexes`(초기값 `SAMPLE_COMPLEXES`), `useEffect`로 `GET /api/realestate/complexes` 로드(배열 있으면 교체).
|
||||
- `addComplex(data)` — 낙관적 append(`id: Date.now()`) + `POST`.
|
||||
- `updateComplex(data)` — 낙관적 map 교체 + `PUT /:id`.
|
||||
- `deleteComplex(id)` — 낙관적 filter + `DELETE /:id`. (confirm은 shell에 유지할지 훅에 둘지 — **동작 보존 위해 현 위치(핸들러) 그대로**: `handleDelete`의 `confirm`은 shell에 남기고 훅은 순수 삭제만 담당. 즉 훅은 `deleteComplex(id)`가 낙관적 삭제+API, shell이 confirm 후 호출.)
|
||||
- 반환: `{ complexes, addComplex, updateComplex, deleteComplex }`. selectedComplex 동기화(삭제/수정 시)는 shell이 담당(UI 상태이므로).
|
||||
|
||||
> 주의: 현 코드의 `handleUpdate`가 `selectedComplex?.id === data.id`면 `setSelectedComplex(data)`도 함. 이는 UI 상태(selectedComplex) 갱신이므로 **shell에 유지**. 훅은 complexes만 관리. shell의 `handleUpdate` 래퍼가 `updateComplex(data)` 호출 + selectedComplex 동기화.
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 (안전망 — 현재 RealEstate 테스트 0)
|
||||
|
||||
1. **`realEstateUtils.test.js`(신규, 필수)** — 순수 헬퍼:
|
||||
- `formatPrice`: 4500→"4,500만원", 0/falsy→"-".
|
||||
- `getDDays`: 오늘=D-Day, 미래 D-N, 과거 D+N, null→null.
|
||||
- `formatDate`: 유효 날짜 한국어 포맷, falsy→"-".
|
||||
2. 컴포넌트는 표현 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동). ComplexCard 스모크(선택) 정도 가능.
|
||||
3. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규 파일 0 에러. 최종 개발서버 `/realestate/property` 3탭(목록/일정/분석)·지도 마커·단지 추가/편집/삭제 모달이 리팩토링 전과 동일 동작.
|
||||
|
||||
---
|
||||
|
||||
## 6. 완료 기준
|
||||
|
||||
- [ ] RealEstate.jsx가 shell(~150줄) + hooks/useComplexes + realEstateUtils(+test) + components/5로 분해, 동작/스타일 불변.
|
||||
- [ ] `realEstateUtils.test.js` 통과, 전체 `npm run test:run` green.
|
||||
- [ ] `npm run lint`·`npm run build` 통과.
|
||||
- [ ] `/realestate/property` 수동 검증 동일 동작(지도·모달·탭·필터).
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 / 오픈 이슈
|
||||
|
||||
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/수동으로 위험 최소화.
|
||||
- **훅 추출 시 상태 경계**: complexes(데이터)는 훅, selectedComplex/activeTab/filter/modal(UI)은 shell. 현 동작(특히 update 시 selectedComplex 동기화, delete 시 confirm)을 그대로 재현.
|
||||
- **leaflet/recharts import**: RightPanel(leaflet)·PriceAnalysis(recharts)가 각자 필요한 것만 import. createMarkerIcon은 utils에서 `L` import.
|
||||
- **후속 follow-up**: RightPanel(~200줄)은 지도/상세 2책임 — 필요 시 후속 분할 여지 기록.
|
||||
- **다음 서브프로젝트**: PortfolioTab(671, stock/) → InstaCards(1013) → MusicStudio(1936).
|
||||
@@ -0,0 +1,125 @@
|
||||
# FE 기능 모듈 구조 컨벤션 + Subscription 분해 리팩토링 — 설계
|
||||
|
||||
- **작성일**: 2026-07-09
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **범위**: FE 대형 컴포넌트 책임 분해 리팩토링의 **1번째 서브프로젝트**. (전체 분해: Subscription→RealEstate→PortfolioTab→InstaCards→MusicStudio 순차, 각 별도 spec/plan.)
|
||||
- **성격**: **behavior-preserving(동작 보존) 순수 추출**. 로직 재작성 금지, UI/동작 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
기능이 늘며 일부 페이지 컴포넌트가 단일 파일에 과도한 책임을 담게 됨(예: `Subscription.jsx` 1647줄에 4탭+카드+상세+캘린더+헬퍼 전부). 하네스 엔지니어링 원칙(단일 책임·명확한 경계·CLAUDE.md 계층 문서화)에 맞춰 **기능 모듈 구조**로 분해하고, 그 컨벤션을 하네스(web-ui/CLAUDE.md)와 README에 기록한다.
|
||||
|
||||
이 스펙은 두 부분:
|
||||
- **Part A (공통 컨벤션 + 문서화)**: "기능 모듈 구조" 표준 정의 → 이후 모든 서브프로젝트가 참조. web-ui/CLAUDE.md + README 기록.
|
||||
- **Part B (Subscription 분해)**: 컨벤션을 `Subscription.jsx`에 첫 적용.
|
||||
|
||||
비목표: 동작/스타일 변경, 신규 기능, api.js 중복(local apiPatch) 정리 외 무관 리팩토링.
|
||||
|
||||
---
|
||||
|
||||
## 2. Part A — 기능 모듈 구조 컨벤션 (하네스 기록)
|
||||
|
||||
**표준 디렉토리 형태** (레퍼런스: `src/pages/listings/`):
|
||||
```
|
||||
src/pages/<feature>/
|
||||
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||
├── <Feature>.css # 스타일 (shell에서 1회 import → 전역 클래스 하위 공유)
|
||||
├── components/ # 표현 단위 (카드/탭/상세/리스트…) 각 단일 책임
|
||||
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
- 파일당 **단일 책임**, 권장 상한 **~300줄**(초과 시 분할 신호).
|
||||
- 순수 로직(포맷/매핑/계산)은 `*Utils.js`로 뽑고 **유닛 테스트 필수**.
|
||||
- 상태+API가 얽힌 로직은 `hooks/` 또는 자립형 탭 컴포넌트로.
|
||||
- shell은 "조합"만: 데이터 페칭·비즈니스 로직을 직접 갖지 않음.
|
||||
- 컴포넌트는 서로 잘 정의된 props/훅 인터페이스로만 통신.
|
||||
|
||||
**문서화 대상:**
|
||||
- `web-ui/CLAUDE.md`: "기능 모듈 구조" 섹션 신설(위 형태·규칙), 페이지 구조 표에 리팩토링 반영.
|
||||
- `README.md`: 프로젝트 통계/구조 설명에 모듈 구조 컨벤션 1문단 추가.
|
||||
|
||||
---
|
||||
|
||||
## 3. Part B — Subscription.jsx 분해 (file map)
|
||||
|
||||
현 `src/pages/subscription/Subscription.jsx`(1647줄)를 아래로 분해. **코드 이동만**(내용 동일), import 배선만 추가.
|
||||
|
||||
```
|
||||
src/pages/subscription/
|
||||
├── Subscription.jsx # shell: PullToRefresh + 헤더 + 탭바 + FAB + activeTab/refreshKey. 4탭 조합. (~70줄)
|
||||
├── Subscription.css # (변경 없음, shell에서 import 유지)
|
||||
├── subscriptionUtils.js # 상수·순수헬퍼 이동
|
||||
├── subscriptionUtils.test.js # 순수헬퍼 유닛 테스트 (신규)
|
||||
└── components/
|
||||
├── StatusBadge.jsx # (기존 컴포넌트 이동)
|
||||
├── DashboardTab.jsx # 자립형(self-fetch)
|
||||
├── AnnouncementCard.jsx
|
||||
├── AnnouncementDetail.jsx # detailTab 상태 + 정보/일정/주택형/매칭분석
|
||||
├── CalendarView.jsx
|
||||
├── AnnouncementsTab.jsx # AnnouncementCard/AnnouncementDetail/CalendarView 조합
|
||||
├── MatchesTab.jsx
|
||||
├── ProfileTab.jsx # DistrictTierEditor/NotificationSettings(기존) 사용
|
||||
├── DistrictTierEditor.jsx # (기존 유지)
|
||||
└── NotificationSettings.jsx # (기존 유지)
|
||||
```
|
||||
|
||||
**`subscriptionUtils.js`로 이동할 항목:**
|
||||
- 상수: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`
|
||||
- 순수 헬퍼: `extractTier`, `fmt`, `fmtFull`, `_diffDays`, `getDDays`, `getDDayColor`, `fmtDateTime`, `fmtPrice`
|
||||
- `apiPatch`(현재 Subscription 로컬 정의) — 이동. (⚠️ `src/api.js`에 동일 기능 `apiPatch` 이미 존재 — 이번엔 **로컬 것을 utils로 그대로 이동**(동작 보존), api.js와의 dedupe는 후속 follow-up으로 기록.)
|
||||
|
||||
**의존 관계(이동 시 import 배선):**
|
||||
- `DashboardTab` → apiGet/apiPost, STATUS_CONFIG, fmt류, getDDays/getDDayColor, fmtDateTime, fmtPrice, StatusBadge
|
||||
- `AnnouncementCard`/`AnnouncementDetail` → STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt/fmtFull, getDDays/getDDayColor, fmtPrice, StatusBadge
|
||||
- `AnnouncementsTab` → apiGet/apiDelete/apiPatch, STATUS_FILTERS, AnnouncementCard/AnnouncementDetail/CalendarView
|
||||
- `MatchesTab` → apiGet/apiPost/apiPatch, extractTier, fmt/getDDays/getDDayColor, StatusBadge
|
||||
- `ProfileTab` → apiGet/apiPut, DEFAULT_PROFILE, DistrictTierEditor/NotificationSettings
|
||||
- `Subscription`(shell) → TABS, 4탭 컴포넌트, PullToRefresh/FAB/Link
|
||||
|
||||
**주의:** `/realestate/listings` 교차링크(현 shell의 Link)는 그대로 유지.
|
||||
|
||||
---
|
||||
|
||||
## 4. 테스트 (안전망 — 현재 Subscription은 테스트 0)
|
||||
|
||||
behavior-preserving라 기존 동작 유지가 핵심. 안전망:
|
||||
1. **`subscriptionUtils.test.js` (신규, 필수)** — 순수 헬퍼 유닛:
|
||||
- `extractTier`: "자치구 S티어: 강남구 (+25)" → "S"; 미매치 → null.
|
||||
- `getDDays`/`getDDayColor`: D-day 경계(오늘=D-Day, 미래 D-N, 과거 D+N; 색 임계 0/3/7).
|
||||
- `fmtPrice`(현 구현 동작 고정): 10000→"1억", 15000→"1.5억", 9999→"9,999만", null→null.
|
||||
- `_diffDays`: 로컬 타임존 파싱 경계.
|
||||
2. **`StatusBadge.test.jsx` (신규, 스모크)** — status→색/라벨, 미정의 status 폴백.
|
||||
3. 각 탭 컴포넌트는 자립형(api 페치) → 이번 범위는 **스모크 최소화**(추출 검증은 build+lint+수동). 탭 스모크(vi.mock api)는 권장이나 필수 아님(후속).
|
||||
|
||||
**검증 게이트(품질 게이트):** `npm run test:run`(신규 유닛 포함 전체 green) + `npm run lint`(신규 파일 0 에러) + `npm run build`(성공) + 개발서버 `/realestate` 4탭 수동 확인(공고 목록/상세/캘린더/매칭/프로필 저장 동작 동일).
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트 구조 (분해 후 검증 관점)
|
||||
|
||||
각 파일이 "무엇을 하는가 / 어떻게 쓰는가 / 무엇에 의존하는가"로 독립 이해 가능해야 함:
|
||||
- `AnnouncementDetail`(현 272줄) → 여전히 큰 편이나 단일 책임(공고 상세). 내부 detailTab 3섹션은 한 컴포넌트 응집. 300줄 근처 → 필요 시 후속 분할 여지 기록.
|
||||
- `ProfileTab`(현 390줄) → 폼이 커서 300줄 초과. 이번엔 파일 추출까지만, 폼 섹션(기본/자격/선호) 하위 분할은 후속 follow-up으로 기록(과분해 회피, YAGNI).
|
||||
|
||||
---
|
||||
|
||||
## 6. 완료 기준 (Acceptance)
|
||||
|
||||
- [ ] Part A: web-ui/CLAUDE.md "기능 모듈 구조" 섹션 + README 컨벤션 문단 기록.
|
||||
- [ ] Part B: Subscription.jsx가 shell(~70줄) + components/8 + subscriptionUtils(+test)로 분해, 동작/스타일 불변.
|
||||
- [ ] `subscriptionUtils.test.js`·`StatusBadge.test.jsx` 통과, 전체 `npm run test:run` green.
|
||||
- [ ] `npm run lint`·`npm run build` 통과.
|
||||
- [ ] `/realestate` 4탭 수동 검증 동일 동작.
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 / 오픈 이슈
|
||||
|
||||
- **테스트 부재 컴포넌트의 리팩토링**: 순수 추출로 위험 최소화 + build/lint/유닛테스트/수동검증. 로직 한 줄도 바꾸지 않음.
|
||||
- **거대 import 배선 실수 가능**: 추출 시 각 파일의 사용 심볼을 정확히 import해야 함(누락 시 build 실패로 즉시 검출).
|
||||
- **후속 follow-up(문서화, 이번 미적용)**: ProfileTab 폼 섹션 분할; local apiPatch ↔ api.js apiPatch dedupe; AnnouncementDetail 추가 분할.
|
||||
- **다음 서브프로젝트**: RealEstate(909)→PortfolioTab(671)→InstaCards(1013)→MusicStudio(1936), 각 동일 컨벤션 별도 spec/plan.
|
||||
157
docs/superpowers/specs/2026-07-09-realestate-listings-design.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 실매물 매물·안전마진 UI (`/realestate/listings`) — FE 설계
|
||||
|
||||
- **작성일**: 2026-07-09
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **상위(BE)**: realestate-lab 매물 알림 + 안전마진 배포 완료(main a7be8f7), `web-backend` §9 realestate / `service_realestate.md`. co-gahusb BE→FE msg id 3.
|
||||
- **범위**: FE(web-ui)만. BE 계약(8 엔드포인트)을 소비하는 신규 전용 페이지. 매물 수집은 NAS 전담(web-ai 워커 불필요).
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
BE가 실매물(매매/전세) **매물 수집 + 안전마진(전세가율)·적정성(호가율) 판정 + 예산 계산** 기능을 배포했다. 이는 기존 `/realestate`(청약, Subscription)와 **다른 도메인**이므로 신규 전용 라우트 `/realestate/listings`에 구현한다.
|
||||
|
||||
목표(v1, 하위 4기능 전체):
|
||||
1. 매물 목록 조회 + 필터 + 판정 tier 뱃지 + 수동 수집 트리거.
|
||||
2. 조건(criteria) 편집 — 특히 `equity`/`annual_income`(만원) 입력 UI(예산상한·전세 max_deposit 매칭 반영).
|
||||
3. 안전마진 단건 체커(safety-check) — 즉시 전세가율/호가율 판정.
|
||||
4. 예산 계산기(budget) — 자기자본·소득 기반 전세/매매/지역 한도.
|
||||
|
||||
비목표(YAGNI): 매물 상세 모달·지도, 실시간 알림 UI(텔레그램은 BE, FE는 `notify_enabled` 토글만), `/announcements`·청약 관련.
|
||||
|
||||
---
|
||||
|
||||
## 2. 소비할 BE 계약 (8 엔드포인트, 전부 `/api/realestate/*` 상대경로)
|
||||
|
||||
| 메서드 | 경로 | 요청/응답 |
|
||||
|--------|------|-----------|
|
||||
| GET | `/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` | → `{ listings: [ <매물아이템> ] }` (+판정 join) |
|
||||
| POST | `/listings/collect` | 수동 수집 트리거 |
|
||||
| GET | `/listings/collect/status` | → `{ status, collected_at?, new_count?, total_count?, error? }` (예: `{status:"never_run"}`) |
|
||||
| GET | `/listings/criteria` | → 아래 criteria shape |
|
||||
| PUT | `/listings/criteria` | body=변경 필드만(부분 업데이트) → 갱신된 criteria |
|
||||
| GET | `/listings/matches` | → `{ matches: [ <매칭+판정> ] }` |
|
||||
| POST | `/safety-check` | body `{ area:float, deal_type, amount:만원, dong_code?, complex_name?, dong? }` → `{ median, ratio, tier, sample, is_toheo, disclaimer }` |
|
||||
| POST | `/budget` | body `{ equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? }` → `{ jeonse:{loan_limit,max_deposit,notes}, purchase:{ltv_pct,loan_cap,max_price,dsr_note,regulation_flags}, region:{is_toheo,is_regulated,notes}, disclaimer }` |
|
||||
|
||||
**criteria shape (프로덕션 확인):**
|
||||
```
|
||||
{ id, dongs:string[], deal_types:string[]("전세"|"반전세"|"매매"), max_deposit:int(만원),
|
||||
max_sale_price:int|null(만원), min_area:float, house_types:string[],
|
||||
min_safety_tier:string|null, notify_enabled:0|1, equity:int|null(만원),
|
||||
annual_income:int|null(만원), is_homeless:0|1, is_householder:0|1, is_first_home:0|1, updated_at }
|
||||
```
|
||||
|
||||
**판정 tier (표시 규칙):**
|
||||
- 임차(전세/반전세) `safety_tier`(전세가율): 안전🟢 / 주의🟡 / 위험🔴 / 보류⚪
|
||||
- 매매 `valuation_tier`(호가율): 저평가🟢 / 시세🟡 / 고가🔴 / 보류⚪
|
||||
- 표본(sample) < 3 → tier="보류".
|
||||
|
||||
**매물 아이템 shape (추론 — 수집 전이라 실샘플 없음, FE는 방어적 렌더):** BE "매물 목록(+판정 join)" 설명 기준 예상 필드 = `{ id, complex_name?, dong?, deal_type?, area?, price?/deposit?/monthly?(만원), safety_tier?, valuation_tier?, ratio?, median?, regulation_flags?:[], reasons?:[], matched?, url? }`. 존재하는 필드만 렌더하고 없는 필드는 생략. 수집 실행 후 실데이터로 필드명 확인·조정(구현 단계 verify).
|
||||
|
||||
> ⚠️ **`regulation_flags`/`reasons`는 이미 배열(list)로 파싱되어 옴 — `JSON.parse` 금지, 그대로 렌더.** (BE 명시. Subscription의 옛 `JSON.parse` 패턴 답습 금지.)
|
||||
|
||||
---
|
||||
|
||||
## 3. 배치 & 라우팅
|
||||
|
||||
- 신규 라우트 `realestate/listings` → `Listings` (routes.jsx `appRoutes`에 추가, `lazy` import). `realestate/property`(RealEstate) 서브라우트 패턴과 동일.
|
||||
- 교차 링크: Subscription(청약) 헤더에 "실매물 →"(`/realestate/listings`), Listings 헤더에 "청약으로"(`/realestate`). 홈 허브 `realestate` 카드 description은 유지(선택적으로 "매물" 언급).
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 구조 (전용 모듈 — 접근안 A)
|
||||
|
||||
기존 Subscription.jsx(1642줄)에 얹지 않고 독립 모듈. 각 파일 단일 책임.
|
||||
|
||||
```
|
||||
src/pages/listings/
|
||||
├── Listings.jsx # 페이지 shell + 내부 탭바(매물/판정도구/조건) + useIsMobile
|
||||
├── Listings.css # lst-* (Subscription의 --text-*/--surface/--line/--radius-* 토큰 재사용)
|
||||
├── components/
|
||||
│ ├── ListingsTab.jsx # 매물 목록 + 필터(동/거래유형/tier/매칭만) + 페이지네이션 + tier 뱃지 + 수집
|
||||
│ ├── ToolsTab.jsx # 안전마진 체커 카드 + 예산 계산기 카드 (입력→결과, disclaimer)
|
||||
│ └── CriteriaTab.jsx # 조건 편집 폼 (equity·annual_income 포함, 부분 PUT)
|
||||
├── listingsUtils.js # 순수헬퍼 (tier 매핑·금액 포맷)
|
||||
└── listingsUtils.test.js
|
||||
```
|
||||
|
||||
내부 탭 3개(기능 4종 수용): **매물 / 판정 도구(안전마진+예산) / 조건**.
|
||||
|
||||
### 4.1 `listingsUtils.js` (순수헬퍼 — 테스트 대상)
|
||||
```
|
||||
SAFETY_TIER_META = { 안전:{emoji:'🟢',color}, 주의:{emoji:'🟡',color}, 위험:{emoji:'🔴',color}, 보류:{emoji:'⚪',color} }
|
||||
VALUATION_TIER_META = { 저평가:{'🟢'}, 시세:{'🟡'}, 고가:{'🔴'}, 보류:{'⚪'} }
|
||||
tierMeta(kind, tier) // kind: 'safety'|'valuation'; 미정의 tier → 회색⚪ + 원문
|
||||
formatMoney(만원) // int(만원) → "X억 Y만"/"X억"/"X만" 정밀 표기(9999→"9,999만", 10000→"1억", 15000→"1억 5,000만"), null→'-'
|
||||
formatRatio(ratio) // 0.812 → "81.2%", null→'-'
|
||||
```
|
||||
|
||||
### 4.2 컴포넌트 데이터 규칙
|
||||
- 응답 방어적 파싱: `data.listings ?? []`, `data.matches ?? []`, `data.criteria ?? data`.
|
||||
- `regulation_flags`/`reasons`: 배열 그대로 `.map` (JSON.parse 금지).
|
||||
- 어떤 필드든 객체면 직접 JSX 렌더 금지(watchlist detail 교훈) — tier/금액/문자열만 렌더, 배열은 칩/리스트.
|
||||
|
||||
---
|
||||
|
||||
## 5. API 레이어 (`src/api.js` 추가)
|
||||
|
||||
```js
|
||||
// ── Realestate Listings / 안전마진 / 예산 ──
|
||||
export const getListings = ({dong,deal_type,tier,matched_only,page=1,size=20}={}) => { /* querystring, 빈값 제외 */ };
|
||||
export const collectListings = () => apiPost('/api/realestate/listings/collect');
|
||||
export const getListingsCollectStatus = () => apiGet('/api/realestate/listings/collect/status');
|
||||
export const getListingsCriteria = () => apiGet('/api/realestate/listings/criteria');
|
||||
export const putListingsCriteria = (body) => apiPut('/api/realestate/listings/criteria', body); // 부분 업데이트
|
||||
export const getListingsMatches = () => apiGet('/api/realestate/listings/matches');
|
||||
export const safetyCheck = (body) => apiPost('/api/realestate/safety-check', body);
|
||||
export const budgetCalc = (body) => apiPost('/api/realestate/budget', body);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UX / 안전장치 (BE 강조)
|
||||
|
||||
- **disclaimer 항상 노출**: safety-check·budget 결과 카드 하단, matches/판정 영역에 disclaimer 문자열 표시. 등기부 선순위·토허·정책수치 근사 경고 → 잘못된 금융/안전 조언 방지. disclaimer가 응답에 있으면 반드시 렌더.
|
||||
- **보류⚪**: 표본<3 등 판정 불가 시 tier="보류" 그대로 표시(회색).
|
||||
- **금액 만원 단위**: 입력·표시 모두 만원. 큰 값은 억 환산 표시.
|
||||
- **is_toheo(토지거래허가)/regulation_flags**: budget/safety 결과에 플래그 칩으로 표시.
|
||||
- **조건 저장**: 부분 PUT(변경 필드만). equity·annual_income 미입력 시 null 유지(매칭에 미반영됨을 힌트로 안내).
|
||||
- 로딩/에러/빈 상태(수집 전 `never_run`이면 "수집을 실행해 매물을 모아보세요" 안내).
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일
|
||||
|
||||
`Listings.css`에 `lst-*` 프리픽스. Subscription의 CSS 변수(`--text-bright/--text-dim/--text-muted/--surface/--surface-raised/--line/--radius-sm`)·다크 테마 재사용. tier 뱃지 색은 판정 규칙 색(🟢#34d399/🟡#f59e0b/🔴#f87171/⚪#94a3b8).
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 (TDD)
|
||||
|
||||
`listingsUtils.test.js` — 순수헬퍼:
|
||||
1. `tierMeta('safety', ...)` 4종 + `tierMeta('valuation', ...)` 4종 이모지·색.
|
||||
2. 미정의 tier → 회색⚪ + 원문 폴백.
|
||||
3. `formatMoney`: 만/억 경계(9999→"9,999만", 10000→"1억", 15000→"1억 5,000만"), null→'-'.
|
||||
4. `formatRatio`: 0.812→"81.2%", null→'-'.
|
||||
|
||||
컴포넌트는 스모크(빈 상태 렌더 + 객체 필드 크래시 없음) + 빌드/lint 통과. safety-check/budget 폼 제출→결과 렌더는 mock 훅으로 스모크 가능(선택).
|
||||
|
||||
---
|
||||
|
||||
## 9. 완료 기준 (Acceptance)
|
||||
|
||||
- [ ] `/realestate/listings` 라우트 노출, Subscription↔Listings 교차 링크.
|
||||
- [ ] 매물 탭: 목록/필터/페이지네이션/tier 뱃지/수집 동작(빈 상태 안내 포함).
|
||||
- [ ] 판정 도구 탭: 안전마진·예산 입력→결과 + **disclaimer 노출**.
|
||||
- [ ] 조건 탭: criteria GET/부분 PUT, equity·annual_income 입력.
|
||||
- [ ] `regulation_flags`/`reasons` 배열 그대로 렌더(JSON.parse 없음).
|
||||
- [ ] `listingsUtils.test.js` 통과, `npm run lint`·`npm run build` 통과.
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크 / 오픈 이슈
|
||||
|
||||
- **매물 아이템 실스키마 미확정**: 수집(`collect`)이 아직 안 돌아 `/listings`가 빈 배열. FE는 방어적 렌더(존재 필드만)로 대응하고, 수집 실행 후 실데이터로 필드명 확인·조정(구현 verify 단계). 필요 시 BE에 아이템 필드 목록 확인 요청.
|
||||
- **safety-check dong_code**: 동 코드(예 "11590")/이름 매핑 필요 시 criteria.dongs 활용. 입력은 동 이름 select + 선택적 dong_code.
|
||||
- **matches vs listings(matched_only)**: 매칭 결과는 `matched_only=true` 필터 또는 `/matches` 중 택1 — v1은 매물 탭의 "매칭만" 토글로 통일, `/matches`는 보조.
|
||||
@@ -0,0 +1,98 @@
|
||||
# InstaCards.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 4)
|
||||
|
||||
- **작성일**: 2026-07-10
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **범위**: FE 대형 컴포넌트 분해 sweep의 **4번째 서브프로젝트**. 컨벤션은 sub-project 1~3에서 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 이번은 적용만.
|
||||
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
`src/pages/insta/InstaCards.jsx`(1013줄, `/insta` 뉴스 키워드 → AI 카드 자동 생성 페이지)를 기능 모듈 구조로 동작 보존 분해한다. 단일 파일에 **메인 shell + 11개 표현/패널 컴포넌트 + 1개 훅(`usePollTask`) + 헬퍼/상수**가 몰려 있다. 각 컴포넌트는 대부분 로컬 상태 + API를 직접 보유하는 자족적 패널이라 그대로 `components/`로 lift 한다.
|
||||
|
||||
비목표: 동작/스타일 변경, 신규 기능, 컴포넌트 내부 재분해(SlateDetail/KeywordsPanel 등), `handleCreateSlate`의 훅 추출.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현 구조 맵 (InstaCards.jsx 1013줄)
|
||||
|
||||
- 헬퍼/상수: `fmtDate`(26), `CATEGORY_COLORS`(170), `CATEGORIES`(524), `KEYWORDS_PER_PAGE`(525), `PROMPT_NAMES`(941)
|
||||
- 소형 표현: `StatusBadge`(36–43), `TaskStatusBox`(84–99)
|
||||
- 훅: `usePollTask`(45–82) — `{ taskId, task, start(taskId), clear() }` 반환, TriggerPanel이 사용
|
||||
- 패널: `AccountFocusPanel`(101–169), `ExternalTrendsPanel`(175–267), `PreferenceImpactPanel`(268–300), `TriggerPanel`(471–521), `KeywordsPanel`(527–637), `SlatesPanel`(638–732), `PagesStrip`(733–817), `SlateDetail`(818–940), `PromptTemplatesEditor`(943–1013)
|
||||
- 메인 `InstaCards()` shell(301–468, ~168줄): status 상태 + 탭(cards/trends, URL 동기화 `switchTab`) + `slateProgress` 배너 + `handleCreateSlate`(createInstaSlate → getInstaTask 인라인 폴링 최대 8분 → 완료 시 cards 탭 전환 + 슬레이트 선택) + 2탭 레이아웃 조합
|
||||
|
||||
**컴포넌트 중첩**: `SlatesPanel` → `SlateDetail` → `PagesStrip`. `TriggerPanel` → `usePollTask` + `TaskStatusBox`.
|
||||
|
||||
의존: `PullToRefresh`(src/components), `../../api` 헬퍼(getInstaStatus/createInstaSlate/getInstaTask/getInstaKeywords/getInstaSlates/getInstaSlate/renderInstaSlate/deleteInstaSlate/getInstaAssetUrl/instaPackageUrl/getInstaPrompt/putInstaPrompt/getInstaTrends/instaCollectTrends/getInstaPreferences/putInstaPreferences/instaCollectNews/instaExtractKeywords).
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표 file map
|
||||
|
||||
```
|
||||
src/pages/insta/
|
||||
├── InstaCards.jsx # shell(~140줄): 탭바 + 상태헤더 + progress배너 + handleCreateSlate + 2탭 레이아웃 조합
|
||||
├── InstaCards.css # 변경 없음 (shell에서 import 유지)
|
||||
├── instaUtils.js # fmtDate(순수) — 다중 컴포넌트 공유 헬퍼. (CATEGORY_COLORS는 단일 사용이라 ExternalTrendsPanel에 co-locate)
|
||||
├── instaUtils.test.js # fmtDate 유닛 (신규)
|
||||
├── hooks/
|
||||
│ └── usePollTask.js # 태스크 폴링 훅 { taskId, task, start, clear }
|
||||
└── components/
|
||||
├── StatusBadge.jsx # ({ status })
|
||||
├── TaskStatusBox.jsx # ({ task })
|
||||
├── TriggerPanel.jsx # () — usePollTask×2 + TaskStatusBox
|
||||
├── KeywordsPanel.jsx # ({ onCreateSlate }) — CATEGORIES/KEYWORDS_PER_PAGE co-locate
|
||||
├── SlatesPanel.jsx # ({ selectedId, onSelect }) — SlateDetail 렌더
|
||||
├── SlateDetail.jsx # ({ slate, onDelete, onRender }) — PagesStrip 렌더
|
||||
├── PagesStrip.jsx # ({ slateId, pageCount })
|
||||
├── AccountFocusPanel.jsx # ()
|
||||
├── ExternalTrendsPanel.jsx# ({ onCreateSlate })
|
||||
├── PreferenceImpactPanel.jsx # ()
|
||||
└── PromptTemplatesEditor.jsx # () — PROMPT_NAMES co-locate
|
||||
```
|
||||
|
||||
**import 경로 규칙:**
|
||||
- `components/*.jsx`·`hooks/*.js` → api `'../../../api'`; utils `'../instaUtils'`; 형제 컴포넌트/훅은 `'./TaskStatusBox'`·`'../hooks/usePollTask'` 등 상대 경로.
|
||||
- `shell(InstaCards.jsx)` → 하위 `'./components/*'`, 훅 `'./hooks/usePollTask'`, utils `'./instaUtils'`, CSS `'./InstaCards.css'`, `PullToRefresh` `'../../components/PullToRefresh'`(기존 유지).
|
||||
- 각 컴포넌트가 실제 사용하는 심볼만 import(빌드/lint가 누락·미사용 검출).
|
||||
|
||||
**상수 배치:** `fmtDate` + `CATEGORY_COLORS`(다중 컴포넌트 사용 가능성 → 공유) = instaUtils. `CATEGORIES`/`KEYWORDS_PER_PAGE`(KeywordsPanel 단독) → KeywordsPanel.jsx co-locate. `PROMPT_NAMES`(PromptTemplatesEditor 단독) → PromptTemplatesEditor.jsx co-locate. (구현 시 실제 사용처가 단일이면 co-locate, 2+면 instaUtils로 승격.)
|
||||
|
||||
---
|
||||
|
||||
## 4. shell 책임 (분해 후)
|
||||
|
||||
현 `InstaCards()`의 조합/오케스트레이션만 남긴다(동작 보존):
|
||||
- 상태: `status`, `selectedSlateId`, `slateProgress`, `activeTab`.
|
||||
- `switchTab`(URL 동기화), `loadStatus`(getInstaStatus), `useEffect` 로드.
|
||||
- `handleCreateSlate`(createInstaSlate → getInstaTask 인라인 폴링 → cards 탭 전환 + 슬레이트 선택 + progress 배너) — **shell 유지**, `onCreateSlate` prop으로 KeywordsPanel·ExternalTrendsPanel에 전달.
|
||||
- 렌더: 탭바 + progress 배너 + Cards 탭(PullToRefresh > 헤더 + TriggerPanel + KeywordsPanel + SlatesPanel + PromptTemplatesEditor) + Trends 탭(AccountFocusPanel + ExternalTrendsPanel + PreferenceImpactPanel).
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 (안전망 — 현재 InstaCards 테스트 0)
|
||||
|
||||
1. **`instaUtils.test.js`(신규, 필수)** — `fmtDate`(현 구현: falsy → `''`; 유효 ISO → `new Date(iso).toLocaleDateString('ko-KR', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})`): falsy 3케이스(`''`/`null`/`undefined`) → `''` 고정; 유효 ISO → **비어있지 않은 문자열**(타임존/로케일 의존이라 정확 포맷 대신 non-empty smoke). 회귀 안전망.
|
||||
2. 나머지 표현/패널 컴포넌트는 로컬 상태+API 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동). StatusBadge 스모크(선택) 가능.
|
||||
3. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러. 최종 개발서버 `/insta`: 2탭(Cards/Trends) 전환·URL 동기화, 트리거(뉴스 수집·키워드 추출), 키워드 목록·페이지네이션, 슬레이트 생성(progress 배너 → 완료 자동 전환)·목록·상세(SlateDetail)·페이지 스트립(PagesStrip)·삭제·렌더, 프롬프트 편집, Trends 3패널이 리팩토링 전과 동일 동작.
|
||||
|
||||
---
|
||||
|
||||
## 6. 완료 기준
|
||||
|
||||
- [ ] InstaCards.jsx가 shell(~140줄) + hooks/usePollTask + instaUtils(+test) + components/11로 분해, 동작/스타일 불변.
|
||||
- [ ] `instaUtils.test.js` 통과, 전체 `npm run test:run` green.
|
||||
- [ ] `npm run lint`·`npm run build` 통과.
|
||||
- [ ] `/insta` 수동 검증 동일 동작(2탭·트리거·키워드·슬레이트 생성/상세/렌더/삭제·프롬프트·Trends).
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 / 오픈 이슈
|
||||
|
||||
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/수동으로 위험 최소화.
|
||||
- **컴포넌트 중첩 wiring**: SlatesPanel→SlateDetail→PagesStrip, TriggerPanel→usePollTask/TaskStatusBox 상대 import 경로 정확성(빌드가 검출).
|
||||
- **상수 배치 판정**: 실제 사용처 수에 따라 co-locate vs instaUtils 승격(구현 시 grep 확인).
|
||||
- **후속 follow-up**: SlateDetail(~122줄)·KeywordsPanel(~110줄) 내부 분할 여지 기록.
|
||||
- **다음 서브프로젝트**: MusicStudio(1936, 최대·마지막).
|
||||
@@ -0,0 +1,95 @@
|
||||
# PortfolioTab.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 3)
|
||||
|
||||
- **작성일**: 2026-07-10
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **범위**: FE 대형 컴포넌트 분해 sweep의 **3번째 서브프로젝트**. 컨벤션은 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 적용만.
|
||||
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
`src/pages/stock/components/PortfolioTab.jsx`(671줄)는 StockTrade의 포트폴리오 탭 컴포넌트로, **로컬 상태 없이 `pf`/`asset` 훅을 props로 받는 단일 대형 표현 컴포넌트**다. 논리 블록(추가폼/총자산요약/자산추이차트/예수금패널/브로커섹션/보유행/NXT뱃지)을 `stock/components/portfolio/` 하위 표현 컴포넌트로 동작 보존 분해한다.
|
||||
|
||||
비목표: 동작/스타일 변경, prop shape 정제(개별 prop으로 좁히기는 후속), HoldingRow 내부 상태 추가분할.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현 구조 맵 (PortfolioTab.jsx 671줄)
|
||||
|
||||
- `formatPriceTime`(순수, 9-14), `PriceSessionBadge`(16-27) — NXT 뱃지
|
||||
- `PortfolioTab({ pf, asset, handleSell, handleSaveSnapshot })`:
|
||||
- 에러(31-33)
|
||||
- 관리 section(36-282): 헤더(refresh/추가 토글) + **추가 폼**(66-153) + **총자산 요약 카드**(155-196) + **자산 추이 차트**(198-281)
|
||||
- **예수금 패널**(285-409): 예수금 테이블(inline edit/delete) + 예수금 추가 폼
|
||||
- **브로커 섹션 반복**(412-659): `pf.brokerGroups.map` → 브로커별 section + `items.map` → **보유 행**(447-654, view/edit/매도확인/삭제확인 4상태)
|
||||
- 빈 상태(661-667)
|
||||
|
||||
의존: `Loading`(src/components), recharts(AreaChart 등), `stockUtils`(formatNumber/formatPercent/toNumeric/profitColorClass/numFitClass).
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표 file map
|
||||
|
||||
```
|
||||
src/pages/stock/components/
|
||||
├── PortfolioTab.jsx # shell (~70줄): 에러 + 관리 section(헤더 inline + AddHoldingForm + PortfolioSummary + AssetHistoryChart) + CashPanel + brokerGroups.map(BrokerSection) + 빈상태
|
||||
└── portfolio/
|
||||
├── portfolioUtils.js # formatPriceTime (순수)
|
||||
├── portfolioUtils.test.js # (신규)
|
||||
├── PriceSessionBadge.jsx # ({ session, asOf })
|
||||
├── AddHoldingForm.jsx # ({ pf })
|
||||
├── PortfolioSummary.jsx # ({ pf })
|
||||
├── AssetHistoryChart.jsx # ({ asset, pf, handleSaveSnapshot })
|
||||
├── CashPanel.jsx # ({ pf })
|
||||
├── BrokerSection.jsx # ({ broker, items, pf, handleSell }) — HoldingRow 반복
|
||||
└── HoldingRow.jsx # ({ item, pf, handleSell })
|
||||
```
|
||||
|
||||
**props 스레딩(behavior-preserving):** 각 하위에 `pf`(훅 객체 전체)/`asset`/핸들러를 그대로 전달. 컴포넌트 본문은 현 `pf.xxx`/`asset.xxx` 참조를 그대로 사용 → prop shape·동작 불변.
|
||||
|
||||
**import 경로 규칙:**
|
||||
- `portfolio/*.jsx` → stockUtils `'../../stockUtils'`; Loading `'../../../../components/Loading'`; portfolioUtils `'./portfolioUtils'`.
|
||||
- `HoldingRow.jsx` → `PriceSessionBadge` `'./PriceSessionBadge'`.
|
||||
- `PortfolioTab.jsx`(stock/components/) → 하위 `'./portfolio/*'`. (기존 Loading `'../../../components/Loading'`, stockUtils `'../stockUtils'`는 shell이 계속 쓰는 것만 유지.)
|
||||
|
||||
각 컴포넌트가 실제 사용하는 심볼만 import(빌드/lint가 누락·미사용 검출).
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트별 책임 (분해 후 검증 관점)
|
||||
|
||||
- `PriceSessionBadge`(~20줄): session이 NXT_AFTER/NXT_PRE일 때만 뱃지. `formatPriceTime`(utils) 사용.
|
||||
- `AddHoldingForm`(~90줄): `pf.addFormOpen`일 때 폼. `pf.addForm`/`pf.setAddForm`/`pf.handleAddSubmit`/`pf.addLoading`/`pf.addError`.
|
||||
- `PortfolioSummary`(~45줄): `pf.portfolioHoldings.length>0`일 때 총매입/평가/손익/수익률 + 예수금/총자산 카드.
|
||||
- `AssetHistoryChart`(~85줄): `asset.assetHistory*` + `asset.setAssetHistoryDays` + `handleSaveSnapshot` + `pf.totalAssets`. recharts AreaChart.
|
||||
- `CashPanel`(~120줄): `pf.cashList` 테이블(inline edit: `pf.cashEditing*`/`pf.handleCashInline*`, delete: `pf.handleCashDelete`) + 예수금 추가 폼(`pf.cashForm`/`pf.handleCashSave`/…).
|
||||
- `BrokerSection`(~55줄): `pf.getBrokerSummary`/`pf.brokerColors`/`pf.cashList` + `items.map(HoldingRow)`.
|
||||
- `HoldingRow`(~200줄): 개별 종목. view/edit(`pf.editingId`/`pf.editForm`/`pf.handleEditSave`…)/매도확인(`pf.sellConfirmId`/`handleSell`/`pf.sellLoading`)/삭제확인(`pf.deleteConfirmId`/`pf.handleDelete`). `PriceSessionBadge` 사용.
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 (안전망 — 현재 PortfolioTab 테스트 0)
|
||||
|
||||
1. **`portfolioUtils.test.js`(신규, 필수)** — `formatPriceTime`: 유효 ISO → "HH:MM"(제로패드), falsy/invalid → "". (현 구현 동작 고정.)
|
||||
2. **`PriceSessionBadge.test.jsx`(신규, 스모크)** — session='NXT_AFTER'→"NXT" 렌더, 'NXT_PRE'→"NXT 프리" 렌더, 그 외(예: undefined/'REGULAR')→렌더 없음(null).
|
||||
3. 나머지 표현 컴포넌트는 props 스레딩 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동).
|
||||
4. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러. 최종 개발서버 `/stock/trade` 포트폴리오 탭에서 추가/요약/차트/예수금(인라인 편집·삭제)/브로커/보유행(편집·매도확인·삭제확인)이 리팩토링 전과 동일 동작.
|
||||
|
||||
---
|
||||
|
||||
## 6. 완료 기준
|
||||
|
||||
- [ ] PortfolioTab.jsx가 shell(~70줄) + portfolio/ 7컴포넌트 + portfolioUtils(+test)로 분해, 동작/스타일 불변.
|
||||
- [ ] `portfolioUtils.test.js`·`PriceSessionBadge.test.jsx` 통과, 전체 `npm run test:run` green.
|
||||
- [ ] `npm run lint`·`npm run build` 통과.
|
||||
- [ ] `/stock/trade` 포트폴리오 탭 수동 검증 동일 동작.
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 / 오픈 이슈
|
||||
|
||||
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/스모크/수동으로 위험 최소화.
|
||||
- **prop drilling**: `pf` 전체 전달은 behavior-preserving 우선(정제는 후속). 각 컴포넌트가 쓰는 `pf.*`만 실제 참조.
|
||||
- **HoldingRow ~200줄**: 단일 책임(보유 1행). 내부 edit/매도/삭제 분할은 후속 follow-up.
|
||||
- **다음 서브프로젝트**: InstaCards(1013) → MusicStudio(1936).
|
||||
@@ -5,6 +5,13 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>가후습 개인기록</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Nanum+Myeongjo:wght@400;700;800&family=Nanum+Gothic:wght@400;700;800&family=Gowun+Batang:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
public/images/saju/horyung/background.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/saju/horyung/horyung-bust.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
public/images/saju/horyung/horyung-front.png
Normal file
|
After Width: | Height: | Size: 554 KiB |
BIN
public/images/saju/horyung/horyung-greeting.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/saju/horyung/horyung-happy.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/images/saju/horyung/horyung-head.png
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
public/images/saju/horyung/horyung-main.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/saju/horyung/horyung-pointing.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/images/saju/horyung/horyung-thinking.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/images/saju/horyung/horyung-upper.png
Normal file
|
After Width: | Height: | Size: 929 KiB |
86
scripts/extract_horyung.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""호령 캐릭터 PNG 추출.
|
||||
|
||||
source/characters/horyung.png (3 view layout: bust / back / front)
|
||||
source/images/saju_page/saju_color_sheet.png (5 emotion stickers in bottom row)
|
||||
에서 호령 PNG 6개를 추출하여 public/images/saju/horyung/에 저장.
|
||||
"""
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
SOURCE_ROOT = "../source"
|
||||
HORYUNG_PATH = f"{SOURCE_ROOT}/characters/horyung.png"
|
||||
COLORSHEET_PATH = f"{SOURCE_ROOT}/images/saju_page/saju_color_sheet.png"
|
||||
|
||||
OUT_DIR = "public/images/saju/horyung"
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def crop_save(src_path, box, out_name):
|
||||
im = Image.open(src_path).convert("RGBA")
|
||||
cropped = im.crop(box)
|
||||
cropped.save(f"{OUT_DIR}/{out_name}")
|
||||
print(f"saved {out_name} ({cropped.size})")
|
||||
|
||||
|
||||
# ---- horyung.png: 1055 x 1491 (3 vertical views) ----
|
||||
# 1. BUST SHOT (top): character is slightly right of center.
|
||||
# 2. BACK VIEW (middle): not used.
|
||||
# 3. FRONT VIEW (bottom): character with sword, slightly right of center.
|
||||
src_horyung = Image.open(HORYUNG_PATH)
|
||||
HW, HH = src_horyung.size
|
||||
print(f"horyung dims: {HW}x{HH}")
|
||||
|
||||
# bust shot — top 1/3, character occupies horizontal middle-right area
|
||||
crop_save(
|
||||
HORYUNG_PATH,
|
||||
(int(HW * 0.22), int(HH * 0.01), int(HW * 0.78), int(HH * 0.33)),
|
||||
"horyung-bust.png",
|
||||
)
|
||||
|
||||
# front view — bottom 1/3. Measured: top of hat at y≈0.66, character spans x≈0.18~0.82
|
||||
crop_save(
|
||||
HORYUNG_PATH,
|
||||
(int(HW * 0.16), int(HH * 0.66), int(HW * 0.82), int(HH * 1.0)),
|
||||
"horyung-front.png",
|
||||
)
|
||||
|
||||
# ---- saju_color_sheet.png: 1536 x 1024 ----
|
||||
# Bottom row contains 6 emotion stickers laid horizontally.
|
||||
# Visual inspection: stickers occupy roughly x=0.439~0.986, y=0.82~1.0.
|
||||
# We need 4 stickers (greeting, thinking, pointing, happy) from positions 0~3.
|
||||
src_sheet = Image.open(COLORSHEET_PATH)
|
||||
SW, SH = src_sheet.size
|
||||
print(f"colorsheet dims: {SW}x{SH}")
|
||||
|
||||
# 6 stickers in a single row at bottom-right
|
||||
# Measured visually with red grid overlay:
|
||||
# Sticker 1 (greeting): x = 600..750
|
||||
# Sticker 2 (thinking): x = 750..900
|
||||
# Sticker 3 (pointing): x = 900..1050
|
||||
# Sticker 4 (happy): x = 1050..1200
|
||||
# Sticker 5 (caution): x = 1200..1350 (unused)
|
||||
# Sticker 6 (lucky): x = 1350..1500 (unused)
|
||||
EMO_X_START = 600
|
||||
EMO_X_END = 1500
|
||||
EMO_Y_START = int(SH * 0.79)
|
||||
EMO_Y_END = int(SH * 1.00)
|
||||
EMO_COLS = 6
|
||||
EMO_W = (EMO_X_END - EMO_X_START) / EMO_COLS
|
||||
EMO_H = EMO_Y_END - EMO_Y_START
|
||||
|
||||
# left-to-right: greeting, thinking, pointing, happy, (5th: unused)
|
||||
positions = [
|
||||
("horyung-greeting.png", 0),
|
||||
("horyung-thinking.png", 1),
|
||||
("horyung-pointing.png", 2),
|
||||
("horyung-happy.png", 3),
|
||||
]
|
||||
|
||||
for name, col in positions:
|
||||
x1 = int(EMO_X_START + col * EMO_W)
|
||||
y1 = EMO_Y_START
|
||||
x2 = int(EMO_X_START + (col + 1) * EMO_W)
|
||||
y2 = EMO_Y_END
|
||||
crop_save(COLORSHEET_PATH, (x1, y1, x2, y2), name)
|
||||
|
||||
print("Done.")
|
||||
26
src/App.css
@@ -25,6 +25,16 @@
|
||||
margin-left: var(--sidebar-w);
|
||||
}
|
||||
|
||||
.app-shell--immersive {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #F7F2E8;
|
||||
}
|
||||
|
||||
.app-content--immersive {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */
|
||||
|
||||
.app-topbar {
|
||||
@@ -59,6 +69,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-main--immersive {
|
||||
padding: 0;
|
||||
background: #F7F2E8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-main {
|
||||
padding: 16px;
|
||||
@@ -491,6 +506,17 @@
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.app-shell--immersive {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.site-main--immersive {
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
|
||||
|
||||
16
src/App.jsx
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import PageHeader from './components/PageHeader';
|
||||
@@ -9,19 +9,21 @@ import './App.css';
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile();
|
||||
const { pathname } = useLocation();
|
||||
const isImmersiveRoute = pathname.startsWith('/saju');
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Navbar />
|
||||
<div className="app-content">
|
||||
<main className="site-main">
|
||||
<PageHeader />
|
||||
<div className={`app-shell${isImmersiveRoute ? ' app-shell--immersive' : ''}`}>
|
||||
{!isImmersiveRoute && <Navbar />}
|
||||
<div className={`app-content${isImmersiveRoute ? ' app-content--immersive' : ''}`}>
|
||||
<main className={`site-main${isImmersiveRoute ? ' site-main--immersive' : ''}`}>
|
||||
{!isImmersiveRoute && <PageHeader />}
|
||||
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||
<Outlet />
|
||||
</React.Suspense>
|
||||
</main>
|
||||
</div>
|
||||
{isMobile && <BottomNav />}
|
||||
{isMobile && !isImmersiveRoute && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
125
src/api.js
@@ -14,6 +14,11 @@ export async function apiGet(path) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 분산 워커 관측 — agent-office 집계 상태 (Part B 백엔드)
|
||||
export async function getNodeStatus() {
|
||||
return apiGet("/api/agent-office/nodes");
|
||||
}
|
||||
|
||||
export async function apiDelete(path) {
|
||||
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
@@ -548,6 +553,8 @@ export function getInstaAssetUrl(slateId, page) {
|
||||
return `/api/insta/slates/${slateId}/assets/${page}`;
|
||||
}
|
||||
|
||||
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
|
||||
|
||||
export function getInstaTask(taskId) {
|
||||
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
@@ -592,6 +599,17 @@ export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/age
|
||||
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (agent_id) p.set('agent_id', agent_id);
|
||||
if (type) p.set('type', type);
|
||||
if (status) p.set('status', status);
|
||||
if (days) p.set('days', String(days));
|
||||
p.set('limit', String(limit));
|
||||
p.set('offset', String(offset));
|
||||
return apiGet(`/api/agent-office/activity?${p.toString()}`);
|
||||
};
|
||||
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
||||
|
||||
// --- Lotto Briefing ---
|
||||
@@ -697,6 +715,10 @@ export const refreshScreenerSnap = () => apiPost('/api/stock/screener
|
||||
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||
|
||||
// ---- Stock Holdings Intelligence ----
|
||||
export const stockHoldingsIntel = () => apiGet('/api/stock/holdings/intel');
|
||||
export const stockHoldingsHistory = (ticker, days = 30) => apiGet(`/api/stock/holdings/intel/history?ticker=${ticker}&days=${days}`);
|
||||
|
||||
// --- Lotto Weight Evolver ---
|
||||
|
||||
export async function fetchEvolverStatus() {
|
||||
@@ -740,14 +762,19 @@ export async function triggerEvolverEvaluate() {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// --- Lotto Backtest ---
|
||||
export const lottoBacktestTrackRecord = () => apiGet('/api/lotto/backtest/track-record');
|
||||
export const lottoBacktestCalibration = (weeks=52) => apiGet(`/api/lotto/backtest/calibration?weeks=${weeks}`);
|
||||
export const lottoBacktestReview = (drawNo) => apiGet(`/api/lotto/backtest/review/${drawNo}`);
|
||||
|
||||
// --- Tarot Lab ---
|
||||
|
||||
export function tarotInterpret(body) {
|
||||
return apiPost('/api/agent-office/tarot/interpret', body);
|
||||
return apiPost('/api/tarot/interpret', body);
|
||||
}
|
||||
|
||||
export function tarotSaveReading(body) {
|
||||
return apiPost('/api/agent-office/tarot/readings', body);
|
||||
return apiPost('/api/tarot/readings', body);
|
||||
}
|
||||
|
||||
export function tarotListReadings({ page = 1, size = 20, favorite, spread_type, category } = {}) {
|
||||
@@ -755,17 +782,103 @@ export function tarotListReadings({ page = 1, size = 20, favorite, spread_type,
|
||||
if (favorite !== undefined) qs.set('favorite', favorite ? 'true' : 'false');
|
||||
if (spread_type) qs.set('spread_type', spread_type);
|
||||
if (category) qs.set('category', category);
|
||||
return apiGet(`/api/agent-office/tarot/readings?${qs.toString()}`);
|
||||
return apiGet(`/api/tarot/readings?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function tarotGetReading(id) {
|
||||
return apiGet(`/api/agent-office/tarot/readings/${id}`);
|
||||
return apiGet(`/api/tarot/readings/${id}`);
|
||||
}
|
||||
|
||||
export function tarotPatchReading(id, body) {
|
||||
return apiPatch(`/api/agent-office/tarot/readings/${id}`, body);
|
||||
return apiPatch(`/api/tarot/readings/${id}`, body);
|
||||
}
|
||||
|
||||
export function tarotDeleteReading(id) {
|
||||
return apiDelete(`/api/agent-office/tarot/readings/${id}`);
|
||||
return apiDelete(`/api/tarot/readings/${id}`);
|
||||
}
|
||||
|
||||
// ====== Saju ======
|
||||
|
||||
export function sajuInterpret(body) {
|
||||
return apiPost('/api/saju/interpret', body);
|
||||
}
|
||||
|
||||
export function sajuListReadings({ page = 1, size = 20, favorite } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('page', page);
|
||||
qs.set('size', size);
|
||||
if (favorite !== undefined) qs.set('favorite', favorite);
|
||||
return apiGet(`/api/saju/readings?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function sajuGetReading(id) {
|
||||
return apiGet(`/api/saju/readings/${id}`);
|
||||
}
|
||||
|
||||
export function sajuPatchReading(id, body) {
|
||||
return apiPatch(`/api/saju/readings/${id}`, body);
|
||||
}
|
||||
|
||||
export function sajuDeleteReading(id) {
|
||||
return apiDelete(`/api/saju/readings/${id}`);
|
||||
}
|
||||
|
||||
export function sajuCurrentFortune(readingId) {
|
||||
return apiGet(`/api/saju/current-fortune?reading_id=${readingId}`);
|
||||
}
|
||||
|
||||
// ====== Compatibility ======
|
||||
|
||||
export function compatInterpret(body) {
|
||||
return apiPost('/api/saju/compat/interpret', body);
|
||||
}
|
||||
|
||||
export function compatListReadings({ page = 1, size = 20, favorite } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('page', page);
|
||||
qs.set('size', size);
|
||||
if (favorite !== undefined) qs.set('favorite', favorite);
|
||||
return apiGet(`/api/saju/compat/readings?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function compatGetReading(id) {
|
||||
return apiGet(`/api/saju/compat/readings/${id}`);
|
||||
}
|
||||
|
||||
export function compatPatchReading(id, body) {
|
||||
return apiPatch(`/api/saju/compat/readings/${id}`, body);
|
||||
}
|
||||
|
||||
export function compatDeleteReading(id) {
|
||||
return apiDelete(`/api/saju/compat/readings/${id}`);
|
||||
}
|
||||
|
||||
// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ──
|
||||
// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] }
|
||||
// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true }
|
||||
// DELETE /api/stock/watchlist/{ticker} → 200/404
|
||||
// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
|
||||
// ── Realestate Listings / 안전마진 / 예산 (실매물) ──
|
||||
// GET /api/realestate/listings → { listings: [...] } (+판정 join)
|
||||
export const getListings = ({ dong, deal_type, tier, matched_only, page = 1, size = 20 } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (dong) q.set('dong', dong);
|
||||
if (deal_type) q.set('deal_type', deal_type);
|
||||
if (tier) q.set('tier', tier);
|
||||
if (matched_only) q.set('matched_only', 'true');
|
||||
q.set('page', String(page));
|
||||
q.set('size', String(size));
|
||||
return apiGet(`/api/realestate/listings?${q.toString()}`);
|
||||
};
|
||||
export const collectListings = () => apiPost('/api/realestate/listings/collect');
|
||||
export const getListingsCollectStatus = () => apiGet('/api/realestate/listings/collect/status');
|
||||
export const getListingsCriteria = () => apiGet('/api/realestate/listings/criteria');
|
||||
export const putListingsCriteria = (body) => apiPut('/api/realestate/listings/criteria', body);
|
||||
export const getListingsMatches = () => apiGet('/api/realestate/listings/matches');
|
||||
export const safetyCheck = (body) => apiPost('/api/realestate/safety-check', body);
|
||||
export const budgetCalc = (body) => apiPost('/api/realestate/budget', body);
|
||||
|
||||
@@ -143,3 +143,13 @@ export const IconTarot = () =>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const IconSaju = () =>
|
||||
svg(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a10 10 0 0 0 0 20 5 5 0 0 1 0-10 5 5 0 0 0 0-10z" fill="currentColor" />
|
||||
<circle cx="12" cy="7" r="1.5" fill="#fff" />
|
||||
<circle cx="12" cy="17" r="1.5" fill="currentColor" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -371,6 +371,18 @@
|
||||
.ao-log-level { min-width: 48px; font-weight: bold; }
|
||||
.ao-log-msg { color: #ccc; word-break: break-all; }
|
||||
|
||||
.ao-log-source {
|
||||
margin-left: 6px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ao-log-meta {
|
||||
color: #6b7280;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ===== Common ===== */
|
||||
.ao-empty {
|
||||
color: #94a3b8;
|
||||
@@ -435,3 +447,102 @@
|
||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 횡단 오버사이트 타임라인 (mission-control activity log) ── */
|
||||
.ao-activity { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||
|
||||
/* 헤더 — 섹션 타이틀 톤 (퍼플 액센트 + 트래킹) */
|
||||
.ao-activity-header { align-items: center; }
|
||||
.ao-activity-header .ao-sidepanel-name {
|
||||
color: #8b5cf6; letter-spacing: 0.6px; text-transform: uppercase; font-size: 13px;
|
||||
}
|
||||
|
||||
/* 필터 바 — 다크 슬레이트 셀렉트 */
|
||||
.ao-activity-filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #333;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
.ao-activity-select {
|
||||
background: #1e293b; color: #e2e8f0;
|
||||
border: 1px solid #334155; border-radius: 4px;
|
||||
padding: 4px 8px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.ao-activity-select:hover { border-color: #475569; }
|
||||
.ao-activity-select:focus { outline: none; border-color: #8b5cf6; box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3); }
|
||||
.ao-activity-select:disabled { opacity: .35; cursor: not-allowed; }
|
||||
|
||||
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; padding: 0; }
|
||||
|
||||
/* 활동 행 — 타임라인 스파인(수직 레일) + 신호등 도트 */
|
||||
.ao-activity-item {
|
||||
position: relative;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid #1a2233;
|
||||
cursor: pointer; transition: background .12s;
|
||||
animation: ao-activity-in .18s ease-out both;
|
||||
}
|
||||
.ao-activity-item::before {
|
||||
content: ''; position: absolute; left: 16px; top: 0; bottom: 0;
|
||||
width: 1px; background: #1e293b; z-index: 0;
|
||||
}
|
||||
.ao-activity-item:hover { background: #161b2e; }
|
||||
.ao-activity-item:focus-visible { outline: none; background: #161b2e; box-shadow: inset 2px 0 0 #8b5cf6; }
|
||||
|
||||
/* 진행/대기 강조 — 앰버 인셋 + 도트 펄스 */
|
||||
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, 0.06); box-shadow: inset 2px 0 0 #f59e0b; }
|
||||
.ao-activity-item.is-highlight .ao-activity-dot { animation: ao-pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* 에이전트 색 = 신호등. 링(#111)으로 뒤 레일을 끊어 점처럼 떠 보이게 */
|
||||
.ao-activity-dot {
|
||||
position: relative; z-index: 1; flex: 0 0 auto;
|
||||
width: 9px; height: 9px; border-radius: 50%; margin-top: 4px;
|
||||
box-shadow: 0 0 0 3px #111;
|
||||
}
|
||||
|
||||
.ao-activity-body { flex: 1; min-width: 0; }
|
||||
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||
.ao-activity-agent { font-size: 11px; font-weight: bold; letter-spacing: 0.3px; }
|
||||
|
||||
/* 상태 뱃지 — 터미널 톤(각진 모서리, 모노) */
|
||||
.ao-activity-badge {
|
||||
font-size: 10px; font-weight: bold; letter-spacing: 0.3px;
|
||||
padding: 1px 7px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 로그 레벨 표식 */
|
||||
.ao-activity-level { font-size: 12px; line-height: 1; }
|
||||
.ao-activity-level.level-info { color: #475569; font-size: 15px; font-weight: bold; }
|
||||
.ao-activity-level.level-warning,
|
||||
.ao-activity-level.level-error { font-size: 12px; }
|
||||
|
||||
.ao-activity-msg {
|
||||
font-size: 12.5px; color: #cbd5e1; margin-top: 3px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ao-activity-item.is-log .ao-activity-msg { color: #94a3b8; }
|
||||
|
||||
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||
.ao-activity-time { font-size: 10px; color: #64748b; }
|
||||
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||
|
||||
.ao-activity-loading,
|
||||
.ao-activity-end {
|
||||
text-align: center; padding: 12px; font-size: 10px;
|
||||
color: #475569; letter-spacing: 0.6px; text-transform: uppercase;
|
||||
}
|
||||
.ao-activity-sentinel { height: 1px; }
|
||||
|
||||
.ao-activity-error { padding: 12px; font-size: 12px; color: #fca5a5; }
|
||||
.ao-activity-error button {
|
||||
margin-left: 8px; background: #2a2a4e; color: #8b5cf6;
|
||||
border: 1px solid #4c1d95; border-radius: 4px;
|
||||
padding: 3px 10px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.ao-activity-error button:hover { background: #3a3a5e; }
|
||||
|
||||
@keyframes ao-activity-in {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import TopBar from './components/TopBar.jsx';
|
||||
import AgentGrid from './components/AgentGrid.jsx';
|
||||
import SidePanel from './components/SidePanel.jsx';
|
||||
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
||||
import ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||
import './AgentOffice.css';
|
||||
|
||||
export default function AgentOffice() {
|
||||
@@ -36,7 +37,12 @@ export default function AgentOffice() {
|
||||
|
||||
let rightPanel;
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = <EmptyDetailPanel variant="initial" />;
|
||||
rightPanel = (
|
||||
<ActivityTimeline
|
||||
refreshTrigger={refreshTrigger}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
);
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
|
||||
} else {
|
||||
|
||||
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/pages/agent-office/components/ActivityFilters.jsx
|
||||
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'succeeded', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'pending', label: '대기' },
|
||||
];
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1일' },
|
||||
{ value: 7, label: '7일' },
|
||||
{ value: 30, label: '30일' },
|
||||
];
|
||||
|
||||
export default function ActivityFilters({ filters, onChange }) {
|
||||
const set = (patch) => onChange({ ...filters, ...patch });
|
||||
const statusDisabled = filters.type === 'log';
|
||||
return (
|
||||
<div className="ao-activity-filters">
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="에이전트 필터"
|
||||
value={filters.agent_id || ''}
|
||||
onChange={e => set({ agent_id: e.target.value })}
|
||||
>
|
||||
<option value="">모든 에이전트</option>
|
||||
{ACTIVE_AGENT_IDS.map(id => (
|
||||
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="타입 필터"
|
||||
value={filters.type || ''}
|
||||
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="상태 필터"
|
||||
value={filters.status || ''}
|
||||
disabled={statusDisabled}
|
||||
onChange={e => set({ status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="기간 필터"
|
||||
value={filters.days}
|
||||
onChange={e => set({ days: Number(e.target.value) })}
|
||||
>
|
||||
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
|
||||
const base = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
describe('ActivityFilters', () => {
|
||||
it('type=log이면 상태 필터가 비활성화된다', () => {
|
||||
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||
});
|
||||
|
||||
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||
});
|
||||
});
|
||||
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// src/pages/agent-office/components/ActivityItem.jsx
|
||||
import { AGENT_META } from '../constants.js';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
|
||||
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
|
||||
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
|
||||
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
|
||||
};
|
||||
|
||||
const LEVEL_STYLE = {
|
||||
error: { icon: '❌', cls: 'level-error' },
|
||||
warning: { icon: '⚠️', cls: 'level-warning' },
|
||||
info: { icon: '·', cls: 'level-info' },
|
||||
};
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
export default function ActivityItem({ item, onSelectAgent }) {
|
||||
const meta = AGENT_META[item.agent_id];
|
||||
const color = meta?.color || '#6b7280';
|
||||
const name = meta?.displayName || item.agent_id;
|
||||
const isTask = item.type === 'task';
|
||||
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
|
||||
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
|
||||
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||
onClick={() => onSelectAgent(item.agent_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||
<div className="ao-activity-body">
|
||||
<div className="ao-activity-line">
|
||||
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||
{isTask
|
||||
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||
</div>
|
||||
<div className="ao-activity-msg">{item.message}</div>
|
||||
</div>
|
||||
<div className="ao-activity-meta">
|
||||
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||
{isTask && item.duration_seconds != null && (
|
||||
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
describe('ActivityItem', () => {
|
||||
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||
});
|
||||
|
||||
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '120px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||
|
||||
// jsdom IntersectionObserver stub
|
||||
beforeEach(() => {
|
||||
global.IntersectionObserver = class {
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
describe('ActivityTimeline', () => {
|
||||
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
const row = await screen.findByText('signal_check');
|
||||
fireEvent.click(row.closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||
});
|
||||
});
|
||||
@@ -5,24 +5,42 @@ import { getAgentLogs } from '../../../api';
|
||||
const LEVEL_STYLE = {
|
||||
info: { color: '#60a5fa' },
|
||||
warning: { color: '#fbbf24' },
|
||||
error: { color: '#ef4444' }
|
||||
error: { color: '#ef4444' },
|
||||
};
|
||||
|
||||
const SOURCE_STYLE = {
|
||||
agent: { color: '#9ca3af', label: 'AGENT' },
|
||||
access: { color: '#5eead4', label: 'ACCESS' },
|
||||
log: { color: '#a78bfa', label: 'LOG' },
|
||||
};
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function LogTab({ agentId, refreshTrigger }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentLogs(agentId, 50).then(data => {
|
||||
if (!cancelled) setLogs(Array.isArray(data) ? data : (data?.logs || []));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
const fetchLogs = () => {
|
||||
getAgentLogs(agentId, 100).then(data => {
|
||||
if (cancelled) return;
|
||||
setLogs(Array.isArray(data) ? data : (data?.logs || []));
|
||||
}).catch(() => {});
|
||||
};
|
||||
fetchLogs();
|
||||
const interval = setInterval(fetchLogs, 5000); // 5초 폴링
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
@@ -30,13 +48,23 @@ export default function LogTab({ agentId, refreshTrigger }) {
|
||||
<div className="ao-log-tab" ref={scrollRef}>
|
||||
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
|
||||
{logs.map((log, i) => {
|
||||
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const source = log.source || 'agent';
|
||||
const sourceMeta = SOURCE_STYLE[source] || SOURCE_STYLE.agent;
|
||||
const levelStyle = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||
const time = formatTime(log.ts || log.created_at);
|
||||
return (
|
||||
<div key={log.id || i} className="ao-log-item">
|
||||
<div key={log.id || `${source}-${i}-${time}`} className="ao-log-item">
|
||||
<span className="ao-log-time">{time}</span>
|
||||
<span className="ao-log-level" style={style}>[{log.level}]</span>
|
||||
<span className="ao-log-source" style={{ color: sourceMeta.color }}>
|
||||
[{sourceMeta.label}]
|
||||
</span>
|
||||
<span className="ao-log-level" style={levelStyle}>[{log.level}]</span>
|
||||
<span className="ao-log-msg">{log.message}</span>
|
||||
{source === 'access' && (
|
||||
<span className="ao-log-meta">
|
||||
{' '}({log.status} · {log.ms}ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { agentActivity } from '../../../api';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const offsetRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const filterKey = JSON.stringify(filters);
|
||||
|
||||
const fetchPage = useCallback(async (offset, replace) => {
|
||||
// append(loadMore)만 중복 방지. replace(필터/refresh 재조회)는 항상 우선 진행.
|
||||
if (!replace && loadingRef.current) return;
|
||||
const reqId = ++requestIdRef.current;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||
if (reqId !== requestIdRef.current) return; // 더 새로운 요청이 시작됨 → stale 응답 무시
|
||||
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||
setTotal(data?.total || 0);
|
||||
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||
offsetRef.current = offset + newItems.length;
|
||||
} catch (e) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setError(e.message || '불러오기 실패');
|
||||
} finally {
|
||||
if (reqId === requestIdRef.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [filterKey, refreshTrigger, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingRef.current) return;
|
||||
if (offsetRef.current >= total) return;
|
||||
fetchPage(offsetRef.current, false);
|
||||
}, [fetchPage, total]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [fetchPage]);
|
||||
|
||||
const hasMore = items.length < total;
|
||||
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||
}
|
||||
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useActivityFeed } from './useActivityFeed.js';
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => mockAgentActivity.mockReset());
|
||||
|
||||
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||
|
||||
describe('useActivityFeed', () => {
|
||||
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
|
||||
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { result.current.loadMore(); });
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||
});
|
||||
|
||||
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||
rerender({ rt: 1 });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('hasMore는 items.length < total', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('필터 변경 중이던 이전(stale) 요청 응답은 무시된다', async () => {
|
||||
let resolveFirst;
|
||||
const firstPromise = new Promise(r => { resolveFirst = r; });
|
||||
mockAgentActivity
|
||||
.mockReturnValueOnce(firstPromise) // 초기 요청 — 느리게 resolve
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'fresh', agent_id: 'insta' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } }); // 첫 요청 resolve 전에 필터 변경
|
||||
await waitFor(() => expect(result.current.items[0]?.task_id).toBe('fresh'));
|
||||
await act(async () => { resolveFirst({ items: [item({ task_id: 'stale' })], total: 99 }); });
|
||||
expect(result.current.items[0].task_id).toBe('fresh'); // stale이 덮어쓰지 않음
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
});
|
||||
359
src/pages/infra/InfraMonitor.css
Normal file
@@ -0,0 +1,359 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
InfraMonitor — NAS↔Windows 워커 파이프라인 관측 콘솔
|
||||
다크 미션컨트롤 / 텔레메트리 미학 (index.css 토큰 재사용)
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.infra {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 상태 바 ───────────────────────────────────────────────────────── */
|
||||
.infra-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.infra-bar__stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.infra-chip {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.infra-chip b {
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
}
|
||||
.infra-chip.is-ok {
|
||||
color: #00d4ff;
|
||||
border-color: rgba(0, 212, 255, 0.35);
|
||||
box-shadow: 0 0 16px rgba(0, 212, 255, 0.12) inset;
|
||||
}
|
||||
.infra-chip.is-warn {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
.infra-chip.is-danger {
|
||||
color: #fb923c;
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
.infra-chip.is-down {
|
||||
color: #f43f5e;
|
||||
border-color: rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 16px rgba(244, 63, 94, 0.1) inset;
|
||||
}
|
||||
|
||||
.infra-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.infra-updated {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.infra-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.infra-toggle button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
.infra-toggle button.is-active {
|
||||
background: var(--neon-cyan-muted);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
.infra-refresh {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
.infra-refresh:hover {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--line-bright);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ── 에러 / 경고 / 로딩 ────────────────────────────────────────────── */
|
||||
.infra-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
}
|
||||
.infra-error b {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.infra-error span {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
.infra-error button {
|
||||
background: rgba(244, 63, 94, 0.18);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: #ffd2da;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.infra-warn-banner {
|
||||
padding: 12px 18px;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: #ffb3bf;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.infra-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── 3D 스테이지 ───────────────────────────────────────────────────── */
|
||||
.infra-stage {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 90% 60% at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 80% 60% at 85% 100%, rgba(139, 92, 246, 0.07) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #060a16 0%, #04060f 100%);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.infra-stage::before {
|
||||
/* 미세 그리드 텍스처 */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(0, 212, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 100% 80% at 50% 50%, #000 40%, transparent 90%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pipeline-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 58vh;
|
||||
min-height: 440px;
|
||||
}
|
||||
.pipeline-labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pipeline-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
background: rgba(6, 10, 22, 0.78);
|
||||
border: 1px solid color-mix(in srgb, var(--pl-color, #00d4ff) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.pipeline-label .pl-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--pl-color, #00d4ff);
|
||||
box-shadow: 0 0 8px var(--pl-color, #00d4ff);
|
||||
}
|
||||
.pipeline-label .pl-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.pipeline-label .pl-state {
|
||||
font-size: 10.5px;
|
||||
color: var(--pl-color, #8892b0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pipeline-label--anchor .pl-name {
|
||||
color: var(--pl-color, #e8f0fe);
|
||||
}
|
||||
|
||||
.infra-legend {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 12px;
|
||||
background: rgba(6, 10, 22, 0.6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.infra-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.infra-legend i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── 워커 카드 그리드 ──────────────────────────────────────────────── */
|
||||
.infra-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.infra-grid--compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
}
|
||||
.infra-card {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--c, #4a5572);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s var(--ease-out), border-color 0.2s;
|
||||
}
|
||||
.infra-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--c) 40%, var(--line));
|
||||
}
|
||||
.infra-card--down {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.infra-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.infra-card__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--c);
|
||||
box-shadow: 0 0 10px var(--c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.infra-card__id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.infra-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.infra-card__kind {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.infra-card__state {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--c);
|
||||
padding: 3px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--c) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.infra-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.infra-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 7px 4px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.infra-metric__v {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.infra-metric__l {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.infra-metric--warn .infra-metric__v {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.infra-metric--danger .infra-metric__v {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.infra-card__foot {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pipeline-canvas {
|
||||
height: 46vh;
|
||||
min-height: 340px;
|
||||
}
|
||||
.infra-bar {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
141
src/pages/infra/InfraMonitor.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// src/pages/infra/InfraMonitor.jsx
|
||||
// /infra — NAS↔Windows 분산 워커 파이프라인 실시간 관측.
|
||||
// 3D 파이프라인(Three.js) + 2D 워커 카드. WebGL 미지원 시 카드만.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNodeStatus } from './useNodeStatus';
|
||||
import PipelineScene from './PipelineScene';
|
||||
import { workerStateLabel, workerColor, workerTitle, kindLabel } from './statusVisual';
|
||||
import './InfraMonitor.css';
|
||||
|
||||
function hasWebGL() {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
return !!(window.WebGLRenderingContext && (c.getContext('webgl') || c.getContext('experimental-webgl')));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }) {
|
||||
return (
|
||||
<div className={`infra-metric${tone ? ` infra-metric--${tone}` : ''}`}>
|
||||
<span className="infra-metric__v">{value ?? 0}</span>
|
||||
<span className="infra-metric__l">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerCard({ w }) {
|
||||
const color = workerColor(w);
|
||||
return (
|
||||
<div className={`infra-card${w.alive ? '' : ' infra-card--down'}`} style={{ '--c': color }}>
|
||||
<div className="infra-card__head">
|
||||
<span className="infra-card__dot" />
|
||||
<div className="infra-card__id">
|
||||
<div className="infra-card__title">{workerTitle(w.name)}</div>
|
||||
<div className="infra-card__kind">{kindLabel(w.kind)}</div>
|
||||
</div>
|
||||
<span className="infra-card__state">{workerStateLabel(w)}</span>
|
||||
</div>
|
||||
<div className="infra-card__metrics">
|
||||
<Metric label="큐" value={w.queue_depth} tone={w.queue_depth > 0 ? 'warn' : null} />
|
||||
<Metric label="실패" value={w.dead_letter} tone={w.dead_letter > 0 ? 'danger' : null} />
|
||||
<Metric label="처리중" value={w.processing} />
|
||||
<Metric label="완료" value={w.jobs_done} />
|
||||
</div>
|
||||
<div className="infra-card__foot">
|
||||
{w.alive
|
||||
? `last beat ${w.last_beat_age_s ?? '?'}s 전`
|
||||
: '비콘 없음 (오프라인)'}
|
||||
{w.jobs_failed > 0 ? ` · 누적 실패 ${w.jobs_failed}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InfraMonitor() {
|
||||
const { data, error, loading, updatedAt, refresh } = useNodeStatus(3000);
|
||||
const webgl = useMemo(() => hasWebGL(), []);
|
||||
const [view, setView] = useState(webgl ? '3d' : 'grid');
|
||||
|
||||
const workers = data?.workers || [];
|
||||
const online = workers.filter((w) => w.alive).length;
|
||||
const total = workers.length;
|
||||
const deadLetters = workers.reduce((a, w) => a + (w.dead_letter || 0), 0);
|
||||
const redisOk = data ? data.redis_ok : null;
|
||||
|
||||
return (
|
||||
<div className="infra">
|
||||
<div className="infra-bar">
|
||||
<div className="infra-bar__stats">
|
||||
<span className={`infra-chip ${online === total && total > 0 ? 'is-ok' : online > 0 ? 'is-warn' : 'is-down'}`}>
|
||||
<b>{online}</b>/{total || '–'} 온라인
|
||||
</span>
|
||||
<span className={`infra-chip ${redisOk === false ? 'is-down' : redisOk ? 'is-ok' : ''}`}>
|
||||
Redis {redisOk === false ? '끊김' : redisOk ? '정상' : '…'}
|
||||
</span>
|
||||
{data?.paused && (
|
||||
<span className="infra-chip is-warn">
|
||||
⏸ 일시정지{data.paused_reason ? ` (${data.paused_reason})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{deadLetters > 0 && <span className="infra-chip is-danger">❌ 실패 {deadLetters}</span>}
|
||||
</div>
|
||||
<div className="infra-bar__actions">
|
||||
{updatedAt && (
|
||||
<span className="infra-updated">
|
||||
{new Date(updatedAt).toLocaleTimeString('ko-KR')} 갱신
|
||||
</span>
|
||||
)}
|
||||
{webgl && (
|
||||
<div className="infra-toggle">
|
||||
<button className={view === '3d' ? 'is-active' : ''} onClick={() => setView('3d')}>
|
||||
3D
|
||||
</button>
|
||||
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>
|
||||
그리드
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="infra-refresh" onClick={refresh} title="새로고침">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !data && (
|
||||
<div className="infra-error">
|
||||
<b>집계 서버 연결 끊김</b>
|
||||
<span>{String(error.message || error)}</span>
|
||||
<button onClick={refresh}>다시 시도</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redisOk === false && (
|
||||
<div className="infra-warn-banner">
|
||||
⚠ Redis 버스 연결이 끊겨 모든 워커 상태를 읽을 수 없습니다. 파이프라인이 전면 중단 상태입니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !data && <div className="infra-loading">노드 상태 수집 중…</div>}
|
||||
|
||||
{view === '3d' && webgl && (
|
||||
<div className="infra-stage">
|
||||
<PipelineScene status={data} />
|
||||
<div className="infra-legend">
|
||||
<span><i style={{ background: '#00d4ff' }} /> 정상·흐름</span>
|
||||
<span><i style={{ background: '#fbbf24' }} /> 일시정지</span>
|
||||
<span><i style={{ background: '#fb923c' }} /> 실패누적</span>
|
||||
<span><i style={{ background: '#f43f5e' }} /> 다운·끊김</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`infra-grid${view === '3d' ? ' infra-grid--compact' : ''}`}>
|
||||
{workers.map((w) => (
|
||||
<WorkerCard key={w.name} w={w} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getNodeStatus } from '../../api';
|
||||
import InfraMonitor from './InfraMonitor';
|
||||
|
||||
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
|
||||
|
||||
const sample = {
|
||||
redis_ok: true,
|
||||
paused: false,
|
||||
paused_reason: null,
|
||||
workers: [
|
||||
{ name: 'image-render', kind: 'render', alive: true, state: 'idle', queue_depth: 0, dead_letter: 0, processing: 0, jobs_done: 5, jobs_failed: 0, last_beat_age_s: 3 },
|
||||
{ name: 'insta-render', kind: 'render', alive: false, state: null, queue_depth: 3, dead_letter: 0, processing: 0, jobs_done: 0, jobs_failed: 0, last_beat_age_s: null },
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
|
||||
describe('InfraMonitor', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders worker cards from /nodes (grid mode in jsdom — no WebGL)', async () => {
|
||||
getNodeStatus.mockResolvedValue(sample);
|
||||
render(<InfraMonitor />);
|
||||
await waitFor(() => expect(screen.getByText('Image Render')).toBeInTheDocument());
|
||||
expect(screen.getByText('Insta Render')).toBeInTheDocument();
|
||||
// alive 워커(image-render, idle)는 '대기' 상태 라벨
|
||||
expect(screen.getByText('대기')).toBeInTheDocument();
|
||||
// 오프라인 워커(insta-render)는 '오프라인' 라벨
|
||||
expect(screen.getByText('오프라인')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when /nodes fails', async () => {
|
||||
getNodeStatus.mockRejectedValue(new Error('down'));
|
||||
render(<InfraMonitor />);
|
||||
await waitFor(() => expect(screen.getByText('집계 서버 연결 끊김')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
340
src/pages/infra/PipelineScene.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
// src/pages/infra/PipelineScene.jsx
|
||||
// NAS ↔ Redis 큐 버스 ↔ Windows 워커 6종을 raw three.js로 그린 실시간 파이프라인.
|
||||
// 정상: 시안 파티클이 흐름 / busy: 빠르게 / paused: 앰버 정지 / degraded: 주황 흐름 / down: 빨강·흐름 멈춤.
|
||||
// status(/nodes)는 statusRef로 RAF 루프에 최신값 주입. 라벨은 3D→화면 투영 HTML 오버레이.
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { linkColor, workerStatus, workerStateLabel, workerTitle } from './statusVisual';
|
||||
|
||||
const NODES = [
|
||||
{ name: 'music-render', kind: 'render' },
|
||||
{ name: 'video-render', kind: 'render' },
|
||||
{ name: 'image-render', kind: 'render' },
|
||||
{ name: 'insta-render', kind: 'render' },
|
||||
{ name: 'task-watcher', kind: 'watcher' },
|
||||
{ name: 'ai_trade', kind: 'trader' },
|
||||
];
|
||||
|
||||
const hexToColor = (hex) => new THREE.Color(hex);
|
||||
|
||||
function workerByName(status, name) {
|
||||
if (!status || !Array.isArray(status.workers)) return null;
|
||||
return status.workers.find((w) => w.name === name) || null;
|
||||
}
|
||||
|
||||
// 링크의 현재 상태 문자열 → 'healthy'|'paused'|'degraded'|'down'|null
|
||||
function linkStatusOf(status, link) {
|
||||
if (!status) return null;
|
||||
if (link.kind === 'trunk') return status.redis_ok ? 'healthy' : 'down';
|
||||
const w = workerByName(status, link.worker);
|
||||
if (link.kind === 'branch' && !status.redis_ok) return 'down';
|
||||
if (!w) return 'down';
|
||||
return workerStatus(w);
|
||||
}
|
||||
|
||||
export default function PipelineScene({ status }) {
|
||||
const mountRef = useRef(null);
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return undefined;
|
||||
|
||||
let width = mount.clientWidth || 900;
|
||||
let height = mount.clientHeight || 520;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(52, width / height, 0.1, 200);
|
||||
camera.position.set(0, 1.4, 20.5);
|
||||
camera.lookAt(0, -0.3, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.domElement.style.display = 'block';
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
// ── lights ──
|
||||
scene.add(new THREE.AmbientLight(0x5577aa, 0.65));
|
||||
const l1 = new THREE.PointLight(0x00d4ff, 1.3, 80);
|
||||
l1.position.set(-10, 7, 14);
|
||||
scene.add(l1);
|
||||
const l2 = new THREE.PointLight(0x8b5cf6, 1.1, 80);
|
||||
l2.position.set(10, -7, 12);
|
||||
scene.add(l2);
|
||||
|
||||
// ── positions ──
|
||||
const nasPos = new THREE.Vector3(-9, 0, 0);
|
||||
const redisPos = new THREE.Vector3(-1.5, 0, 0);
|
||||
const colX = 8;
|
||||
const ys = [6.25, 3.75, 1.25, -1.25, -3.75, -6.25];
|
||||
const nodePositions = NODES.map((n, i) => new THREE.Vector3(colX, ys[i], 0));
|
||||
|
||||
const disposables = [];
|
||||
const track = (obj) => {
|
||||
if (obj.geometry) disposables.push(obj.geometry);
|
||||
if (obj.material) disposables.push(obj.material);
|
||||
return obj;
|
||||
};
|
||||
|
||||
// ── NAS node (left monolith) ──
|
||||
const nasMesh = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2.2, 3.2, 1.4),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x0d1530,
|
||||
emissive: 0x0a2a44,
|
||||
emissiveIntensity: 0.9,
|
||||
metalness: 0.5,
|
||||
roughness: 0.35,
|
||||
})
|
||||
)
|
||||
);
|
||||
nasMesh.position.copy(nasPos);
|
||||
scene.add(nasMesh);
|
||||
|
||||
// ── Redis bus (vertical glowing spine) ──
|
||||
const busMesh = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.55, 0.55, 13.2, 24, 1, true),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
)
|
||||
);
|
||||
busMesh.position.copy(redisPos);
|
||||
scene.add(busMesh);
|
||||
const busCore = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.18, 0.18, 13.2, 16),
|
||||
new THREE.MeshBasicMaterial({ color: 0xe8f0fe, transparent: true, opacity: 0.9 })
|
||||
)
|
||||
);
|
||||
busCore.position.copy(redisPos);
|
||||
scene.add(busCore);
|
||||
|
||||
// ── worker nodes ──
|
||||
const nodeMeshes = NODES.map((n, i) => {
|
||||
const geo =
|
||||
n.kind === 'trader'
|
||||
? new THREE.IcosahedronGeometry(0.95, 0)
|
||||
: n.kind === 'watcher'
|
||||
? new THREE.OctahedronGeometry(1.0, 0)
|
||||
: new THREE.BoxGeometry(1.7, 1.4, 1.4);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x0d1530,
|
||||
emissive: 0x111a3a,
|
||||
emissiveIntensity: 1.0,
|
||||
metalness: 0.45,
|
||||
roughness: 0.4,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.copy(nodePositions[i]);
|
||||
scene.add(mesh);
|
||||
disposables.push(geo, mat);
|
||||
return mesh;
|
||||
});
|
||||
|
||||
// ── links (curves) ──
|
||||
const particleGeo = new THREE.SphereGeometry(0.13, 8, 8);
|
||||
disposables.push(particleGeo);
|
||||
const PARTICLES_PER_LINK = 6;
|
||||
|
||||
function makeLink(curve, kind, worker) {
|
||||
const pts = curve.getPoints(60);
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x2a3a66,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
});
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
scene.add(line);
|
||||
disposables.push(lineGeo, lineMat);
|
||||
|
||||
const pMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.95,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
disposables.push(pMat);
|
||||
const particles = [];
|
||||
for (let k = 0; k < PARTICLES_PER_LINK; k += 1) {
|
||||
const pm = new THREE.Mesh(particleGeo, pMat);
|
||||
scene.add(pm);
|
||||
particles.push({ mesh: pm, t: k / PARTICLES_PER_LINK });
|
||||
}
|
||||
return { curve, kind, worker, line, lineMat, pMat, particles };
|
||||
}
|
||||
|
||||
const links = [];
|
||||
// trunk: NAS → Redis
|
||||
links.push(
|
||||
makeLink(
|
||||
new THREE.QuadraticBezierCurve3(
|
||||
nasPos.clone().add(new THREE.Vector3(1.2, 0, 0)),
|
||||
new THREE.Vector3((nasPos.x + redisPos.x) / 2, 0.6, 1.2),
|
||||
redisPos.clone()
|
||||
),
|
||||
'trunk'
|
||||
)
|
||||
);
|
||||
// branches: Redis → render/watcher (indices 0..4)
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const start = new THREE.Vector3(redisPos.x, ys[i] * 0.45, 0);
|
||||
const end = nodePositions[i].clone().add(new THREE.Vector3(-1.0, 0, 0));
|
||||
const ctrl = new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, 1.6);
|
||||
links.push(makeLink(new THREE.QuadraticBezierCurve3(start, ctrl, end), 'branch', NODES[i].name));
|
||||
}
|
||||
// ai_trade: node → NAS directly (http-pull, bypasses Redis bus)
|
||||
links.push(
|
||||
makeLink(
|
||||
new THREE.QuadraticBezierCurve3(
|
||||
nodePositions[5].clone().add(new THREE.Vector3(-0.9, -0.2, 0)),
|
||||
new THREE.Vector3(0, -9.5, 4.5),
|
||||
nasPos.clone().add(new THREE.Vector3(0.4, -1.4, 0))
|
||||
),
|
||||
'pull',
|
||||
'ai_trade'
|
||||
)
|
||||
);
|
||||
|
||||
// ── HTML label overlay ──
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'pipeline-labels';
|
||||
mount.appendChild(overlay);
|
||||
const makeLabel = (title, sub) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pipeline-label';
|
||||
el.innerHTML = `<span class="pl-dot"></span><span class="pl-name">${title}</span><span class="pl-state">${sub}</span>`;
|
||||
overlay.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
const nasLabel = makeLabel('NAS', '게이트웨이');
|
||||
nasLabel.classList.add('pipeline-label--anchor');
|
||||
const busLabel = makeLabel('Redis Bus', '큐');
|
||||
busLabel.classList.add('pipeline-label--anchor');
|
||||
const nodeLabels = NODES.map((n) => makeLabel(workerTitle(n.name), '—'));
|
||||
|
||||
const projectTo = (pos, el, dx = 0, dy = 0) => {
|
||||
const v = pos.clone().project(camera);
|
||||
const x = (v.x * 0.5 + 0.5) * width + dx;
|
||||
const y = (-v.y * 0.5 + 0.5) * height + dy;
|
||||
el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px)`;
|
||||
el.style.opacity = v.z < 1 ? '1' : '0';
|
||||
};
|
||||
|
||||
// ── animation ──
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const clock = { t: 0 };
|
||||
|
||||
const speedFor = (st) => {
|
||||
if (st === 'down' || st === 'paused' || st == null) return 0;
|
||||
return 0.16; // healthy/degraded base
|
||||
};
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
clock.t += dt;
|
||||
const status = statusRef.current;
|
||||
|
||||
// Redis bus color/pulse
|
||||
const redisOk = !status || status.redis_ok;
|
||||
const busColor = redisOk ? 0x00d4ff : 0xf43f5e;
|
||||
const pulse = 0.7 + Math.sin(clock.t * 2.2) * 0.18;
|
||||
busMesh.material.color.setHex(busColor);
|
||||
busMesh.material.opacity = 0.45 + pulse * 0.3;
|
||||
busCore.material.opacity = redisOk ? 0.55 + pulse * 0.35 : 0.5;
|
||||
|
||||
// per-link
|
||||
links.forEach((lk) => {
|
||||
const st = linkStatusOf(status, lk);
|
||||
const col = hexToColor(st ? linkColor(st) : '#2a3a66');
|
||||
lk.lineMat.color.copy(col);
|
||||
lk.lineMat.opacity = st === 'down' ? 0.5 : 0.55;
|
||||
lk.pMat.color.copy(col);
|
||||
|
||||
let speed = speedFor(st);
|
||||
// busy 워커는 빠르게
|
||||
if (lk.worker && status) {
|
||||
const w = workerByName(status, lk.worker);
|
||||
if (w && w.state === 'busy') speed = 0.42;
|
||||
}
|
||||
const showParticles = st !== 'down';
|
||||
lk.pMat.opacity = showParticles ? 0.95 : 0.0;
|
||||
lk.particles.forEach((p) => {
|
||||
p.t = (p.t + speed * dt) % 1;
|
||||
const pos = lk.curve.getPoint(p.t);
|
||||
p.mesh.position.copy(pos);
|
||||
p.mesh.visible = showParticles;
|
||||
const s = st === 'paused' ? 0.8 : 1 + Math.sin((p.t + clock.t) * 6) * 0.25;
|
||||
p.mesh.scale.setScalar(s);
|
||||
});
|
||||
});
|
||||
|
||||
// worker node color/pulse + labels
|
||||
NODES.forEach((n, i) => {
|
||||
const w = workerByName(status, n.name);
|
||||
const stt = workerStatus(w);
|
||||
const c = hexToColor(linkColor(stt));
|
||||
const mesh = nodeMeshes[i];
|
||||
mesh.material.emissive.copy(c);
|
||||
const alive = w && w.alive;
|
||||
const beat = alive ? 1.05 + Math.sin(clock.t * 3 + i) * 0.06 : 0.92;
|
||||
mesh.material.emissiveIntensity = alive ? 0.9 + Math.sin(clock.t * 3 + i) * 0.25 : 0.35;
|
||||
mesh.scale.setScalar(beat);
|
||||
mesh.rotation.y += dt * (n.kind === 'render' ? 0.15 : 0.4);
|
||||
|
||||
// label
|
||||
const el = nodeLabels[i];
|
||||
el.style.setProperty('--pl-color', linkColor(stt));
|
||||
const sub = el.querySelector('.pl-state');
|
||||
if (sub) sub.textContent = workerStateLabel(w);
|
||||
projectTo(nodePositions[i].clone().add(new THREE.Vector3(0, 1.5, 0)), el);
|
||||
});
|
||||
|
||||
// NAS / bus labels
|
||||
nasLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
|
||||
projectTo(nasPos.clone().add(new THREE.Vector3(0, 2.2, 0)), nasLabel);
|
||||
busLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
|
||||
const busSub = busLabel.querySelector('.pl-state');
|
||||
if (busSub) busSub.textContent = redisOk ? '정상' : '연결 끊김';
|
||||
projectTo(redisPos.clone().add(new THREE.Vector3(0, 7.3, 0)), busLabel);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
// ── resize ──
|
||||
const onResize = () => {
|
||||
width = mount.clientWidth || width;
|
||||
height = mount.clientHeight || height;
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(mount);
|
||||
|
||||
// ── cleanup ──
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
disposables.forEach((d) => d.dispose && d.dispose());
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} className="pipeline-canvas" />;
|
||||
}
|
||||
73
src/pages/infra/statusVisual.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// src/pages/infra/statusVisual.js
|
||||
// 상태 → 색/라벨 매핑. 2D 패널과 Three.js 파이프라인이 공유하는 단일 진실원천.
|
||||
// 색은 index.css 테마 팔레트와 일치(neon-cyan healthy, amber paused, orange degraded, red down).
|
||||
|
||||
export const LINK_COLORS = {
|
||||
healthy: '#00d4ff', // neon-cyan — 통신이 흐름
|
||||
paused: '#fbbf24', // amber — 작업중(트레이딩) 일시정지
|
||||
degraded: '#fb923c', // orange — dead-letter 누적
|
||||
down: '#f43f5e', // red — 워커 다운/링크 끊김
|
||||
};
|
||||
|
||||
const NEUTRAL = '#4a5572';
|
||||
|
||||
export function linkColor(status) {
|
||||
return LINK_COLORS[status] || NEUTRAL;
|
||||
}
|
||||
|
||||
// 워커 객체 → 사람이 읽는 상태 라벨
|
||||
export function workerStateLabel(w) {
|
||||
if (!w || !w.alive) return '오프라인';
|
||||
switch (w.state) {
|
||||
case 'paused':
|
||||
return '일시정지';
|
||||
case 'busy':
|
||||
return '처리 중';
|
||||
case 'idle':
|
||||
return '대기';
|
||||
case 'market_open':
|
||||
return '장중';
|
||||
case 'market_closed':
|
||||
return '휴장';
|
||||
case 'fetching':
|
||||
return '수집 중';
|
||||
default:
|
||||
return '온라인';
|
||||
}
|
||||
}
|
||||
|
||||
// 워커 객체 → 링크 status 도출(2D/3D 공통). collect_status의 link 산정과 동일 규칙.
|
||||
export function workerStatus(w) {
|
||||
if (!w || !w.alive) return 'down';
|
||||
if (w.state === 'paused') return 'paused';
|
||||
if ((w.dead_letter || 0) > 0) return 'degraded';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function workerColor(w) {
|
||||
return linkColor(workerStatus(w));
|
||||
}
|
||||
|
||||
// 워커 내부명 → 표시 타이틀
|
||||
export const WORKER_TITLES = {
|
||||
'music-render': 'Music Render',
|
||||
'video-render': 'Video Render',
|
||||
'image-render': 'Image Render',
|
||||
'insta-render': 'Insta Render',
|
||||
'task-watcher': 'Task Watcher',
|
||||
ai_trade: 'AI Trade',
|
||||
'naver-fetch': 'Naver Fetch',
|
||||
};
|
||||
|
||||
export function workerTitle(name) {
|
||||
return WORKER_TITLES[name] || name;
|
||||
}
|
||||
|
||||
// kind → 한 줄 역할
|
||||
export function kindLabel(kind) {
|
||||
if (kind === 'render') return '렌더 워커';
|
||||
if (kind === 'watcher') return '작업 감시';
|
||||
if (kind === 'trader') return '트레이딩';
|
||||
if (kind === 'fetcher') return '수집 워커';
|
||||
return kind || '';
|
||||
}
|
||||
55
src/pages/infra/statusVisual.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle, kindLabel } from './statusVisual';
|
||||
|
||||
describe('statusVisual', () => {
|
||||
it('maps link status to theme colors', () => {
|
||||
expect(linkColor('healthy')).toBe('#00d4ff');
|
||||
expect(linkColor('paused')).toBe('#fbbf24');
|
||||
expect(linkColor('degraded')).toBe('#fb923c');
|
||||
expect(linkColor('down')).toBe('#f43f5e');
|
||||
expect(linkColor('???')).toBe('#4a5572');
|
||||
});
|
||||
|
||||
it('labels a dead worker offline', () => {
|
||||
expect(workerStateLabel({ alive: false })).toBe('오프라인');
|
||||
expect(workerStateLabel(null)).toBe('오프라인');
|
||||
});
|
||||
|
||||
it('labels alive workers by state', () => {
|
||||
expect(workerStateLabel({ alive: true, state: 'idle' })).toBe('대기');
|
||||
expect(workerStateLabel({ alive: true, state: 'busy' })).toBe('처리 중');
|
||||
expect(workerStateLabel({ alive: true, state: 'paused' })).toBe('일시정지');
|
||||
expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중');
|
||||
});
|
||||
|
||||
it('labels a fetching worker as collecting', () => {
|
||||
expect(workerStateLabel({ alive: true, state: 'fetching' })).toBe('수집 중');
|
||||
});
|
||||
|
||||
it('maps kind to a one-line role label', () => {
|
||||
expect(kindLabel('render')).toBe('렌더 워커');
|
||||
expect(kindLabel('watcher')).toBe('작업 감시');
|
||||
expect(kindLabel('trader')).toBe('트레이딩');
|
||||
expect(kindLabel('fetcher')).toBe('수집 워커');
|
||||
expect(kindLabel('unknown')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('derives worker status with dead-letter and paused precedence', () => {
|
||||
expect(workerStatus({ alive: false })).toBe('down');
|
||||
expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused');
|
||||
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 3 })).toBe('degraded');
|
||||
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 0 })).toBe('healthy');
|
||||
});
|
||||
|
||||
it('workerColor follows workerStatus', () => {
|
||||
expect(workerColor({ alive: false })).toBe('#f43f5e');
|
||||
expect(workerColor({ alive: true, state: 'idle' })).toBe('#00d4ff');
|
||||
});
|
||||
|
||||
it('humanizes worker names', () => {
|
||||
expect(workerTitle('insta-render')).toBe('Insta Render');
|
||||
expect(workerTitle('ai_trade')).toBe('AI Trade');
|
||||
expect(workerTitle('naver-fetch')).toBe('Naver Fetch');
|
||||
expect(workerTitle('unknown-x')).toBe('unknown-x');
|
||||
});
|
||||
});
|
||||
39
src/pages/infra/useNodeStatus.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/pages/infra/useNodeStatus.js
|
||||
// /api/agent-office/nodes 를 주기 폴링하는 훅. 3초 권장(Three.js 흐름과 동기).
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getNodeStatus } from '../../api';
|
||||
|
||||
export function useNodeStatus(intervalMs = 4000) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatedAt, setUpdatedAt] = useState(null);
|
||||
const aliveRef = useRef(true);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
try {
|
||||
const d = await getNodeStatus();
|
||||
if (!aliveRef.current) return;
|
||||
setData(d);
|
||||
setError(null);
|
||||
setUpdatedAt(Date.now());
|
||||
} catch (e) {
|
||||
if (!aliveRef.current) return;
|
||||
setError(e);
|
||||
} finally {
|
||||
if (aliveRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
tick();
|
||||
const id = setInterval(tick, intervalMs);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [tick, intervalMs]);
|
||||
|
||||
return { data, error, loading, updatedAt, refresh: tick };
|
||||
}
|
||||
26
src/pages/infra/useNodeStatus.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useNodeStatus } from './useNodeStatus';
|
||||
import { getNodeStatus } from '../../api';
|
||||
|
||||
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
|
||||
|
||||
describe('useNodeStatus', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('fetches node status on mount', async () => {
|
||||
getNodeStatus.mockResolvedValue({ redis_ok: true, workers: [], links: [] });
|
||||
const { result } = renderHook(() => useNodeStatus(100000));
|
||||
await waitFor(() => expect(result.current.data).toBeTruthy());
|
||||
expect(result.current.data.redis_ok).toBe(true);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('captures fetch error', async () => {
|
||||
getNodeStatus.mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useNodeStatus(100000));
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.error.message).toBe('boom');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@
|
||||
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||
a.ic-btn { color: inherit; }
|
||||
a.ic-btn:hover { color: inherit; }
|
||||
|
||||
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
||||
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -1,300 +1,18 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import {
|
||||
getInstaStatus,
|
||||
instaCollectNews,
|
||||
instaExtractKeywords,
|
||||
getInstaKeywords,
|
||||
createInstaSlate,
|
||||
getInstaSlates,
|
||||
getInstaSlate,
|
||||
renderInstaSlate,
|
||||
deleteInstaSlate,
|
||||
getInstaAssetUrl,
|
||||
getInstaTask,
|
||||
getInstaPrompt,
|
||||
putInstaPrompt,
|
||||
getInstaTrends,
|
||||
instaCollectTrends,
|
||||
getInstaPreferences,
|
||||
putInstaPreferences,
|
||||
} from '../../api';
|
||||
import './InstaCards.css';
|
||||
|
||||
/* ────────────────────── 유틸 ────────────────────── */
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
return (
|
||||
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
||||
{status || 'draft'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||
function usePollTask(onDone) {
|
||||
const [taskId, setTaskId] = useState(null);
|
||||
const [task, setTask] = useState(null);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const t = await getInstaTask(taskId);
|
||||
if (cancelled) return;
|
||||
setTask(t);
|
||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||
setTaskId(null);
|
||||
onDone?.(t);
|
||||
} else {
|
||||
timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer.current);
|
||||
};
|
||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
taskId,
|
||||
task,
|
||||
start: setTaskId,
|
||||
clear: () => { setTaskId(null); setTask(null); },
|
||||
};
|
||||
}
|
||||
|
||||
/* ────────────────────── TaskStatusBox ────────────────────── */
|
||||
function TaskStatusBox({ task }) {
|
||||
if (!task) return null;
|
||||
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
|
||||
return (
|
||||
<div className="ic-task-status">
|
||||
<div className="ic-task-status__label">
|
||||
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
||||
</div>
|
||||
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
||||
<div className="ic-task-status__progress">
|
||||
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
||||
function AccountFocusPanel() {
|
||||
const [prefs, setPrefs] = useState([]);
|
||||
const [draft, setDraft] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newCat, setNewCat] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await getInstaPreferences();
|
||||
setPrefs(data.categories || []);
|
||||
const m = {};
|
||||
(data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); });
|
||||
setDraft(m);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {};
|
||||
Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; });
|
||||
await putInstaPreferences(payload);
|
||||
await load();
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const addCat = () => {
|
||||
const name = newCat.trim().toLowerCase();
|
||||
if (!name || draft[name] !== undefined) return;
|
||||
setDraft({ ...draft, [name]: 0 });
|
||||
setNewCat('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ic-panel ic-panel--focus">
|
||||
<h3 className="ic-panel__title">🎯 이 계정의 주제 (카테고리 가중치)</h3>
|
||||
<p className="ic-panel__hint">슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
|
||||
<div className="ic-focus__list">
|
||||
{Object.entries(draft).map(([cat, val]) => (
|
||||
<div key={cat} className="ic-focus__row">
|
||||
<label className="ic-focus__label">{cat}</label>
|
||||
<input
|
||||
type="range" min="0" max="100" value={val}
|
||||
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
|
||||
className="ic-focus__slider"
|
||||
/>
|
||||
<span className="ic-focus__num">{val}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ic-focus__add">
|
||||
<input
|
||||
type="text" placeholder="신규 카테고리 (영문 소문자)"
|
||||
value={newCat} onChange={e => setNewCat(e.target.value)}
|
||||
/>
|
||||
<button onClick={addCat}>+ 추가</button>
|
||||
</div>
|
||||
<button className="ic-focus__save" onClick={save} disabled={saving}>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
<div className="ic-focus__hint">
|
||||
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
|
||||
<code>category_seeds</code>에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */
|
||||
const CATEGORY_COLORS = {
|
||||
economy: '#0F62FE', psychology: '#A66CFF',
|
||||
celebrity: '#FF5C8A', uncategorized: '#6B7280',
|
||||
};
|
||||
|
||||
function ExternalTrendsPanel({ onCreateSlate }) {
|
||||
const [naver, setNaver] = useState([]);
|
||||
const [google, setGoogle] = useState([]);
|
||||
const [lastFetched, setLastFetched] = useState(null);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const [task, setTask] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [n, g] = await Promise.all([
|
||||
getInstaTrends({ source: 'naver_popular', days: 2 }),
|
||||
getInstaTrends({ source: 'youtube_trending', days: 2 }),
|
||||
]);
|
||||
setNaver(n.items || []);
|
||||
setGoogle(g.items || []);
|
||||
const all = [...(n.items || []), ...(g.items || [])];
|
||||
if (all.length) {
|
||||
const latest = all.map(t => t.suggested_at).sort().reverse()[0];
|
||||
setLastFetched(latest);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const trigger = async () => {
|
||||
setCollecting(true);
|
||||
try {
|
||||
const { task_id } = await instaCollectTrends();
|
||||
let st = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
st = await getInstaTask(task_id);
|
||||
setTask(st);
|
||||
if (st.status === 'succeeded' || st.status === 'failed') break;
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
await load();
|
||||
} finally { setCollecting(false); }
|
||||
};
|
||||
|
||||
const groupByCat = (items) => {
|
||||
const g = {};
|
||||
items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); });
|
||||
return g;
|
||||
};
|
||||
|
||||
const renderRow = (t) => (
|
||||
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
|
||||
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
|
||||
<span className="ic-trend__kw">{t.keyword}</span>
|
||||
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
|
||||
<button
|
||||
className="ic-trend__make"
|
||||
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
|
||||
>🎴</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const naverGrouped = groupByCat(naver);
|
||||
return (
|
||||
<section className="ic-panel ic-panel--trends">
|
||||
<div className="ic-panel__head">
|
||||
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
|
||||
<div className="ic-panel__actions">
|
||||
<span className="ic-panel__hint">
|
||||
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
|
||||
</span>
|
||||
<button onClick={trigger} disabled={collecting}>
|
||||
{collecting ? '수집 중...' : '🔄 수동 수집'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{task && <TaskStatusBox task={task} />}
|
||||
<div className="ic-trends__cols">
|
||||
<div className="ic-trends__col">
|
||||
<h4>🔥 NAVER 인기</h4>
|
||||
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
|
||||
{Object.entries(naverGrouped).map(([cat, items]) => (
|
||||
<div key={cat} className="ic-trend__group">
|
||||
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
|
||||
{items.map(renderRow)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ic-trends__col">
|
||||
<h4>📺 YouTube 인기</h4>
|
||||
{google.length === 0 && <p className="ic-empty">없음</p>}
|
||||
{google.map(renderRow)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */
|
||||
function PreferenceImpactPanel() {
|
||||
const [prefs, setPrefs] = useState([]);
|
||||
const TOTAL = 15;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const data = await getInstaPreferences();
|
||||
setPrefs(data.categories || []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1;
|
||||
const breakdown = prefs.map(p => ({
|
||||
category: p.category,
|
||||
count: Math.round(TOTAL * (p.weight || 0) / totalWeight),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="ic-panel ic-panel--impact">
|
||||
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
|
||||
<div className="ic-impact__row">
|
||||
{breakdown.map(b => (
|
||||
<div key={b.category} className="ic-impact__chip">
|
||||
<span className="ic-impact__cat">{b.category}</span>
|
||||
<span className="ic-impact__count">{b.count}개</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import TriggerPanel from './components/TriggerPanel';
|
||||
import KeywordsPanel from './components/KeywordsPanel';
|
||||
import SlatesPanel from './components/SlatesPanel';
|
||||
import PromptTemplatesEditor from './components/PromptTemplatesEditor';
|
||||
import AccountFocusPanel from './components/AccountFocusPanel';
|
||||
import ExternalTrendsPanel from './components/ExternalTrendsPanel';
|
||||
import PreferenceImpactPanel from './components/PreferenceImpactPanel';
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||
export default function InstaCards() {
|
||||
@@ -465,545 +183,3 @@ export default function InstaCards() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||||
function TriggerPanel() {
|
||||
const collectPoll = usePollTask();
|
||||
const keywordsPoll = usePollTask();
|
||||
|
||||
async function handleCollect() {
|
||||
try {
|
||||
const res = await instaCollectNews();
|
||||
collectPoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('뉴스 수집 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeywords() {
|
||||
try {
|
||||
const res = await instaExtractKeywords();
|
||||
keywordsPoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('키워드 추출 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const collectBusy = !!collectPoll.taskId;
|
||||
const kwBusy = !!keywordsPoll.taskId;
|
||||
|
||||
return (
|
||||
<div className="ic-section">
|
||||
<p className="ic-section__title">트리거</p>
|
||||
<div className="ic-trigger-buttons">
|
||||
<button
|
||||
className="ic-btn ic-btn--primary"
|
||||
onClick={handleCollect}
|
||||
disabled={collectBusy}
|
||||
>
|
||||
{collectBusy && <span className="ic-spinner" />}
|
||||
뉴스 수집
|
||||
</button>
|
||||
<TaskStatusBox task={collectPoll.task} />
|
||||
<button
|
||||
className="ic-btn ic-btn--secondary"
|
||||
onClick={handleKeywords}
|
||||
disabled={kwBusy}
|
||||
>
|
||||
{kwBusy && <span className="ic-spinner" />}
|
||||
키워드 추출
|
||||
</button>
|
||||
<TaskStatusBox task={keywordsPoll.task} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
||||
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
||||
const KEYWORDS_PER_PAGE = 10;
|
||||
|
||||
function KeywordsPanel({ onCreateSlate }) {
|
||||
const [category, setCategory] = useState('전체');
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [creating, setCreating] = useState(null); // keyword_id being created
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const load = useCallback(() => {
|
||||
const cat = category === '전체' ? undefined : category;
|
||||
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { setPage(0); }, [category]); // 카테고리 변경 시 첫 페이지로
|
||||
|
||||
// 동일 keyword 중복 제거(최고 score 1개만 유지) + score 내림차순
|
||||
const deduped = useMemo(() => {
|
||||
const best = new Map();
|
||||
for (const kw of keywords) {
|
||||
const name = (kw.keyword || '').trim();
|
||||
if (!name) continue;
|
||||
const prev = best.get(name);
|
||||
if (!prev || (kw.score ?? 0) > (prev.score ?? 0)) best.set(name, kw);
|
||||
}
|
||||
return [...best.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
}, [keywords]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(deduped.length / KEYWORDS_PER_PAGE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = deduped.slice(safePage * KEYWORDS_PER_PAGE, safePage * KEYWORDS_PER_PAGE + KEYWORDS_PER_PAGE);
|
||||
|
||||
// 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화
|
||||
async function handleCreate(kw) {
|
||||
if (creating) return;
|
||||
setCreating(kw.id);
|
||||
try {
|
||||
await onCreateSlate?.({
|
||||
keyword: kw.keyword,
|
||||
category: kw.category,
|
||||
keyword_id: kw.id,
|
||||
});
|
||||
} finally {
|
||||
setCreating(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-section">
|
||||
<p className="ic-section__title">트렌딩 키워드</p>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="ic-filter">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
|
||||
|
||||
{deduped.length === 0 ? (
|
||||
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ic-keywords">
|
||||
{pageItems.map((kw) => (
|
||||
<div key={kw.id} className="ic-keyword-row">
|
||||
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
||||
<span className="ic-keyword-row__meta">
|
||||
{kw.category} · {kw.articles_count ?? 0}건
|
||||
</span>
|
||||
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
||||
<button
|
||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||
onClick={() => handleCreate(kw)}
|
||||
disabled={!!creating}
|
||||
>
|
||||
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="ic-keywords__pager">
|
||||
<button
|
||||
className="ic-pager-btn"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={safePage === 0}
|
||||
aria-label="이전 키워드"
|
||||
>←</button>
|
||||
<span className="ic-pager-info">{safePage + 1} / {totalPages}</span>
|
||||
<button
|
||||
className="ic-pager-btn"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="다음 키워드"
|
||||
>→</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||||
function SlatesPanel({ selectedId, onSelect }) {
|
||||
const [slates, setSlates] = useState([]);
|
||||
const [detail, setDetail] = useState(null);
|
||||
|
||||
const loadSlates = useCallback(() => {
|
||||
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadSlates(); }, [loadSlates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setDetail(null); return; }
|
||||
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
||||
}, [selectedId]);
|
||||
|
||||
function handleSelect(id) {
|
||||
onSelect(id === selectedId ? null : id);
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await deleteInstaSlate(id);
|
||||
if (selectedId === id) onSelect(null);
|
||||
loadSlates();
|
||||
} catch (e) {
|
||||
alert('삭제 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender(id) {
|
||||
try {
|
||||
const res = await renderInstaSlate(id);
|
||||
// Re-render is fire-and-forget from the panel; user can refresh detail
|
||||
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
||||
setTimeout(loadSlates, 3000);
|
||||
} catch (e) {
|
||||
alert('재렌더 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ic-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||||
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||||
</div>
|
||||
|
||||
{slates.length === 0 ? (
|
||||
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||||
) : (
|
||||
<div className="ic-slates-grid">
|
||||
{slates.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||||
onClick={() => handleSelect(s.id)}
|
||||
>
|
||||
{s.status === 'rendered' || s.status === 'sent' ? (
|
||||
<img
|
||||
className="ic-slate-thumb"
|
||||
src={getInstaAssetUrl(s.id, 1)}
|
||||
alt={s.keyword}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||||
)}
|
||||
<div className="ic-slate-card__info">
|
||||
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||||
<div className="ic-slate-card__meta">
|
||||
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 슬레이트 상세 */}
|
||||
{detail && (
|
||||
<SlateDetail
|
||||
slate={detail}
|
||||
onDelete={() => handleDelete(detail.id)}
|
||||
onRender={() => handleRender(detail.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
|
||||
function PagesStrip({ slateId, pageCount }) {
|
||||
const stripRef = useRef(null);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
|
||||
const scrollToPage = useCallback((pageNo) => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
const next = Math.max(1, Math.min(pageCount, pageNo));
|
||||
const child = strip.children[next - 1];
|
||||
if (child) {
|
||||
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
setActivePage(next);
|
||||
}
|
||||
}, [pageCount]);
|
||||
|
||||
// 스크롤/드래그 시 가운데 카드 감지
|
||||
const onScroll = useCallback(() => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
const rect = strip.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
let best = 1, bestDist = Infinity;
|
||||
Array.from(strip.children).forEach((child, i) => {
|
||||
const cRect = child.getBoundingClientRect();
|
||||
const cCenter = cRect.left + cRect.width / 2;
|
||||
const dist = Math.abs(cCenter - centerX);
|
||||
if (dist < bestDist) { bestDist = dist; best = i + 1; }
|
||||
});
|
||||
if (best !== activePage) setActivePage(best);
|
||||
}, [activePage]);
|
||||
|
||||
// 키보드 ←/→
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
|
||||
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [activePage, scrollToPage]);
|
||||
|
||||
return (
|
||||
<div className="ic-pages-wrap">
|
||||
<button
|
||||
className="ic-pages-nav ic-pages-nav--prev"
|
||||
onClick={() => scrollToPage(activePage - 1)}
|
||||
disabled={activePage <= 1}
|
||||
aria-label="이전 페이지"
|
||||
type="button"
|
||||
>‹</button>
|
||||
|
||||
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||
<img
|
||||
key={page}
|
||||
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
||||
src={getInstaAssetUrl(slateId, page)}
|
||||
alt={`Page ${page}`}
|
||||
loading="lazy"
|
||||
onClick={() => scrollToPage(page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="ic-pages-nav ic-pages-nav--next"
|
||||
onClick={() => scrollToPage(activePage + 1)}
|
||||
disabled={activePage >= pageCount}
|
||||
aria-label="다음 페이지"
|
||||
type="button"
|
||||
>›</button>
|
||||
|
||||
<div className="ic-pages-indicator-row">
|
||||
<span className="ic-pages-indicator">
|
||||
<span className="ic-pages-indicator__current">{activePage}</span>
|
||||
<span className="ic-pages-indicator__sep">/</span>
|
||||
<span className="ic-pages-indicator__total">{pageCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||
function SlateDetail({ slate, onDelete, onRender }) {
|
||||
const pages = slate.assets || [];
|
||||
const pageCount = pages.length > 0 ? pages.length : 10;
|
||||
|
||||
function copyCaption() {
|
||||
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
||||
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-detail">
|
||||
<div className="ic-detail__header">
|
||||
<div className="ic-detail__title">
|
||||
{slate.keyword}
|
||||
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||||
</div>
|
||||
<div className="ic-detail__actions">
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
|
||||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||
<PagesStrip slateId={slate.id} pageCount={pageCount} />
|
||||
) : (
|
||||
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 캡션 */}
|
||||
{slate.suggested_caption && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">
|
||||
캡션
|
||||
<button
|
||||
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={copyCaption}
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||||
{slate.hashtags?.length > 0 && (
|
||||
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||||
{slate.hashtags.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커버 카피 (1/10) */}
|
||||
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">🎯 커버 (1/10)</div>
|
||||
<div className="ic-caption-text">
|
||||
<strong>{slate.cover_copy.headline}</strong>
|
||||
{slate.cover_copy.body && (
|
||||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||
{slate.cover_copy.body}
|
||||
</div>
|
||||
)}
|
||||
{slate.cover_copy.accent_color && (
|
||||
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
|
||||
accent: <code>{slate.cover_copy.accent_color}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 본문 카피 8장 (2~9/10) */}
|
||||
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">📝 본문 8장 (2~9/10)</div>
|
||||
{slate.body_copies.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||||
padding: '10px 0',
|
||||
}}
|
||||
>
|
||||
<strong>{i + 2}. {b?.headline || ''}</strong>
|
||||
{b?.body && (
|
||||
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||
{b.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA 카피 (10/10) */}
|
||||
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
|
||||
<div className="ic-caption-text">
|
||||
<strong>{slate.cta_copy.headline}</strong>
|
||||
{slate.cta_copy.body && (
|
||||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||
{slate.cta_copy.body}
|
||||
</div>
|
||||
)}
|
||||
{slate.cta_copy.cta && (
|
||||
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
|
||||
CTA: {slate.cta_copy.cta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
||||
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
||||
|
||||
function PromptTemplatesEditor() {
|
||||
const [prompts, setPrompts] = useState({});
|
||||
const [drafts, setDrafts] = useState({});
|
||||
const [saving, setSaving] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
PROMPT_NAMES.forEach((name) => {
|
||||
getInstaPrompt(name)
|
||||
.then((p) => {
|
||||
setPrompts((prev) => ({ ...prev, [name]: p }));
|
||||
setDrafts((prev) => ({ ...prev, [name]: p.template }));
|
||||
})
|
||||
.catch(() => {
|
||||
setPrompts((prev) => ({ ...prev, [name]: null }));
|
||||
setDrafts((prev) => ({ ...prev, [name]: '' }));
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleSave(name) {
|
||||
setSaving((prev) => ({ ...prev, [name]: true }));
|
||||
try {
|
||||
const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || '');
|
||||
setPrompts((prev) => ({ ...prev, [name]: updated }));
|
||||
alert(`${name} 저장 완료`);
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message);
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [name]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
|
||||
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
|
||||
{PROMPT_NAMES.map((name) => (
|
||||
<div key={name} className="ic-prompt-block">
|
||||
<div className="ic-prompt-block__head">
|
||||
<span className="ic-prompt-block__name">{name}</span>
|
||||
{prompts[name]?.updated_at && (
|
||||
<span className="ic-prompt-block__date">
|
||||
최종 수정: {fmtDate(prompts[name].updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prompts[name]?.description && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
|
||||
{prompts[name].description}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="ic-prompt-textarea"
|
||||
value={drafts[name] ?? ''}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
|
||||
placeholder={`${name} 템플릿을 입력하세요...`}
|
||||
/>
|
||||
<div className="ic-prompt-save-row">
|
||||
<button
|
||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||
onClick={() => handleSave(name)}
|
||||
disabled={saving[name]}
|
||||
>
|
||||
{saving[name] ? <span className="ic-spinner" /> : null}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
73
src/pages/insta/components/AccountFocusPanel.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { getInstaPreferences, putInstaPreferences } from '../../../api';
|
||||
|
||||
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
||||
function AccountFocusPanel() {
|
||||
const [prefs, setPrefs] = useState([]);
|
||||
const [draft, setDraft] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newCat, setNewCat] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await getInstaPreferences();
|
||||
setPrefs(data.categories || []);
|
||||
const m = {};
|
||||
(data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); });
|
||||
setDraft(m);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {};
|
||||
Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; });
|
||||
await putInstaPreferences(payload);
|
||||
await load();
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const addCat = () => {
|
||||
const name = newCat.trim().toLowerCase();
|
||||
if (!name || draft[name] !== undefined) return;
|
||||
setDraft({ ...draft, [name]: 0 });
|
||||
setNewCat('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ic-panel ic-panel--focus">
|
||||
<h3 className="ic-panel__title">🎯 이 계정의 주제 (카테고리 가중치)</h3>
|
||||
<p className="ic-panel__hint">슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
|
||||
<div className="ic-focus__list">
|
||||
{Object.entries(draft).map(([cat, val]) => (
|
||||
<div key={cat} className="ic-focus__row">
|
||||
<label className="ic-focus__label">{cat}</label>
|
||||
<input
|
||||
type="range" min="0" max="100" value={val}
|
||||
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
|
||||
className="ic-focus__slider"
|
||||
/>
|
||||
<span className="ic-focus__num">{val}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ic-focus__add">
|
||||
<input
|
||||
type="text" placeholder="신규 카테고리 (영문 소문자)"
|
||||
value={newCat} onChange={e => setNewCat(e.target.value)}
|
||||
/>
|
||||
<button onClick={addCat}>+ 추가</button>
|
||||
</div>
|
||||
<button className="ic-focus__save" onClick={save} disabled={saving}>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
<div className="ic-focus__hint">
|
||||
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
|
||||
<code>category_seeds</code>에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountFocusPanel;
|
||||
104
src/pages/insta/components/ExternalTrendsPanel.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import TaskStatusBox from './TaskStatusBox';
|
||||
import { fmtDate } from '../instaUtils';
|
||||
import { getInstaTrends, instaCollectTrends, getInstaTask } from '../../../api';
|
||||
|
||||
/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */
|
||||
const CATEGORY_COLORS = {
|
||||
economy: '#0F62FE', psychology: '#A66CFF',
|
||||
celebrity: '#FF5C8A', uncategorized: '#6B7280',
|
||||
};
|
||||
|
||||
function ExternalTrendsPanel({ onCreateSlate }) {
|
||||
const [naver, setNaver] = useState([]);
|
||||
const [google, setGoogle] = useState([]);
|
||||
const [lastFetched, setLastFetched] = useState(null);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const [task, setTask] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [n, g] = await Promise.all([
|
||||
getInstaTrends({ source: 'naver_popular', days: 2 }),
|
||||
getInstaTrends({ source: 'youtube_trending', days: 2 }),
|
||||
]);
|
||||
setNaver(n.items || []);
|
||||
setGoogle(g.items || []);
|
||||
const all = [...(n.items || []), ...(g.items || [])];
|
||||
if (all.length) {
|
||||
const latest = all.map(t => t.suggested_at).sort().reverse()[0];
|
||||
setLastFetched(latest);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const trigger = async () => {
|
||||
setCollecting(true);
|
||||
try {
|
||||
const { task_id } = await instaCollectTrends();
|
||||
let st = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
st = await getInstaTask(task_id);
|
||||
setTask(st);
|
||||
if (st.status === 'succeeded' || st.status === 'failed') break;
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
await load();
|
||||
} finally { setCollecting(false); }
|
||||
};
|
||||
|
||||
const groupByCat = (items) => {
|
||||
const g = {};
|
||||
items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); });
|
||||
return g;
|
||||
};
|
||||
|
||||
const renderRow = (t) => (
|
||||
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
|
||||
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
|
||||
<span className="ic-trend__kw">{t.keyword}</span>
|
||||
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
|
||||
<button
|
||||
className="ic-trend__make"
|
||||
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
|
||||
>🎴</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const naverGrouped = groupByCat(naver);
|
||||
return (
|
||||
<section className="ic-panel ic-panel--trends">
|
||||
<div className="ic-panel__head">
|
||||
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
|
||||
<div className="ic-panel__actions">
|
||||
<span className="ic-panel__hint">
|
||||
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
|
||||
</span>
|
||||
<button onClick={trigger} disabled={collecting}>
|
||||
{collecting ? '수집 중...' : '🔄 수동 수집'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{task && <TaskStatusBox task={task} />}
|
||||
<div className="ic-trends__cols">
|
||||
<div className="ic-trends__col">
|
||||
<h4>🔥 NAVER 인기</h4>
|
||||
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
|
||||
{Object.entries(naverGrouped).map(([cat, items]) => (
|
||||
<div key={cat} className="ic-trend__group">
|
||||
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
|
||||
{items.map(renderRow)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ic-trends__col">
|
||||
<h4>📺 YouTube 인기</h4>
|
||||
{google.length === 0 && <p className="ic-empty">없음</p>}
|
||||
{google.map(renderRow)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExternalTrendsPanel;
|
||||
118
src/pages/insta/components/KeywordsPanel.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { getInstaKeywords } from '../../../api';
|
||||
|
||||
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
||||
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
||||
const KEYWORDS_PER_PAGE = 10;
|
||||
|
||||
function KeywordsPanel({ onCreateSlate }) {
|
||||
const [category, setCategory] = useState('전체');
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [creating, setCreating] = useState(null); // keyword_id being created
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const load = useCallback(() => {
|
||||
const cat = category === '전체' ? undefined : category;
|
||||
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { setPage(0); }, [category]); // 카테고리 변경 시 첫 페이지로
|
||||
|
||||
// 동일 keyword 중복 제거(최고 score 1개만 유지) + score 내림차순
|
||||
const deduped = useMemo(() => {
|
||||
const best = new Map();
|
||||
for (const kw of keywords) {
|
||||
const name = (kw.keyword || '').trim();
|
||||
if (!name) continue;
|
||||
const prev = best.get(name);
|
||||
if (!prev || (kw.score ?? 0) > (prev.score ?? 0)) best.set(name, kw);
|
||||
}
|
||||
return [...best.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
}, [keywords]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(deduped.length / KEYWORDS_PER_PAGE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = deduped.slice(safePage * KEYWORDS_PER_PAGE, safePage * KEYWORDS_PER_PAGE + KEYWORDS_PER_PAGE);
|
||||
|
||||
// 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화
|
||||
async function handleCreate(kw) {
|
||||
if (creating) return;
|
||||
setCreating(kw.id);
|
||||
try {
|
||||
await onCreateSlate?.({
|
||||
keyword: kw.keyword,
|
||||
category: kw.category,
|
||||
keyword_id: kw.id,
|
||||
});
|
||||
} finally {
|
||||
setCreating(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-section">
|
||||
<p className="ic-section__title">트렌딩 키워드</p>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="ic-filter">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
|
||||
|
||||
{deduped.length === 0 ? (
|
||||
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ic-keywords">
|
||||
{pageItems.map((kw) => (
|
||||
<div key={kw.id} className="ic-keyword-row">
|
||||
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
||||
<span className="ic-keyword-row__meta">
|
||||
{kw.category} · {kw.articles_count ?? 0}건
|
||||
</span>
|
||||
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
||||
<button
|
||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||
onClick={() => handleCreate(kw)}
|
||||
disabled={!!creating}
|
||||
>
|
||||
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="ic-keywords__pager">
|
||||
<button
|
||||
className="ic-pager-btn"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={safePage === 0}
|
||||
aria-label="이전 키워드"
|
||||
>←</button>
|
||||
<span className="ic-pager-info">{safePage + 1} / {totalPages}</span>
|
||||
<button
|
||||
className="ic-pager-btn"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="다음 키워드"
|
||||
>→</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeywordsPanel;
|
||||
89
src/pages/insta/components/PagesStrip.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getInstaAssetUrl } from '../../../api';
|
||||
|
||||
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
|
||||
function PagesStrip({ slateId, pageCount }) {
|
||||
const stripRef = useRef(null);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
|
||||
const scrollToPage = useCallback((pageNo) => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
const next = Math.max(1, Math.min(pageCount, pageNo));
|
||||
const child = strip.children[next - 1];
|
||||
if (child) {
|
||||
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
setActivePage(next);
|
||||
}
|
||||
}, [pageCount]);
|
||||
|
||||
// 스크롤/드래그 시 가운데 카드 감지
|
||||
const onScroll = useCallback(() => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
const rect = strip.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
let best = 1, bestDist = Infinity;
|
||||
Array.from(strip.children).forEach((child, i) => {
|
||||
const cRect = child.getBoundingClientRect();
|
||||
const cCenter = cRect.left + cRect.width / 2;
|
||||
const dist = Math.abs(cCenter - centerX);
|
||||
if (dist < bestDist) { bestDist = dist; best = i + 1; }
|
||||
});
|
||||
if (best !== activePage) setActivePage(best);
|
||||
}, [activePage]);
|
||||
|
||||
// 키보드 ←/→
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
|
||||
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [activePage, scrollToPage]);
|
||||
|
||||
return (
|
||||
<div className="ic-pages-wrap">
|
||||
<button
|
||||
className="ic-pages-nav ic-pages-nav--prev"
|
||||
onClick={() => scrollToPage(activePage - 1)}
|
||||
disabled={activePage <= 1}
|
||||
aria-label="이전 페이지"
|
||||
type="button"
|
||||
>‹</button>
|
||||
|
||||
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||
<img
|
||||
key={page}
|
||||
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
||||
src={getInstaAssetUrl(slateId, page)}
|
||||
alt={`Page ${page}`}
|
||||
loading="lazy"
|
||||
onClick={() => scrollToPage(page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="ic-pages-nav ic-pages-nav--next"
|
||||
onClick={() => scrollToPage(activePage + 1)}
|
||||
disabled={activePage >= pageCount}
|
||||
aria-label="다음 페이지"
|
||||
type="button"
|
||||
>›</button>
|
||||
|
||||
<div className="ic-pages-indicator-row">
|
||||
<span className="ic-pages-indicator">
|
||||
<span className="ic-pages-indicator__current">{activePage}</span>
|
||||
<span className="ic-pages-indicator__sep">/</span>
|
||||
<span className="ic-pages-indicator__total">{pageCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PagesStrip;
|
||||
37
src/pages/insta/components/PreferenceImpactPanel.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getInstaPreferences } from '../../../api';
|
||||
|
||||
/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */
|
||||
function PreferenceImpactPanel() {
|
||||
const [prefs, setPrefs] = useState([]);
|
||||
const TOTAL = 15;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const data = await getInstaPreferences();
|
||||
setPrefs(data.categories || []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1;
|
||||
const breakdown = prefs.map(p => ({
|
||||
category: p.category,
|
||||
count: Math.round(TOTAL * (p.weight || 0) / totalWeight),
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="ic-panel ic-panel--impact">
|
||||
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
|
||||
<div className="ic-impact__row">
|
||||
{breakdown.map(b => (
|
||||
<div key={b.category} className="ic-impact__chip">
|
||||
<span className="ic-impact__cat">{b.category}</span>
|
||||
<span className="ic-impact__count">{b.count}개</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreferenceImpactPanel;
|
||||
80
src/pages/insta/components/PromptTemplatesEditor.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fmtDate } from '../instaUtils';
|
||||
import { getInstaPrompt, putInstaPrompt } from '../../../api';
|
||||
|
||||
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
||||
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
||||
|
||||
function PromptTemplatesEditor() {
|
||||
const [prompts, setPrompts] = useState({});
|
||||
const [drafts, setDrafts] = useState({});
|
||||
const [saving, setSaving] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
PROMPT_NAMES.forEach((name) => {
|
||||
getInstaPrompt(name)
|
||||
.then((p) => {
|
||||
setPrompts((prev) => ({ ...prev, [name]: p }));
|
||||
setDrafts((prev) => ({ ...prev, [name]: p.template }));
|
||||
})
|
||||
.catch(() => {
|
||||
setPrompts((prev) => ({ ...prev, [name]: null }));
|
||||
setDrafts((prev) => ({ ...prev, [name]: '' }));
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleSave(name) {
|
||||
setSaving((prev) => ({ ...prev, [name]: true }));
|
||||
try {
|
||||
const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || '');
|
||||
setPrompts((prev) => ({ ...prev, [name]: updated }));
|
||||
alert(`${name} 저장 완료`);
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message);
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [name]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
|
||||
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
|
||||
{PROMPT_NAMES.map((name) => (
|
||||
<div key={name} className="ic-prompt-block">
|
||||
<div className="ic-prompt-block__head">
|
||||
<span className="ic-prompt-block__name">{name}</span>
|
||||
{prompts[name]?.updated_at && (
|
||||
<span className="ic-prompt-block__date">
|
||||
최종 수정: {fmtDate(prompts[name].updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prompts[name]?.description && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
|
||||
{prompts[name].description}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="ic-prompt-textarea"
|
||||
value={drafts[name] ?? ''}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
|
||||
placeholder={`${name} 템플릿을 입력하세요...`}
|
||||
/>
|
||||
<div className="ic-prompt-save-row">
|
||||
<button
|
||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||
onClick={() => handleSave(name)}
|
||||
disabled={saving[name]}
|
||||
>
|
||||
{saving[name] ? <span className="ic-spinner" /> : null}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PromptTemplatesEditor;
|
||||
129
src/pages/insta/components/SlateDetail.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import PagesStrip from './PagesStrip';
|
||||
import { instaPackageUrl } from '../../../api';
|
||||
|
||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||
function SlateDetail({ slate, onDelete, onRender }) {
|
||||
const pages = slate.assets || [];
|
||||
const pageCount = pages.length > 0 ? pages.length : 10;
|
||||
|
||||
function copyCaption() {
|
||||
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
||||
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-detail">
|
||||
<div className="ic-detail__header">
|
||||
<div className="ic-detail__title">
|
||||
{slate.keyword}
|
||||
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||||
</div>
|
||||
<div className="ic-detail__actions">
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
|
||||
📦 패키지 다운로드 (10장 + 캡션)
|
||||
</a>
|
||||
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
|
||||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||
<PagesStrip slateId={slate.id} pageCount={pageCount} />
|
||||
) : (
|
||||
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 캡션 */}
|
||||
{slate.suggested_caption && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">
|
||||
캡션
|
||||
<button
|
||||
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={copyCaption}
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||||
{slate.hashtags?.length > 0 && (
|
||||
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||||
{slate.hashtags.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커버 카피 (1/10) */}
|
||||
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">🎯 커버 (1/10)</div>
|
||||
<div className="ic-caption-text">
|
||||
<strong>{slate.cover_copy.headline}</strong>
|
||||
{slate.cover_copy.body && (
|
||||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||
{slate.cover_copy.body}
|
||||
</div>
|
||||
)}
|
||||
{slate.cover_copy.accent_color && (
|
||||
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
|
||||
accent: <code>{slate.cover_copy.accent_color}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 본문 카피 8장 (2~9/10) */}
|
||||
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">📝 본문 8장 (2~9/10)</div>
|
||||
{slate.body_copies.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||||
padding: '10px 0',
|
||||
}}
|
||||
>
|
||||
<strong>{i + 2}. {b?.headline || ''}</strong>
|
||||
{b?.body && (
|
||||
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||
{b.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA 카피 (10/10) */}
|
||||
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
|
||||
<div className="ic-caption-text">
|
||||
<strong>{slate.cta_copy.headline}</strong>
|
||||
{slate.cta_copy.body && (
|
||||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||
{slate.cta_copy.body}
|
||||
</div>
|
||||
)}
|
||||
{slate.cta_copy.cta && (
|
||||
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
|
||||
CTA: {slate.cta_copy.cta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlateDetail;
|
||||
102
src/pages/insta/components/SlatesPanel.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import SlateDetail from './SlateDetail';
|
||||
import { fmtDate } from '../instaUtils';
|
||||
import { getInstaSlates, getInstaSlate, getInstaAssetUrl, renderInstaSlate, deleteInstaSlate } from '../../../api';
|
||||
|
||||
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||||
function SlatesPanel({ selectedId, onSelect }) {
|
||||
const [slates, setSlates] = useState([]);
|
||||
const [detail, setDetail] = useState(null);
|
||||
|
||||
const loadSlates = useCallback(() => {
|
||||
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadSlates(); }, [loadSlates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setDetail(null); return; }
|
||||
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
||||
}, [selectedId]);
|
||||
|
||||
function handleSelect(id) {
|
||||
onSelect(id === selectedId ? null : id);
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await deleteInstaSlate(id);
|
||||
if (selectedId === id) onSelect(null);
|
||||
loadSlates();
|
||||
} catch (e) {
|
||||
alert('삭제 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender(id) {
|
||||
try {
|
||||
const res = await renderInstaSlate(id);
|
||||
// Re-render is fire-and-forget from the panel; user can refresh detail
|
||||
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
||||
setTimeout(loadSlates, 3000);
|
||||
} catch (e) {
|
||||
alert('재렌더 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ic-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||||
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||||
</div>
|
||||
|
||||
{slates.length === 0 ? (
|
||||
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||||
) : (
|
||||
<div className="ic-slates-grid">
|
||||
{slates.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||||
onClick={() => handleSelect(s.id)}
|
||||
>
|
||||
{s.status === 'rendered' || s.status === 'sent' ? (
|
||||
<img
|
||||
className="ic-slate-thumb"
|
||||
src={getInstaAssetUrl(s.id, 1)}
|
||||
alt={s.keyword}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||||
)}
|
||||
<div className="ic-slate-card__info">
|
||||
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||||
<div className="ic-slate-card__meta">
|
||||
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 슬레이트 상세 */}
|
||||
{detail && (
|
||||
<SlateDetail
|
||||
slate={detail}
|
||||
onDelete={() => handleDelete(detail.id)}
|
||||
onRender={() => handleRender(detail.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlatesPanel;
|
||||
11
src/pages/insta/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
return (
|
||||
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
||||
{status || 'draft'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBadge;
|
||||
20
src/pages/insta/components/TaskStatusBox.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
/* ────────────────────── TaskStatusBox ────────────────────── */
|
||||
function TaskStatusBox({ task }) {
|
||||
if (!task) return null;
|
||||
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
|
||||
return (
|
||||
<div className="ic-task-status">
|
||||
<div className="ic-task-status__label">
|
||||
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
||||
</div>
|
||||
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
||||
<div className="ic-task-status__progress">
|
||||
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskStatusBox;
|
||||
59
src/pages/insta/components/TriggerPanel.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import usePollTask from '../hooks/usePollTask';
|
||||
import TaskStatusBox from './TaskStatusBox';
|
||||
import { instaCollectNews, instaExtractKeywords } from '../../../api';
|
||||
|
||||
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||||
function TriggerPanel() {
|
||||
const collectPoll = usePollTask();
|
||||
const keywordsPoll = usePollTask();
|
||||
|
||||
async function handleCollect() {
|
||||
try {
|
||||
const res = await instaCollectNews();
|
||||
collectPoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('뉴스 수집 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeywords() {
|
||||
try {
|
||||
const res = await instaExtractKeywords();
|
||||
keywordsPoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('키워드 추출 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const collectBusy = !!collectPoll.taskId;
|
||||
const kwBusy = !!keywordsPoll.taskId;
|
||||
|
||||
return (
|
||||
<div className="ic-section">
|
||||
<p className="ic-section__title">트리거</p>
|
||||
<div className="ic-trigger-buttons">
|
||||
<button
|
||||
className="ic-btn ic-btn--primary"
|
||||
onClick={handleCollect}
|
||||
disabled={collectBusy}
|
||||
>
|
||||
{collectBusy && <span className="ic-spinner" />}
|
||||
뉴스 수집
|
||||
</button>
|
||||
<TaskStatusBox task={collectPoll.task} />
|
||||
<button
|
||||
className="ic-btn ic-btn--secondary"
|
||||
onClick={handleKeywords}
|
||||
disabled={kwBusy}
|
||||
>
|
||||
{kwBusy && <span className="ic-spinner" />}
|
||||
키워드 추출
|
||||
</button>
|
||||
<TaskStatusBox task={keywordsPoll.task} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TriggerPanel;
|
||||
41
src/pages/insta/hooks/usePollTask.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getInstaTask } from '../../../api';
|
||||
|
||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||
export default function usePollTask(onDone) {
|
||||
const [taskId, setTaskId] = useState(null);
|
||||
const [task, setTask] = useState(null);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const t = await getInstaTask(taskId);
|
||||
if (cancelled) return;
|
||||
setTask(t);
|
||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||
setTaskId(null);
|
||||
onDone?.(t);
|
||||
} else {
|
||||
timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer.current);
|
||||
};
|
||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
taskId,
|
||||
task,
|
||||
start: setTaskId,
|
||||
clear: () => { setTaskId(null); setTask(null); },
|
||||
};
|
||||
}
|
||||
10
src/pages/insta/instaUtils.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/pages/insta/instaUtils.js
|
||||
export function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
15
src/pages/insta/instaUtils.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fmtDate } from './instaUtils';
|
||||
|
||||
describe('instaUtils.fmtDate', () => {
|
||||
it('returns empty string for falsy input', () => {
|
||||
expect(fmtDate('')).toBe('');
|
||||
expect(fmtDate(null)).toBe('');
|
||||
expect(fmtDate(undefined)).toBe('');
|
||||
});
|
||||
it('returns a non-empty string for a valid ISO date (locale/tz-dependent format)', () => {
|
||||
const out = fmtDate('2026-07-10T09:30:00Z');
|
||||
expect(typeof out).toBe('string');
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
65
src/pages/listings/Listings.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* 실매물 매물·안전마진 (lst-*) — Subscription 다크 토큰 재사용 */
|
||||
.lst { display: grid; gap: 18px; padding: 20px; max-width: 1100px; margin: 0 auto; }
|
||||
.lst-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.lst-kicker { margin: 0 0 4px; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: #f43f5e; }
|
||||
.lst-header h1 { margin: 0; font-size: 24px; color: var(--text-bright); }
|
||||
.lst-sub { margin: 6px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.lst-tabbar { display: flex; gap: 6px; border-bottom: 1px solid var(--line); }
|
||||
.lst-tabbtn { border: none; background: none; color: var(--text-dim); padding: 10px 16px; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
|
||||
.lst-tabbtn.is-active { color: var(--text-bright); border-bottom-color: #f43f5e; }
|
||||
|
||||
.lst-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.lst-toolbar__right { display: flex; gap: 6px; }
|
||||
.lst-filter { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.lst-filter-btn { border: 1px solid var(--line); background: var(--surface); color: var(--text-dim); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; text-decoration: none; }
|
||||
.lst-filter-btn.is-active { color: var(--text-bright); border-color: #f43f5e; }
|
||||
.lst-filter-btn.is-primary { color: #fff; background: #f43f5e; border-color: #f43f5e; }
|
||||
.lst-filter-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.lst-status { font-size: 12px; color: var(--text-muted); margin: 0; }
|
||||
.lst-error { color: #f9b6b1; border: 1px solid rgba(249,182,177,0.4); border-radius: 12px; padding: 10px; background: rgba(249,182,177,0.1); margin: 0; font-size: 13px; }
|
||||
.lst-ok { color: #34d399; font-size: 12px; }
|
||||
.lst-empty { color: var(--text-muted); text-align: center; padding: 32px 0; }
|
||||
|
||||
.lst-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.lst-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px 14px; display: grid; gap: 6px; }
|
||||
.lst-card__head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.lst-tier { font-size: 12px; font-weight: 700; }
|
||||
.lst-card__name { font-size: 14px; color: var(--text-bright); }
|
||||
.lst-card__deal { font-size: 11px; color: #60a5fa; }
|
||||
.lst-card__dong { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-card__body { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
|
||||
.lst-card__price { color: #f59e0b; font-weight: 600; }
|
||||
.lst-card__reasons { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-flags { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.lst-flag { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(244,63,94,0.12); color: #f87171; }
|
||||
|
||||
.lst-tools { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
.lst-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 16px; display: grid; gap: 10px; }
|
||||
.lst-panel__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
|
||||
.lst-panel__actions { display: flex; gap: 8px; align-items: center; }
|
||||
.lst-panel__title { margin: 0; font-size: 15px; color: var(--text-bright); }
|
||||
.lst-panel__sub { margin: 0; font-size: 12px; color: var(--text-muted); }
|
||||
.lst-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.lst-input { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: inherit; font-size: 13px; min-width: 100px; flex: 1 1 100px; }
|
||||
.lst-check { font-size: 12px; color: var(--text-dim); display: inline-flex; gap: 4px; align-items: center; }
|
||||
.lst-chks { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.lst-result { display: grid; gap: 8px; padding-top: 6px; border-top: 1px solid var(--line); }
|
||||
.lst-result__row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; align-items: center; }
|
||||
.lst-result__block { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; font-size: 13px; }
|
||||
.lst-result__block strong { color: var(--text-bright); }
|
||||
.lst-note { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-disclaimer { font-size: 11px; color: #cbd5e1; margin: 0; line-height: 1.45; }
|
||||
|
||||
.lst-criteria { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.lst-field { display: grid; gap: 4px; font-size: 12px; color: var(--text-dim); }
|
||||
.lst-field > span { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-field--chks { grid-column: 1 / -1; display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.lst-foot-disclaimer { font-size: 11px; color: #64748b; text-align: center; margin: 8px 0 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lst { padding: 12px; }
|
||||
.lst-tools { grid-template-columns: 1fr; }
|
||||
}
|
||||
46
src/pages/listings/Listings.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ListingsTab from './components/ListingsTab';
|
||||
import ToolsTab from './components/ToolsTab';
|
||||
import CriteriaTab from './components/CriteriaTab';
|
||||
import './Listings.css';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'listings', label: '매물' },
|
||||
{ id: 'tools', label: '판정 도구' },
|
||||
{ id: 'criteria', label: '조건' },
|
||||
];
|
||||
|
||||
const Listings = () => {
|
||||
const [tab, setTab] = useState('listings');
|
||||
return (
|
||||
<div className="lst">
|
||||
<header className="lst-header">
|
||||
<div>
|
||||
<p className="lst-kicker">실매물 · 안전마진</p>
|
||||
<h1>매물 · 안전마진</h1>
|
||||
<p className="lst-sub">매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.</p>
|
||||
</div>
|
||||
<Link className="lst-filter-btn" to="/realestate">청약으로 →</Link>
|
||||
</header>
|
||||
|
||||
<div className="lst-tabbar">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} type="button"
|
||||
className={`lst-tabbtn ${tab === t.id ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'listings' && <ListingsTab />}
|
||||
{tab === 'tools' && <ToolsTab />}
|
||||
{tab === 'criteria' && <CriteriaTab />}
|
||||
|
||||
<p className="lst-foot-disclaimer">
|
||||
※ 모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Listings;
|
||||
19
src/pages/listings/Listings.test.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('./components/ListingsTab.jsx', () => ({ default: () => <div>LISTINGS_TAB</div> }));
|
||||
vi.mock('./components/ToolsTab.jsx', () => ({ default: () => <div>TOOLS_TAB</div> }));
|
||||
vi.mock('./components/CriteriaTab.jsx', () => ({ default: () => <div>CRITERIA_TAB</div> }));
|
||||
vi.mock('react-router-dom', () => ({ Link: ({ children }) => <a>{children}</a> }));
|
||||
import Listings from './Listings.jsx';
|
||||
|
||||
describe('Listings', () => {
|
||||
it('기본 매물 탭 렌더 + 탭 전환', () => {
|
||||
render(<Listings />);
|
||||
expect(screen.getByText('LISTINGS_TAB')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '판정 도구' }));
|
||||
expect(screen.getByText('TOOLS_TAB')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '조건' }));
|
||||
expect(screen.getByText('CRITERIA_TAB')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
148
src/pages/listings/components/CriteriaTab.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getListingsCriteria, putListingsCriteria } from '../../../api';
|
||||
|
||||
const DEAL_TYPES = ['전세', '반전세', '매매'];
|
||||
const HOUSE_TYPES = ['아파트', '오피스텔', '빌라'];
|
||||
const SAFETY_TIERS = ['', '안전', '주의', '위험'];
|
||||
|
||||
const toList = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const numOrNull = (v) => (v === '' || v == null ? null : Number(v));
|
||||
|
||||
const CriteriaTab = () => {
|
||||
const [c, setC] = useState(null); // 편집 중 폼 상태 (dongs/house_types는 문자열/배열)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getListingsCriteria();
|
||||
setC({ ...data, dongs: (data.dongs ?? []).join(', ') });
|
||||
} catch (e) {
|
||||
setMsg('불러오기 실패: ' + (e?.message ?? e));
|
||||
} finally { setLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const set = (k, v) => setC((prev) => ({ ...prev, [k]: v }));
|
||||
const toggleArr = (k, val) => setC((prev) => {
|
||||
const arr = Array.isArray(prev[k]) ? prev[k] : [];
|
||||
return { ...prev, [k]: arr.includes(val) ? arr.filter((x) => x !== val) : [...arr, val] };
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
const payload = {
|
||||
dongs: toList(c.dongs),
|
||||
deal_types: c.deal_types ?? [],
|
||||
house_types: c.house_types ?? [],
|
||||
max_deposit: numOrNull(c.max_deposit),
|
||||
max_sale_price: numOrNull(c.max_sale_price),
|
||||
min_area: numOrNull(c.min_area),
|
||||
equity: numOrNull(c.equity),
|
||||
annual_income: numOrNull(c.annual_income),
|
||||
min_safety_tier: c.min_safety_tier || null,
|
||||
notify_enabled: c.notify_enabled ? 1 : 0,
|
||||
is_homeless: c.is_homeless ? 1 : 0,
|
||||
is_householder: c.is_householder ? 1 : 0,
|
||||
is_first_home: c.is_first_home ? 1 : 0,
|
||||
};
|
||||
const updated = await putListingsCriteria(payload);
|
||||
setC({ ...updated, dongs: (updated.dongs ?? []).join(', ') });
|
||||
setMsg('저장 완료');
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
} catch (e) {
|
||||
setMsg('저장 실패: ' + (e?.message ?? e));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <p className="lst-empty">불러오는 중…</p>;
|
||||
if (!c) return <p className="lst-error">{msg || '조건을 불러오지 못했습니다.'}</p>;
|
||||
|
||||
return (
|
||||
<section className="lst-panel">
|
||||
<div className="lst-panel__head">
|
||||
<div>
|
||||
<h3 className="lst-panel__title">매물 매칭 조건</h3>
|
||||
<p className="lst-panel__sub">자기자본·연소득을 넣어야 매매 예산상한·전세 max_deposit이 매칭에 반영됩니다.</p>
|
||||
</div>
|
||||
<div className="lst-panel__actions">
|
||||
{msg && <span className={msg.startsWith('저장 완료') ? 'lst-ok' : 'lst-error'}>{msg}</span>}
|
||||
<button className="lst-filter-btn is-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lst-criteria">
|
||||
<label className="lst-field">
|
||||
<span>관심 동 (쉼표 구분)</span>
|
||||
<input className="lst-input" value={c.dongs} onChange={(e) => set('dongs', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div className="lst-field">
|
||||
<span>거래 유형</span>
|
||||
<div className="lst-chks">
|
||||
{DEAL_TYPES.map((d) => (
|
||||
<label key={d} className="lst-check"><input type="checkbox"
|
||||
checked={(c.deal_types ?? []).includes(d)} onChange={() => toggleArr('deal_types', d)} /> {d}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lst-field">
|
||||
<span>주택 유형</span>
|
||||
<div className="lst-chks">
|
||||
{HOUSE_TYPES.map((h) => (
|
||||
<label key={h} className="lst-check"><input type="checkbox"
|
||||
checked={(c.house_types ?? []).includes(h)} onChange={() => toggleArr('house_types', h)} /> {h}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="lst-field">
|
||||
<span>전세 최대 보증금 (만원)</span>
|
||||
<input className="lst-input" value={c.max_deposit ?? ''} onChange={(e) => set('max_deposit', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>매매 예산 상한 (만원)</span>
|
||||
<input className="lst-input" value={c.max_sale_price ?? ''} onChange={(e) => set('max_sale_price', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>최소 면적 (m²)</span>
|
||||
<input className="lst-input" value={c.min_area ?? ''} onChange={(e) => set('min_area', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>자기자본 (만원)</span>
|
||||
<input className="lst-input" value={c.equity ?? ''} onChange={(e) => set('equity', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>연소득 (만원)</span>
|
||||
<input className="lst-input" value={c.annual_income ?? ''} onChange={(e) => set('annual_income', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>최소 안전등급</span>
|
||||
<select className="lst-input" value={c.min_safety_tier ?? ''} onChange={(e) => set('min_safety_tier', e.target.value)}>
|
||||
{SAFETY_TIERS.map((t) => <option key={t} value={t}>{t || '제한 없음'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="lst-field lst-field--chks">
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.is_homeless}
|
||||
onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.is_householder}
|
||||
onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.is_first_home}
|
||||
onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.notify_enabled}
|
||||
onChange={(e) => set('notify_enabled', e.target.checked)} /> 텔레그램 알림</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CriteriaTab;
|
||||
31
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({ getListingsCriteria: vi.fn(), putListingsCriteria: vi.fn() }));
|
||||
import { getListingsCriteria } from '../../../api';
|
||||
import CriteriaTab from './CriteriaTab.jsx';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getListingsCriteria.mockResolvedValue({
|
||||
id: 1, dongs: ['신대방동', '상도동'], deal_types: ['전세', '매매'], max_deposit: 32000,
|
||||
max_sale_price: null, min_area: 40, house_types: ['아파트'], min_safety_tier: null,
|
||||
notify_enabled: 1, equity: null, annual_income: null, is_homeless: 1, is_householder: 0, is_first_home: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('CriteriaTab', () => {
|
||||
it('criteria를 로드해 자기자본·연소득 입력과 동 목록을 렌더', async () => {
|
||||
render(<CriteriaTab />);
|
||||
await waitFor(() => expect(screen.getByLabelText(/자기자본/)).toBeInTheDocument());
|
||||
expect(screen.getByLabelText(/연소득/)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('신대방동, 상도동')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('로드 실패 시 무한 로딩 대신 에러를 표시', async () => {
|
||||
getListingsCriteria.mockRejectedValue(new Error('HTTP 500'));
|
||||
render(<CriteriaTab />);
|
||||
await waitFor(() => expect(screen.getByText(/불러오지 못했|HTTP 500/)).toBeInTheDocument());
|
||||
expect(screen.queryByText('불러오는 중…')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
138
src/pages/listings/components/ListingsTab.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getListings, collectListings, getListingsCollectStatus } from '../../../api';
|
||||
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';
|
||||
|
||||
const DEAL_TYPES = ['전체', '전세', '반전세', '매매'];
|
||||
|
||||
const ListingCard = ({ l }) => {
|
||||
const isSale = l.deal_type === '매매';
|
||||
const tv = isSale ? l.valuation_tier : l.safety_tier;
|
||||
const meta = tierMeta(isSale ? 'valuation' : 'safety', tv);
|
||||
const price = l.price ?? l.deposit ?? l.amount;
|
||||
const flags = Array.isArray(l.regulation_flags) ? l.regulation_flags : [];
|
||||
const reasons = Array.isArray(l.reasons) ? l.reasons : [];
|
||||
return (
|
||||
<div className="lst-card">
|
||||
<div className="lst-card__head">
|
||||
{tv && <span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>}
|
||||
<strong className="lst-card__name">{l.complex_name || l.name || l.dong || '(매물)'}</strong>
|
||||
{l.deal_type && <span className="lst-card__deal">{l.deal_type}</span>}
|
||||
{l.dong && <span className="lst-card__dong">{l.dong}</span>}
|
||||
</div>
|
||||
<div className="lst-card__body">
|
||||
{price != null && <span className="lst-card__price">{formatMoney(price)}</span>}
|
||||
{l.area != null && <span className="lst-card__area">{l.area}m²</span>}
|
||||
{l.ratio != null && <span className="lst-card__ratio">비율 {formatRatio(l.ratio)}</span>}
|
||||
</div>
|
||||
{flags.length > 0 && (
|
||||
<div className="lst-flags">{flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||
)}
|
||||
{reasons.length > 0 && <div className="lst-card__reasons">{reasons.join(' · ')}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListingsTab = () => {
|
||||
const [listings, setListings] = useState([]);
|
||||
const [dealType, setDealType] = useState('전체');
|
||||
const [matchedOnly, setMatchedOnly] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [collectStatus, setCollectStatus] = useState(null);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getListings({
|
||||
deal_type: dealType === '전체' ? undefined : dealType,
|
||||
matched_only: matchedOnly,
|
||||
size: 50,
|
||||
});
|
||||
setListings(Array.isArray(data) ? data : (data.listings ?? []));
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
setListings([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
try { setCollectStatus(await getListingsCollectStatus()); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [dealType, matchedOnly]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { loadStatus(); }, []);
|
||||
|
||||
const handleCollect = async () => {
|
||||
setCollecting(true);
|
||||
setError('');
|
||||
const prevAt = collectStatus?.collected_at ?? null;
|
||||
try {
|
||||
await collectListings();
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
setCollecting(false);
|
||||
return;
|
||||
}
|
||||
let tries = 0;
|
||||
const poll = async () => {
|
||||
tries += 1;
|
||||
let st = null;
|
||||
try { st = await getListingsCollectStatus(); } catch { /* noop */ }
|
||||
if (st) setCollectStatus(st);
|
||||
const done = st && (st.collected_at !== prevAt || st.error);
|
||||
if (done || tries >= 12) {
|
||||
setCollecting(false);
|
||||
load();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(poll, 5000);
|
||||
};
|
||||
window.setTimeout(poll, 5000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lst-tab">
|
||||
<div className="lst-toolbar">
|
||||
<div className="lst-filter">
|
||||
{DEAL_TYPES.map((d) => (
|
||||
<button key={d} type="button" className={`lst-filter-btn ${dealType === d ? 'is-active' : ''}`}
|
||||
onClick={() => setDealType(d)}>{d}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="lst-toolbar__right">
|
||||
<button type="button" className={`lst-filter-btn ${matchedOnly ? 'is-active' : ''}`}
|
||||
onClick={() => setMatchedOnly((v) => !v)}>매칭만</button>
|
||||
<button type="button" className="lst-filter-btn is-primary" onClick={handleCollect} disabled={collecting}>
|
||||
{collecting ? '수집 중…' : '수집 실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collectStatus && (
|
||||
<p className="lst-status">
|
||||
수집 상태: {collectStatus.status === 'never_run' ? '미실행' : collectStatus.status}
|
||||
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
|
||||
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="lst-error">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="lst-empty">불러오는 중…</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="lst-empty">조건에 맞는 매물이 없습니다. ‘수집 실행’으로 매물을 모아보세요.</p>
|
||||
) : (
|
||||
<div className="lst-grid">
|
||||
{listings.map((l) => <ListingCard key={l.id ?? `${l.complex_name}-${l.dong}`} l={l} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingsTab;
|
||||
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getListings: vi.fn(),
|
||||
collectListings: vi.fn(),
|
||||
getListingsCollectStatus: vi.fn(),
|
||||
}));
|
||||
import { getListings, getListingsCollectStatus } from '../../../api';
|
||||
import ListingsTab from './ListingsTab.jsx';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getListingsCollectStatus.mockResolvedValue({ status: 'never_run' });
|
||||
});
|
||||
|
||||
describe('ListingsTab', () => {
|
||||
it('빈 목록이면 안내 문구', async () => {
|
||||
getListings.mockResolvedValue({ listings: [] });
|
||||
render(<ListingsTab />);
|
||||
await waitFor(() => expect(screen.getByText(/매물이 없습니다/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('매물+판정 tier·배열 필드를 크래시 없이 렌더 (매매=valuation, 배열 그대로)', async () => {
|
||||
getListings.mockResolvedValue({ listings: [{
|
||||
id: 1, complex_name: '상도더샵', dong: '상도동', deal_type: '매매',
|
||||
price: 95000, valuation_tier: '고가', ratio: 1.08,
|
||||
regulation_flags: ['토지거래허가'], reasons: ['호가가 최근 실거래 대비 8% 높음'],
|
||||
}] });
|
||||
render(<ListingsTab />);
|
||||
await waitFor(() => expect(screen.getByText('상도더샵')).toBeInTheDocument());
|
||||
expect(screen.getByText(/고가/)).toBeInTheDocument();
|
||||
expect(screen.getByText('토지거래허가')).toBeInTheDocument();
|
||||
expect(screen.getByText(/호가가 최근 실거래/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
161
src/pages/listings/components/ToolsTab.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { safetyCheck, budgetCalc } from '../../../api';
|
||||
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';
|
||||
|
||||
const DEAL_TYPES = ['전세', '반전세', '매매'];
|
||||
|
||||
/* ── 안전마진 체커 ── */
|
||||
const SafetyCard = () => {
|
||||
const [form, setForm] = useState({ area: '', deal_type: '전세', amount: '', dong: '' });
|
||||
const [res, setRes] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.area || !form.amount) return;
|
||||
setLoading(true); setError(''); setRes(null);
|
||||
try {
|
||||
const r = await safetyCheck({
|
||||
area: Number(form.area), deal_type: form.deal_type,
|
||||
amount: Number(form.amount), dong: form.dong || undefined,
|
||||
});
|
||||
setRes(r);
|
||||
} catch (e2) { setError(e2?.message ?? String(e2)); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const isSale = form.deal_type === '매매';
|
||||
const meta = res ? tierMeta(isSale ? 'valuation' : 'safety', res.tier) : null;
|
||||
|
||||
return (
|
||||
<section className="lst-panel">
|
||||
<h3 className="lst-panel__title">안전마진 체커</h3>
|
||||
<p className="lst-panel__sub">면적·거래유형·금액으로 전세가율/호가율 적정성을 즉시 판정합니다.</p>
|
||||
<form className="lst-form" onSubmit={submit}>
|
||||
<input className="lst-input" placeholder="면적(m²)" value={form.area}
|
||||
onChange={(e) => set('area', e.target.value)} />
|
||||
<select className="lst-input" value={form.deal_type} onChange={(e) => set('deal_type', e.target.value)}>
|
||||
{DEAL_TYPES.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<input className="lst-input" placeholder="금액(만원)" value={form.amount}
|
||||
onChange={(e) => set('amount', e.target.value)} />
|
||||
<input className="lst-input" placeholder="동(선택)" value={form.dong}
|
||||
onChange={(e) => set('dong', e.target.value)} />
|
||||
<button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.area || !form.amount}>
|
||||
{loading ? '판정 중…' : '판정'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="lst-error">{error}</p>}
|
||||
{res && (
|
||||
<div className="lst-result">
|
||||
<div className="lst-result__row">
|
||||
<span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>
|
||||
{res.ratio != null && <span>비율 {formatRatio(res.ratio)}</span>}
|
||||
{res.median != null && <span>중앙값 {formatMoney(res.median)}</span>}
|
||||
{res.sample != null && <span>표본 {res.sample}건</span>}
|
||||
{res.is_toheo && <span className="lst-flag">토지거래허가</span>}
|
||||
</div>
|
||||
{res.disclaimer && <p className="lst-disclaimer">※ {res.disclaimer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 예산 계산기 ── */
|
||||
const BudgetCard = () => {
|
||||
const [form, setForm] = useState({
|
||||
equity: '', annual_income: '', is_homeless: true, is_householder: true, is_first_home: false, target_dong: '',
|
||||
});
|
||||
const [res, setRes] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.equity) return;
|
||||
setLoading(true); setError(''); setRes(null);
|
||||
try {
|
||||
const r = await budgetCalc({
|
||||
equity: Number(form.equity),
|
||||
annual_income: form.annual_income ? Number(form.annual_income) : null,
|
||||
is_homeless: form.is_homeless ? 1 : 0,
|
||||
is_householder: form.is_householder ? 1 : 0,
|
||||
is_first_home: form.is_first_home ? 1 : 0,
|
||||
target_dong: form.target_dong || undefined,
|
||||
});
|
||||
setRes(r);
|
||||
} catch (e2) { setError(e2?.message ?? String(e2)); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="lst-panel">
|
||||
<h3 className="lst-panel__title">예산 계산기</h3>
|
||||
<p className="lst-panel__sub">자기자본·소득 기준 전세/매매 한도와 지역 규제를 계산합니다.</p>
|
||||
<form className="lst-form" onSubmit={submit}>
|
||||
<input className="lst-input" placeholder="자기자본(만원)" value={form.equity}
|
||||
onChange={(e) => set('equity', e.target.value)} />
|
||||
<input className="lst-input" placeholder="연소득(만원, 선택)" value={form.annual_income}
|
||||
onChange={(e) => set('annual_income', e.target.value)} />
|
||||
<input className="lst-input" placeholder="대상 동(선택)" value={form.target_dong}
|
||||
onChange={(e) => set('target_dong', e.target.value)} />
|
||||
<label className="lst-check"><input type="checkbox" checked={form.is_homeless}
|
||||
onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={form.is_householder}
|
||||
onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={form.is_first_home}
|
||||
onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
|
||||
<button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.equity}>
|
||||
{loading ? '계산 중…' : '계산'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="lst-error">{error}</p>}
|
||||
{res && (
|
||||
<div className="lst-result">
|
||||
{res.jeonse && (
|
||||
<div className="lst-result__block">
|
||||
<strong>전세</strong>
|
||||
<span>대출한도 {formatMoney(res.jeonse.loan_limit)}</span>
|
||||
<span>최대 보증금 {formatMoney(res.jeonse.max_deposit)}</span>
|
||||
{res.jeonse.notes && <span className="lst-note">{res.jeonse.notes}</span>}
|
||||
</div>
|
||||
)}
|
||||
{res.purchase && (
|
||||
<div className="lst-result__block">
|
||||
<strong>매매</strong>
|
||||
{res.purchase.ltv_pct != null && <span>LTV {res.purchase.ltv_pct}%</span>}
|
||||
<span>대출한도 {formatMoney(res.purchase.loan_cap)}</span>
|
||||
<span>최대 매수가 {formatMoney(res.purchase.max_price)}</span>
|
||||
{res.purchase.dsr_note && <span className="lst-note">{res.purchase.dsr_note}</span>}
|
||||
{Array.isArray(res.purchase.regulation_flags) && res.purchase.regulation_flags.length > 0 && (
|
||||
<div className="lst-flags">{res.purchase.regulation_flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{res.region && (
|
||||
<div className="lst-result__block">
|
||||
<strong>지역</strong>
|
||||
{res.region.is_toheo && <span className="lst-flag">토지거래허가</span>}
|
||||
{res.region.is_regulated && <span className="lst-flag">규제지역</span>}
|
||||
{res.region.notes && <span className="lst-note">{res.region.notes}</span>}
|
||||
</div>
|
||||
)}
|
||||
{res.disclaimer && <p className="lst-disclaimer">※ {res.disclaimer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolsTab = () => (
|
||||
<div className="lst-tools">
|
||||
<SafetyCard />
|
||||
<BudgetCard />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ToolsTab;
|
||||
44
src/pages/listings/components/ToolsTab.test.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() }));
|
||||
import { safetyCheck, budgetCalc } from '../../../api';
|
||||
import ToolsTab from './ToolsTab.jsx';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ToolsTab', () => {
|
||||
it('안전마진·예산 카드 헤딩과 disclaimer 안내를 렌더', () => {
|
||||
render(<ToolsTab />);
|
||||
expect(screen.getByText('안전마진 체커')).toBeInTheDocument();
|
||||
expect(screen.getByText('예산 계산기')).toBeInTheDocument();
|
||||
// 제출 버튼 존재
|
||||
expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('안전마진 제출 → tier·비율·disclaimer 렌더 (객체 아님)', async () => {
|
||||
safetyCheck.mockResolvedValue({ median: 45000, ratio: 0.72, tier: '안전', sample: 7, is_toheo: false, disclaimer: '등기부 선순위·근사치 주의' });
|
||||
render(<ToolsTab />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/면적/), { target: { value: '84' } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/금액/), { target: { value: '45000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '판정' }));
|
||||
await waitFor(() => expect(screen.getByText(/등기부 선순위/)).toBeInTheDocument());
|
||||
// "안전마진 체커" 헤딩과 구분하기 위해 "마진"이 뒤따르지 않는 "안전"만 매칭
|
||||
expect(screen.getByText(/안전(?!마진)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('예산 제출 → 전세/매매 금액·disclaimer 렌더 (중첩 객체 필드별 렌더)', async () => {
|
||||
budgetCalc.mockResolvedValue({
|
||||
jeonse: { loan_limit: 30000, max_deposit: 40000, notes: '' },
|
||||
purchase: { ltv_pct: 70, loan_cap: 50000, max_price: 90000, dsr_note: 'DSR 40%', regulation_flags: ['규제지역'] },
|
||||
region: { is_toheo: true, is_regulated: true, notes: '토허 구역' },
|
||||
disclaimer: '정책 수치는 근사치입니다',
|
||||
});
|
||||
render(<ToolsTab />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/자기자본/), { target: { value: '20000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '계산' }));
|
||||
await waitFor(() => expect(screen.getByText(/정책 수치는 근사치/)).toBeInTheDocument());
|
||||
// purchase.regulation_flags 와 region.is_regulated 플래그 둘 다 "규제지역" 텍스트를 렌더함
|
||||
expect(screen.getAllByText('규제지역').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
40
src/pages/listings/listingsUtils.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/* 실매물 판정 tier / 금액 포맷 순수 헬퍼 */
|
||||
const C = { green: '#34d399', yellow: '#f59e0b', red: '#f87171', gray: '#94a3b8' };
|
||||
|
||||
export const SAFETY_TIER_META = Object.freeze({
|
||||
'안전': { emoji: '🟢', color: C.green },
|
||||
'주의': { emoji: '🟡', color: C.yellow },
|
||||
'위험': { emoji: '🔴', color: C.red },
|
||||
'보류': { emoji: '⚪', color: C.gray },
|
||||
});
|
||||
|
||||
export const VALUATION_TIER_META = Object.freeze({
|
||||
'저평가': { emoji: '🟢', color: C.green },
|
||||
'시세': { emoji: '🟡', color: C.yellow },
|
||||
'고가': { emoji: '🔴', color: C.red },
|
||||
'보류': { emoji: '⚪', color: C.gray },
|
||||
});
|
||||
|
||||
export const tierMeta = (kind, tier) => {
|
||||
const map = kind === 'valuation' ? VALUATION_TIER_META : SAFETY_TIER_META;
|
||||
if (Object.hasOwn(map, tier)) return { ...map[tier], label: tier };
|
||||
return { emoji: '⚪', color: C.gray, label: tier ?? '' };
|
||||
};
|
||||
|
||||
export const formatMoney = (manwon) => {
|
||||
if (manwon == null || manwon === '') return '-';
|
||||
const n = Number(manwon);
|
||||
if (Number.isNaN(n)) return '-';
|
||||
const eok = Math.floor(n / 10000);
|
||||
const rest = n % 10000;
|
||||
if (eok > 0 && rest > 0) return `${eok}억 ${rest.toLocaleString('ko-KR')}만`;
|
||||
if (eok > 0) return `${eok}억`;
|
||||
return `${n.toLocaleString('ko-KR')}만`;
|
||||
};
|
||||
|
||||
export const formatRatio = (ratio) => {
|
||||
if (ratio == null) return '-';
|
||||
const n = Number(ratio);
|
||||
if (Number.isNaN(n)) return '-';
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
};
|
||||
44
src/pages/listings/listingsUtils.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tierMeta, formatMoney, formatRatio } from './listingsUtils.js';
|
||||
|
||||
describe('tierMeta', () => {
|
||||
it('safety 4종 이모지·색·label', () => {
|
||||
expect(tierMeta('safety', '안전')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '안전' });
|
||||
expect(tierMeta('safety', '주의').emoji).toBe('🟡');
|
||||
expect(tierMeta('safety', '위험').color).toBe('#f87171');
|
||||
expect(tierMeta('safety', '보류').emoji).toBe('⚪');
|
||||
});
|
||||
it('valuation 4종', () => {
|
||||
expect(tierMeta('valuation', '저평가')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '저평가' });
|
||||
expect(tierMeta('valuation', '시세').emoji).toBe('🟡');
|
||||
expect(tierMeta('valuation', '고가').color).toBe('#f87171');
|
||||
});
|
||||
it('미정의 tier는 회색⚪ + 원문 label, undefined는 빈 label', () => {
|
||||
expect(tierMeta('safety', '없는거')).toMatchObject({ emoji: '⚪', color: '#94a3b8', label: '없는거' });
|
||||
expect(tierMeta('valuation', undefined).label).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMoney (만원 정밀 표기)', () => {
|
||||
it('만/억 경계', () => {
|
||||
expect(formatMoney(9999)).toBe('9,999만');
|
||||
expect(formatMoney(10000)).toBe('1억');
|
||||
expect(formatMoney(15000)).toBe('1억 5,000만');
|
||||
expect(formatMoney(32000)).toBe('3억 2,000만');
|
||||
});
|
||||
it('null/빈값/NaN → -', () => {
|
||||
expect(formatMoney(null)).toBe('-');
|
||||
expect(formatMoney('')).toBe('-');
|
||||
expect(formatMoney('abc')).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRatio', () => {
|
||||
it('비율 → 퍼센트 1자리', () => {
|
||||
expect(formatRatio(0.812)).toBe('81.2%');
|
||||
expect(formatRatio(1.05)).toBe('105.0%');
|
||||
});
|
||||
it('null → -', () => {
|
||||
expect(formatRatio(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,9 @@
|
||||
.winner-card .winner-meta strong { color: #f1f5f9; font-weight: 600; }
|
||||
.winner-card .winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; }
|
||||
|
||||
/* Backtest — WinnerAnalysisCard chart wrapper (standalone, not inside .winner-card) */
|
||||
.backtest-winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; }
|
||||
|
||||
/* TrialsGrid */
|
||||
.trials-grid .grid {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr);
|
||||
@@ -186,6 +189,47 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Backtest — TrackRecordCard */
|
||||
.backtest-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.backtest-table th {
|
||||
text-align: left;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.backtest-table td {
|
||||
padding: 6px 8px;
|
||||
color: #cbd5e1;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.backtest-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* Backtest — shared note */
|
||||
.backtest-note {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.backtest-note strong { color: #cbd5e1; }
|
||||
|
||||
/* Backtest — section divider */
|
||||
.backtest-section-header {
|
||||
margin: 8px 0 4px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; }
|
||||
.base-diff .diff-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
53
src/pages/lotto/evolver/CalibrationChart.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
export default function CalibrationChart({ history }) {
|
||||
if (!history || history.length === 0) {
|
||||
return (
|
||||
<div className="evolver-card backtest-calibration empty">
|
||||
<h2>당첨조합 캘리브레이션 추세</h2>
|
||||
<p className="muted">캘리브레이션 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// history는 DESC 순서로 오므로 역순해서 오름차순 x축
|
||||
const data = [...history].reverse().map((h) => ({
|
||||
draw: h.draw_no,
|
||||
score: h.score_total != null ? +h.score_total.toFixed(3) : null,
|
||||
pct: h.percentile != null ? +h.percentile.toFixed(3) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="evolver-card backtest-calibration">
|
||||
<h2>당첨조합 캘리브레이션 추세 (최근 {history.length}회차)</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="draw" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<YAxis domain={[0, 1]} tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid rgba(255,255,255,0.1)', color: '#e2e8f0' }} />
|
||||
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: '0.8rem' }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke="#f59e0b"
|
||||
dot={false}
|
||||
name="당첨조합 분석치"
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pct"
|
||||
stroke="#34d399"
|
||||
dot={false}
|
||||
name="무작위 percentile"
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/pages/lotto/evolver/TrackRecordCard.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
const STRATEGY_ORDER = ['engine_w', 'random_null', 'coverage'];
|
||||
const STRATEGY_LABEL = { engine_w: '엔진', random_null: '무작위', coverage: '커버리지' };
|
||||
|
||||
export default function TrackRecordCard({ byStrategy }) {
|
||||
if (!byStrategy) return null;
|
||||
|
||||
const rows = STRATEGY_ORDER.filter((s) => byStrategy[s]);
|
||||
|
||||
return (
|
||||
<div className="evolver-card backtest-track-record">
|
||||
<h2>누적 성적표</h2>
|
||||
{rows.length === 0 ? (
|
||||
<p className="backtest-note">아직 백테스트 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<>
|
||||
<table className="backtest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>전략</th>
|
||||
<th>누적 장수</th>
|
||||
<th>회차수</th>
|
||||
<th>1등</th>
|
||||
<th>2등</th>
|
||||
<th>3등</th>
|
||||
<th>4등</th>
|
||||
<th>5등</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => {
|
||||
const a = byStrategy[s];
|
||||
return (
|
||||
<tr key={s}>
|
||||
<td>{STRATEGY_LABEL[s] || s}</td>
|
||||
<td>{(a.n_tickets || 0).toLocaleString()}</td>
|
||||
<td>{a.draws || 0}</td>
|
||||
<td>{a['1st'] || 0}</td>
|
||||
<td>{a['2nd'] || 0}</td>
|
||||
<td>{a['3rd'] || 0}</td>
|
||||
<td>{a['4th'] || 0}</td>
|
||||
<td>{a['5th'] || 0}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="backtest-note">
|
||||
엔진이 무작위를 넘지 못하면 분석에 통계적 우위가 없다는 정직한 증거입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/pages/lotto/evolver/WinnerAnalysisCard.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
|
||||
Radar, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
export default function WinnerAnalysisCard({ analysis }) {
|
||||
if (!analysis) return null;
|
||||
|
||||
const data = [
|
||||
{ k: '빈도', v: analysis.score_frequency ?? 0 },
|
||||
{ k: '지문', v: analysis.score_fingerprint ?? 0 },
|
||||
{ k: '갭', v: analysis.score_gap ?? 0 },
|
||||
{ k: '공동출현', v: analysis.score_cooccur ?? 0 },
|
||||
{ k: '다양성', v: analysis.score_diversity ?? 0 },
|
||||
];
|
||||
|
||||
const pct = analysis.percentile != null
|
||||
? `${(analysis.percentile * 100).toFixed(0)}%`
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="evolver-card backtest-winner-analysis">
|
||||
<h2>
|
||||
이번 당첨조합 분석치
|
||||
<span className="badge">무작위 상위 {pct}</span>
|
||||
</h2>
|
||||
<div className="backtest-winner-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.12)" />
|
||||
<PolarAngleAxis dataKey="k" tick={{ fill: '#cbd5e1', fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 1]} tick={{ fill: '#64748b', fontSize: 10 }} />
|
||||
<Radar
|
||||
name="분석치"
|
||||
dataKey="v"
|
||||
stroke="#60a5fa"
|
||||
fill="#60a5fa"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="backtest-note">
|
||||
종합 점수: <strong>{(analysis.score_total ?? 0).toFixed(3)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import '../Evolver.css';
|
||||
import { useEvolverApi } from '../evolver/useEvolverApi';
|
||||
import WinnerCard from '../evolver/WinnerCard';
|
||||
@@ -7,10 +7,40 @@ import BaseDiff from '../evolver/BaseDiff';
|
||||
import BaseHistory from '../evolver/BaseHistory';
|
||||
import LottoActivityTimeline from '../evolver/LottoActivityTimeline';
|
||||
import EvolverActions from '../evolver/EvolverActions';
|
||||
import TrackRecordCard from '../evolver/TrackRecordCard';
|
||||
import CalibrationChart from '../evolver/CalibrationChart';
|
||||
import WinnerAnalysisCard from '../evolver/WinnerAnalysisCard';
|
||||
import { getLatest, lottoBacktestTrackRecord, lottoBacktestCalibration, lottoBacktestReview } from '../../../api';
|
||||
|
||||
export default function EvolverTab() {
|
||||
const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 });
|
||||
|
||||
const [trackRecord, setTrackRecord] = useState(null);
|
||||
const [calibHistory, setCalibHistory] = useState([]);
|
||||
const [winnerAnalysis, setWinnerAnalysis] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [tr, cal] = await Promise.all([
|
||||
lottoBacktestTrackRecord(),
|
||||
lottoBacktestCalibration(52),
|
||||
]);
|
||||
setTrackRecord(tr);
|
||||
setCalibHistory(cal.history || []);
|
||||
} catch (_) { /* 백엔드 미준비 시 graceful skip */ }
|
||||
|
||||
try {
|
||||
const latest = await getLatest();
|
||||
const drawNo = latest?.drawNo || latest?.drw_no || latest?.draw_no;
|
||||
if (drawNo) {
|
||||
const review = await lottoBacktestReview(drawNo);
|
||||
setWinnerAnalysis(review.winner_analysis || null);
|
||||
}
|
||||
} catch (_) { /* 아직 데이터 없으면 null 유지 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="lotto-evolver"><p className="lotto-evolver-muted">로딩 중...</p></div>;
|
||||
if (error) return <div className="lotto-evolver"><p className="lotto-evolver-muted">에러: {String(error)}</p></div>;
|
||||
|
||||
@@ -73,6 +103,16 @@ export default function EvolverTab() {
|
||||
<EvolverActions onChange={refetch} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 백테스트 성적표 · 캘리브레이션 · 당첨조합 분석 */}
|
||||
{(winnerAnalysis || trackRecord || calibHistory.length > 0) && (
|
||||
<>
|
||||
<p className="backtest-section-header">백테스트 & 캘리브레이션</p>
|
||||
<WinnerAnalysisCard analysis={winnerAnalysis} />
|
||||
<TrackRecordCard byStrategy={trackRecord?.by_strategy} />
|
||||
<CalibrationChart history={calibHistory} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,761 +1,42 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Cell,
|
||||
} from 'recharts';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||
STATUS_CONFIG, TABS,
|
||||
} from './realEstateUtils';
|
||||
import useComplexes from './hooks/useComplexes';
|
||||
import ComplexCard from './components/ComplexCard';
|
||||
import RightPanel from './components/RightPanel';
|
||||
import ScheduleView from './components/ScheduleView';
|
||||
import PriceAnalysis from './components/PriceAnalysis';
|
||||
import ComplexModal from './components/ComplexModal';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './RealEstate.css';
|
||||
|
||||
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
||||
const SAMPLE_COMPLEXES = [
|
||||
{
|
||||
id: 1,
|
||||
name: '래미안 원베일리',
|
||||
address: '서울 서초구 반포동',
|
||||
lat: 37.5065,
|
||||
lng: 126.9942,
|
||||
units: 2990,
|
||||
types: ['59㎡', '84㎡', '114㎡'],
|
||||
avgPricePerPyeong: 9500,
|
||||
subscriptionStart: '2024-01-08',
|
||||
subscriptionEnd: '2024-01-10',
|
||||
resultDate: '2024-01-15',
|
||||
status: '완료',
|
||||
priority: 'high',
|
||||
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '올림픽파크 포레온',
|
||||
address: '서울 강동구 둔촌동',
|
||||
lat: 37.5284,
|
||||
lng: 127.1340,
|
||||
units: 12032,
|
||||
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
||||
avgPricePerPyeong: 3800,
|
||||
subscriptionStart: '2022-12-05',
|
||||
subscriptionEnd: '2022-12-07',
|
||||
resultDate: '2022-12-12',
|
||||
status: '완료',
|
||||
priority: 'high',
|
||||
tags: ['대단지', '역세권', '재건축'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '역대 최대 규모 재건축 단지.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '힐스테이트 동탄',
|
||||
address: '경기 화성시 동탄2신도시',
|
||||
lat: 37.2001,
|
||||
lng: 127.0724,
|
||||
units: 1534,
|
||||
types: ['59㎡', '74㎡', '84㎡'],
|
||||
avgPricePerPyeong: 1850,
|
||||
subscriptionStart: '2026-04-10',
|
||||
subscriptionEnd: '2026-04-12',
|
||||
resultDate: '2026-04-17',
|
||||
status: '청약예정',
|
||||
priority: 'normal',
|
||||
tags: ['동탄2', '신도시', 'SRT'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '롯데캐슬 마곡',
|
||||
address: '서울 강서구 마곡동',
|
||||
lat: 37.5626,
|
||||
lng: 126.8295,
|
||||
units: 868,
|
||||
types: ['59㎡', '84㎡'],
|
||||
avgPricePerPyeong: 4200,
|
||||
subscriptionStart: '2026-03-20',
|
||||
subscriptionEnd: '2026-03-22',
|
||||
resultDate: '2026-03-27',
|
||||
status: '청약중',
|
||||
priority: 'high',
|
||||
tags: ['마곡', '9호선', '공항철도'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '', address: '', lat: '', lng: '',
|
||||
units: '', types: '', avgPricePerPyeong: '',
|
||||
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||
status: '청약예정', priority: 'normal',
|
||||
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||
};
|
||||
|
||||
const TABS = ['목록', '일정', '분석'];
|
||||
|
||||
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
const formatPrice = (v) => {
|
||||
if (!v) return '-';
|
||||
return `${v.toLocaleString()}만원`;
|
||||
};
|
||||
|
||||
const getDDays = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
const target = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||
if (diff === 0) return 'D-Day';
|
||||
if (diff > 0) return `D-${diff}`;
|
||||
return `D+${Math.abs(diff)}`;
|
||||
};
|
||||
|
||||
const createMarkerIcon = (status, isSelected = false) => {
|
||||
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||
const size = isSelected ? 18 : 12;
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${cfg.color};
|
||||
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||
transition:all 0.2s;
|
||||
"></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -(size / 2 + 4)],
|
||||
});
|
||||
};
|
||||
|
||||
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
||||
const MapFlyTo = ({ position, zoom }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (position) {
|
||||
map.flyTo(position, zoom ?? 14, { duration: 1.0 });
|
||||
}
|
||||
}, [position, zoom, map]);
|
||||
return null;
|
||||
};
|
||||
|
||||
// ── 단지 카드 ──────────────────────────────────────────────────────────────────
|
||||
const ComplexCard = ({ complex, isSelected, onClick }) => {
|
||||
const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료'];
|
||||
const dday = getDDays(complex.subscriptionStart);
|
||||
const isUpcoming = complex.status === '청약예정' || complex.status === '청약중';
|
||||
|
||||
return (
|
||||
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
|
||||
<div className="re-card__top">
|
||||
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{complex.status}
|
||||
</span>
|
||||
{complex.priority === 'high' && <span className="re-priority-star">★</span>}
|
||||
</div>
|
||||
<h3 className="re-card__name">{complex.name}</h3>
|
||||
<p className="re-card__address">{complex.address}</p>
|
||||
<div className="re-card__stats">
|
||||
<span>{complex.units.toLocaleString()}세대</span>
|
||||
<span className="re-card__dot">·</span>
|
||||
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/평</span>
|
||||
</div>
|
||||
<div className="re-chip-group">
|
||||
{complex.types.map((t) => (
|
||||
<span key={t} className="re-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
{isUpcoming && dday && (
|
||||
<div className="re-card__dday" style={{ color: cfg.color }}>
|
||||
청약 {dday}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 인라인 지도 + 단지 상세 패널 ──────────────────────────────────────────────
|
||||
const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => {
|
||||
const cfg = selectedComplex
|
||||
? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료']
|
||||
: null;
|
||||
|
||||
const mapCenter = useMemo(() => {
|
||||
if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng];
|
||||
if (complexes.length === 0) return [37.5665, 126.9780];
|
||||
return [
|
||||
complexes.reduce((s, c) => s + c.lat, 0) / complexes.length,
|
||||
complexes.reduce((s, c) => s + c.lng, 0) / complexes.length,
|
||||
];
|
||||
}, []); // 초기 중심값만 계산 (flyTo로 이후 이동)
|
||||
|
||||
return (
|
||||
<div className="re-right-panel">
|
||||
{/* ── 지도 ── */}
|
||||
<div className="re-panel re-panel--map">
|
||||
<div className="re-mini-map-wrap">
|
||||
<MapContainer
|
||||
key="inline-map"
|
||||
center={mapCenter}
|
||||
zoom={10}
|
||||
className="re-map"
|
||||
scrollWheelZoom
|
||||
zoomControl={false}
|
||||
>
|
||||
<MapFlyTo
|
||||
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
|
||||
zoom={14}
|
||||
/>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com">CartoDB</a>'
|
||||
/>
|
||||
{complexes.map((c) => (
|
||||
<Marker
|
||||
key={c.id}
|
||||
position={[c.lat, c.lng]}
|
||||
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
|
||||
eventHandlers={{ click: () => onSelectComplex(c) }}
|
||||
>
|
||||
<Popup>
|
||||
<div className="re-popup">
|
||||
<strong>{c.name}</strong>
|
||||
<span>{c.address}</span>
|
||||
<span>{c.status} · {c.units.toLocaleString()}세대</span>
|
||||
<span>{formatPrice(c.avgPricePerPyeong)}/평</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
{selectedComplex && (
|
||||
<div className="re-map-label">
|
||||
<span style={{ color: cfg.color }}>●</span> {selectedComplex.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 패널 ── */}
|
||||
{selectedComplex ? (
|
||||
<div className="re-detail" key={selectedComplex.id}>
|
||||
<div className="re-detail__header">
|
||||
<div>
|
||||
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{selectedComplex.status}
|
||||
</span>
|
||||
<h2 className="re-detail__name">{selectedComplex.name}</h2>
|
||||
<p className="re-detail__address">{selectedComplex.address}</p>
|
||||
</div>
|
||||
<div className="re-detail__header-actions">
|
||||
<button className="button ghost small" onClick={onEdit}>편집</button>
|
||||
<button className="button danger small" onClick={onDelete}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-detail__section">
|
||||
<div className="re-detail__stats-grid">
|
||||
<div className="re-stat">
|
||||
<p className="re-stat__label">세대수</p>
|
||||
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="re-stat">
|
||||
<p className="re-stat__label">평당가</p>
|
||||
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
|
||||
{formatPrice(selectedComplex.avgPricePerPyeong)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="re-stat">
|
||||
<p className="re-stat__label">우선순위</p>
|
||||
<p className="re-stat__value" style={{ fontSize: 13 }}>
|
||||
{PRIORITY_LABELS[selectedComplex.priority]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">평형대</p>
|
||||
<div className="re-chip-group">
|
||||
{selectedComplex.types.map((t) => (
|
||||
<span key={t} className="re-chip re-chip--lg">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">청약 일정</p>
|
||||
<div className="re-timeline">
|
||||
<div className="re-timeline__item">
|
||||
<div className="re-timeline__dot re-timeline__dot--start" />
|
||||
<div>
|
||||
<p className="re-timeline__label">청약 시작</p>
|
||||
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-timeline__item">
|
||||
<div className="re-timeline__dot" />
|
||||
<div>
|
||||
<p className="re-timeline__label">청약 마감</p>
|
||||
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-timeline__item">
|
||||
<div className="re-timeline__dot re-timeline__dot--result" />
|
||||
<div>
|
||||
<p className="re-timeline__label">당첨 발표</p>
|
||||
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedComplex.tags.length > 0 && (
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">특징</p>
|
||||
<div className="re-chip-group">
|
||||
{selectedComplex.tags.map((tag) => (
|
||||
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedComplex.memo && (
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">메모</p>
|
||||
<p className="re-detail__memo">{selectedComplex.memo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="re-detail__actions">
|
||||
{selectedComplex.naverUrl ? (
|
||||
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
|
||||
네이버 부동산 →
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="button ghost small"
|
||||
>
|
||||
네이버 검색 →
|
||||
</a>
|
||||
)}
|
||||
{selectedComplex.floorPlanUrl && (
|
||||
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
|
||||
평면도 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="re-detail re-detail--empty">
|
||||
<div className="re-detail__empty-icon">🏢</div>
|
||||
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
|
||||
const ScheduleView = ({ complexes }) => {
|
||||
const events = complexes
|
||||
.filter((c) => c.subscriptionStart)
|
||||
.flatMap((c) => [
|
||||
{ date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' },
|
||||
{ date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' },
|
||||
{ date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' },
|
||||
])
|
||||
.filter((e) => e.date)
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const upcoming = events.filter((e) => new Date(e.date) >= today);
|
||||
const past = events.filter((e) => new Date(e.date) < today).reverse();
|
||||
|
||||
const EventItem = ({ event }) => {
|
||||
const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료'];
|
||||
const dday = getDDays(event.date);
|
||||
return (
|
||||
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
|
||||
<div className="re-schedule-item__date">
|
||||
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
|
||||
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
|
||||
</div>
|
||||
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
|
||||
<div className="re-schedule-item__content">
|
||||
<p className="re-schedule-item__complex">{event.complex.name}</p>
|
||||
<p className="re-schedule-item__label">{event.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="re-schedule">
|
||||
{upcoming.length > 0 && (
|
||||
<div className="re-schedule-section">
|
||||
<h4 className="re-schedule-section__title">예정 일정</h4>
|
||||
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
|
||||
</div>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<div className="re-schedule-section">
|
||||
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
|
||||
{past.map((e, i) => <EventItem key={i} event={e} />)}
|
||||
</div>
|
||||
)}
|
||||
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 가격 분석 ──────────────────────────────────────────────────────────────────
|
||||
const PriceAnalysis = ({ complexes }) => {
|
||||
const chartData = [...complexes]
|
||||
.filter((c) => c.avgPricePerPyeong > 0)
|
||||
.sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong)
|
||||
.map((c) => ({
|
||||
name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name,
|
||||
price: c.avgPricePerPyeong,
|
||||
status: c.status,
|
||||
fullName: c.name,
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="re-chart-tooltip">
|
||||
<p>{payload[0].payload.fullName}</p>
|
||||
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/평</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0);
|
||||
const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0;
|
||||
const max = prices.length ? Math.max(...prices) : 0;
|
||||
const min = prices.length ? Math.min(...prices) : 0;
|
||||
|
||||
return (
|
||||
<div className="re-analysis">
|
||||
<div className="re-analysis__stats">
|
||||
<div className="re-stat-card">
|
||||
<p className="re-stat-card__label">평균 평당가</p>
|
||||
<p className="re-stat-card__value">{formatPrice(avg)}</p>
|
||||
</div>
|
||||
<div className="re-stat-card">
|
||||
<p className="re-stat-card__label">최고 평당가</p>
|
||||
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
|
||||
</div>
|
||||
<div className="re-stat-card">
|
||||
<p className="re-stat-card__label">최저 평당가</p>
|
||||
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-panel">
|
||||
<div className="re-panel__head">
|
||||
<div>
|
||||
<p className="re-panel__eyebrow">가격 비교</p>
|
||||
<h3>단지별 평당가</h3>
|
||||
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--text-dim)"
|
||||
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||
angle={-20}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-dim)"
|
||||
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
|
||||
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => {
|
||||
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
|
||||
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-panel">
|
||||
<div className="re-panel__head">
|
||||
<div>
|
||||
<p className="re-panel__eyebrow">비교표</p>
|
||||
<h3>단지 상세 비교</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-table-wrapper">
|
||||
<table className="re-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>단지명</th>
|
||||
<th>상태</th>
|
||||
<th>세대수</th>
|
||||
<th>평형대</th>
|
||||
<th>평당가</th>
|
||||
<th>청약 시작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{complexes.map((c) => {
|
||||
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td className="re-table__name">{c.name}</td>
|
||||
<td>
|
||||
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{c.units.toLocaleString()}</td>
|
||||
<td>{c.types.join(', ')}</td>
|
||||
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
|
||||
{formatPrice(c.avgPricePerPyeong)}
|
||||
</td>
|
||||
<td>{formatDate(c.subscriptionStart)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
|
||||
const ComplexModal = ({ complex, onClose, onSave }) => {
|
||||
const [form, setForm] = useState(
|
||||
complex
|
||||
? {
|
||||
...complex,
|
||||
types: complex.types.join(', '),
|
||||
tags: complex.tags.join(', '),
|
||||
lat: String(complex.lat),
|
||||
lng: String(complex.lng),
|
||||
units: String(complex.units),
|
||||
avgPricePerPyeong: String(complex.avgPricePerPyeong),
|
||||
}
|
||||
: { ...EMPTY_FORM }
|
||||
);
|
||||
|
||||
const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...form,
|
||||
lat: parseFloat(form.lat) || 37.5665,
|
||||
lng: parseFloat(form.lng) || 126.9780,
|
||||
units: parseInt(form.units) || 0,
|
||||
avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0,
|
||||
types: form.types.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="re-modal-overlay" onClick={onClose}>
|
||||
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="re-modal__header">
|
||||
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
|
||||
<button className="re-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<form className="re-modal__form" onSubmit={handleSubmit}>
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">기본 정보</p>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
단지명 *
|
||||
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
상태
|
||||
<select className="re-form-input" value={form.status} onChange={set('status')}>
|
||||
{Object.keys(STATUS_CONFIG).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="re-form-label">
|
||||
주소
|
||||
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
|
||||
</label>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
위도 (lat)
|
||||
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
경도 (lng)
|
||||
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">단지 정보</p>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
세대수
|
||||
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
평당가 (만원)
|
||||
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
평형대 (쉼표 구분)
|
||||
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
우선순위
|
||||
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
|
||||
<option value="high">★ 최우선</option>
|
||||
<option value="normal">보통</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="re-form-label">
|
||||
특징 태그 (쉼표 구분)
|
||||
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">청약 일정</p>
|
||||
<div className="re-form-row re-form-row--three">
|
||||
<label className="re-form-label">
|
||||
청약 시작
|
||||
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
청약 마감
|
||||
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
당첨 발표
|
||||
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">링크 & 메모</p>
|
||||
<label className="re-form-label">
|
||||
네이버 부동산 URL
|
||||
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
평면도 URL
|
||||
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
메모
|
||||
<textarea
|
||||
className="re-form-input re-form-textarea"
|
||||
value={form.memo}
|
||||
onChange={set('memo')}
|
||||
placeholder="관심 포인트, 분석 내용 등"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="re-modal__footer">
|
||||
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
||||
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
|
||||
const RealEstate = () => {
|
||||
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||
const { complexes, addComplex, updateComplex, deleteComplex } = useComplexes();
|
||||
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('목록');
|
||||
const [filterStatus, setFilterStatus] = useState('전체');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingComplex, setEditingComplex] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/realestate/complexes')
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (data) => {
|
||||
const newComplex = { ...data, id: Date.now() };
|
||||
setComplexes((prev) => [...prev, newComplex]);
|
||||
const handleAdd = (data) => {
|
||||
addComplex(data);
|
||||
setShowModal(false);
|
||||
try { await apiPost('/api/realestate/complexes', data); } catch {}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data) => {
|
||||
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||
const handleUpdate = (data) => {
|
||||
updateComplex(data);
|
||||
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||
setEditingComplex(null);
|
||||
setShowModal(false);
|
||||
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const handleDelete = (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||
deleteComplex(id);
|
||||
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch {}
|
||||
};
|
||||
|
||||
const handleModalSave = (data) => {
|
||||
|
||||
39
src/pages/realestate/components/ComplexCard.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';
|
||||
|
||||
// ── 단지 카드 ──────────────────────────────────────────────────────────────────
|
||||
const ComplexCard = ({ complex, isSelected, onClick }) => {
|
||||
const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료'];
|
||||
const dday = getDDays(complex.subscriptionStart);
|
||||
const isUpcoming = complex.status === '청약예정' || complex.status === '청약중';
|
||||
|
||||
return (
|
||||
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
|
||||
<div className="re-card__top">
|
||||
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{complex.status}
|
||||
</span>
|
||||
{complex.priority === 'high' && <span className="re-priority-star">★</span>}
|
||||
</div>
|
||||
<h3 className="re-card__name">{complex.name}</h3>
|
||||
<p className="re-card__address">{complex.address}</p>
|
||||
<div className="re-card__stats">
|
||||
<span>{complex.units.toLocaleString()}세대</span>
|
||||
<span className="re-card__dot">·</span>
|
||||
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/평</span>
|
||||
</div>
|
||||
<div className="re-chip-group">
|
||||
{complex.types.map((t) => (
|
||||
<span key={t} className="re-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
{isUpcoming && dday && (
|
||||
<div className="re-card__dday" style={{ color: cfg.color }}>
|
||||
청약 {dday}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComplexCard;
|
||||
157
src/pages/realestate/components/ComplexModal.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';
|
||||
|
||||
// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
|
||||
const ComplexModal = ({ complex, onClose, onSave }) => {
|
||||
const [form, setForm] = useState(
|
||||
complex
|
||||
? {
|
||||
...complex,
|
||||
types: complex.types.join(', '),
|
||||
tags: complex.tags.join(', '),
|
||||
lat: String(complex.lat),
|
||||
lng: String(complex.lng),
|
||||
units: String(complex.units),
|
||||
avgPricePerPyeong: String(complex.avgPricePerPyeong),
|
||||
}
|
||||
: { ...EMPTY_FORM }
|
||||
);
|
||||
|
||||
const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...form,
|
||||
lat: parseFloat(form.lat) || 37.5665,
|
||||
lng: parseFloat(form.lng) || 126.9780,
|
||||
units: parseInt(form.units) || 0,
|
||||
avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0,
|
||||
types: form.types.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="re-modal-overlay" onClick={onClose}>
|
||||
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="re-modal__header">
|
||||
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
|
||||
<button className="re-modal__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<form className="re-modal__form" onSubmit={handleSubmit}>
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">기본 정보</p>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
단지명 *
|
||||
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
상태
|
||||
<select className="re-form-input" value={form.status} onChange={set('status')}>
|
||||
{Object.keys(STATUS_CONFIG).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="re-form-label">
|
||||
주소
|
||||
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
|
||||
</label>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
위도 (lat)
|
||||
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
경도 (lng)
|
||||
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">단지 정보</p>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
세대수
|
||||
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
평당가 (만원)
|
||||
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="re-form-row">
|
||||
<label className="re-form-label">
|
||||
평형대 (쉼표 구분)
|
||||
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
우선순위
|
||||
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
|
||||
<option value="high">★ 최우선</option>
|
||||
<option value="normal">보통</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="re-form-label">
|
||||
특징 태그 (쉼표 구분)
|
||||
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">청약 일정</p>
|
||||
<div className="re-form-row re-form-row--three">
|
||||
<label className="re-form-label">
|
||||
청약 시작
|
||||
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
청약 마감
|
||||
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
당첨 발표
|
||||
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-form-section">
|
||||
<p className="re-form-section__title">링크 & 메모</p>
|
||||
<label className="re-form-label">
|
||||
네이버 부동산 URL
|
||||
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
평면도 URL
|
||||
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
|
||||
</label>
|
||||
<label className="re-form-label">
|
||||
메모
|
||||
<textarea
|
||||
className="re-form-input re-form-textarea"
|
||||
value={form.memo}
|
||||
onChange={set('memo')}
|
||||
placeholder="관심 포인트, 분석 내용 등"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="re-modal__footer">
|
||||
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
||||
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComplexModal;
|
||||
134
src/pages/realestate/components/PriceAnalysis.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';
|
||||
|
||||
// ── 가격 분석 ──────────────────────────────────────────────────────────────────
|
||||
const PriceAnalysis = ({ complexes }) => {
|
||||
const chartData = [...complexes]
|
||||
.filter((c) => c.avgPricePerPyeong > 0)
|
||||
.sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong)
|
||||
.map((c) => ({
|
||||
name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name,
|
||||
price: c.avgPricePerPyeong,
|
||||
status: c.status,
|
||||
fullName: c.name,
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="re-chart-tooltip">
|
||||
<p>{payload[0].payload.fullName}</p>
|
||||
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/평</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0);
|
||||
const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0;
|
||||
const max = prices.length ? Math.max(...prices) : 0;
|
||||
const min = prices.length ? Math.min(...prices) : 0;
|
||||
|
||||
return (
|
||||
<div className="re-analysis">
|
||||
<div className="re-analysis__stats">
|
||||
<div className="re-stat-card">
|
||||
<p className="re-stat-card__label">평균 평당가</p>
|
||||
<p className="re-stat-card__value">{formatPrice(avg)}</p>
|
||||
</div>
|
||||
<div className="re-stat-card">
|
||||
<p className="re-stat-card__label">최고 평당가</p>
|
||||
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
|
||||
</div>
|
||||
<div className="re-stat-card">
|
||||
<p className="re-stat-card__label">최저 평당가</p>
|
||||
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-panel">
|
||||
<div className="re-panel__head">
|
||||
<div>
|
||||
<p className="re-panel__eyebrow">가격 비교</p>
|
||||
<h3>단지별 평당가</h3>
|
||||
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--text-dim)"
|
||||
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||
angle={-20}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-dim)"
|
||||
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
|
||||
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => {
|
||||
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
|
||||
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-panel">
|
||||
<div className="re-panel__head">
|
||||
<div>
|
||||
<p className="re-panel__eyebrow">비교표</p>
|
||||
<h3>단지 상세 비교</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-table-wrapper">
|
||||
<table className="re-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>단지명</th>
|
||||
<th>상태</th>
|
||||
<th>세대수</th>
|
||||
<th>평형대</th>
|
||||
<th>평당가</th>
|
||||
<th>청약 시작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{complexes.map((c) => {
|
||||
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td className="re-table__name">{c.name}</td>
|
||||
<td>
|
||||
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{c.units.toLocaleString()}</td>
|
||||
<td>{c.types.join(', ')}</td>
|
||||
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
|
||||
{formatPrice(c.avgPricePerPyeong)}
|
||||
</td>
|
||||
<td>{formatDate(c.subscriptionStart)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceAnalysis;
|
||||
202
src/pages/realestate/components/RightPanel.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import { STATUS_CONFIG, PRIORITY_LABELS, formatDate, formatPrice, createMarkerIcon } from '../realEstateUtils';
|
||||
|
||||
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
||||
const MapFlyTo = ({ position, zoom }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (position) {
|
||||
map.flyTo(position, zoom ?? 14, { duration: 1.0 });
|
||||
}
|
||||
}, [position, zoom, map]);
|
||||
return null;
|
||||
};
|
||||
|
||||
// ── 인라인 지도 + 단지 상세 패널 ──────────────────────────────────────────────
|
||||
const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => {
|
||||
const cfg = selectedComplex
|
||||
? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료']
|
||||
: null;
|
||||
|
||||
const mapCenter = useMemo(() => {
|
||||
if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng];
|
||||
if (complexes.length === 0) return [37.5665, 126.9780];
|
||||
return [
|
||||
complexes.reduce((s, c) => s + c.lat, 0) / complexes.length,
|
||||
complexes.reduce((s, c) => s + c.lng, 0) / complexes.length,
|
||||
];
|
||||
}, []); // 초기 중심값만 계산 (flyTo로 이후 이동)
|
||||
|
||||
return (
|
||||
<div className="re-right-panel">
|
||||
{/* ── 지도 ── */}
|
||||
<div className="re-panel re-panel--map">
|
||||
<div className="re-mini-map-wrap">
|
||||
<MapContainer
|
||||
key="inline-map"
|
||||
center={mapCenter}
|
||||
zoom={10}
|
||||
className="re-map"
|
||||
scrollWheelZoom
|
||||
zoomControl={false}
|
||||
>
|
||||
<MapFlyTo
|
||||
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
|
||||
zoom={14}
|
||||
/>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com">CartoDB</a>'
|
||||
/>
|
||||
{complexes.map((c) => (
|
||||
<Marker
|
||||
key={c.id}
|
||||
position={[c.lat, c.lng]}
|
||||
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
|
||||
eventHandlers={{ click: () => onSelectComplex(c) }}
|
||||
>
|
||||
<Popup>
|
||||
<div className="re-popup">
|
||||
<strong>{c.name}</strong>
|
||||
<span>{c.address}</span>
|
||||
<span>{c.status} · {c.units.toLocaleString()}세대</span>
|
||||
<span>{formatPrice(c.avgPricePerPyeong)}/평</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
{selectedComplex && (
|
||||
<div className="re-map-label">
|
||||
<span style={{ color: cfg.color }}>●</span> {selectedComplex.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 상세 패널 ── */}
|
||||
{selectedComplex ? (
|
||||
<div className="re-detail" key={selectedComplex.id}>
|
||||
<div className="re-detail__header">
|
||||
<div>
|
||||
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{selectedComplex.status}
|
||||
</span>
|
||||
<h2 className="re-detail__name">{selectedComplex.name}</h2>
|
||||
<p className="re-detail__address">{selectedComplex.address}</p>
|
||||
</div>
|
||||
<div className="re-detail__header-actions">
|
||||
<button className="button ghost small" onClick={onEdit}>편집</button>
|
||||
<button className="button danger small" onClick={onDelete}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-detail__section">
|
||||
<div className="re-detail__stats-grid">
|
||||
<div className="re-stat">
|
||||
<p className="re-stat__label">세대수</p>
|
||||
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="re-stat">
|
||||
<p className="re-stat__label">평당가</p>
|
||||
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
|
||||
{formatPrice(selectedComplex.avgPricePerPyeong)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="re-stat">
|
||||
<p className="re-stat__label">우선순위</p>
|
||||
<p className="re-stat__value" style={{ fontSize: 13 }}>
|
||||
{PRIORITY_LABELS[selectedComplex.priority]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">평형대</p>
|
||||
<div className="re-chip-group">
|
||||
{selectedComplex.types.map((t) => (
|
||||
<span key={t} className="re-chip re-chip--lg">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">청약 일정</p>
|
||||
<div className="re-timeline">
|
||||
<div className="re-timeline__item">
|
||||
<div className="re-timeline__dot re-timeline__dot--start" />
|
||||
<div>
|
||||
<p className="re-timeline__label">청약 시작</p>
|
||||
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-timeline__item">
|
||||
<div className="re-timeline__dot" />
|
||||
<div>
|
||||
<p className="re-timeline__label">청약 마감</p>
|
||||
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="re-timeline__item">
|
||||
<div className="re-timeline__dot re-timeline__dot--result" />
|
||||
<div>
|
||||
<p className="re-timeline__label">당첨 발표</p>
|
||||
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedComplex.tags.length > 0 && (
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">특징</p>
|
||||
<div className="re-chip-group">
|
||||
{selectedComplex.tags.map((tag) => (
|
||||
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedComplex.memo && (
|
||||
<div className="re-detail__section">
|
||||
<p className="re-detail__section-title">메모</p>
|
||||
<p className="re-detail__memo">{selectedComplex.memo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="re-detail__actions">
|
||||
{selectedComplex.naverUrl ? (
|
||||
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
|
||||
네이버 부동산 →
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="button ghost small"
|
||||
>
|
||||
네이버 검색 →
|
||||
</a>
|
||||
)}
|
||||
{selectedComplex.floorPlanUrl && (
|
||||
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
|
||||
평면도 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="re-detail re-detail--empty">
|
||||
<div className="re-detail__empty-icon">🏢</div>
|
||||
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
59
src/pages/realestate/components/ScheduleView.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';
|
||||
|
||||
// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
|
||||
const ScheduleView = ({ complexes }) => {
|
||||
const events = complexes
|
||||
.filter((c) => c.subscriptionStart)
|
||||
.flatMap((c) => [
|
||||
{ date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' },
|
||||
{ date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' },
|
||||
{ date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' },
|
||||
])
|
||||
.filter((e) => e.date)
|
||||
.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const upcoming = events.filter((e) => new Date(e.date) >= today);
|
||||
const past = events.filter((e) => new Date(e.date) < today).reverse();
|
||||
|
||||
const EventItem = ({ event }) => {
|
||||
const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료'];
|
||||
const dday = getDDays(event.date);
|
||||
return (
|
||||
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
|
||||
<div className="re-schedule-item__date">
|
||||
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
|
||||
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
|
||||
</div>
|
||||
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
|
||||
<div className="re-schedule-item__content">
|
||||
<p className="re-schedule-item__complex">{event.complex.name}</p>
|
||||
<p className="re-schedule-item__label">{event.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="re-schedule">
|
||||
{upcoming.length > 0 && (
|
||||
<div className="re-schedule-section">
|
||||
<h4 className="re-schedule-section__title">예정 일정</h4>
|
||||
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
|
||||
</div>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<div className="re-schedule-section">
|
||||
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
|
||||
{past.map((e, i) => <EventItem key={i} event={e} />)}
|
||||
</div>
|
||||
)}
|
||||
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleView;
|
||||
33
src/pages/realestate/hooks/useComplexes.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
|
||||
import { SAMPLE_COMPLEXES } from '../realEstateUtils';
|
||||
|
||||
export default function useComplexes() {
|
||||
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet('/api/realestate/complexes')
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const addComplex = async (data) => {
|
||||
const newComplex = { ...data, id: Date.now() };
|
||||
setComplexes((prev) => [...prev, newComplex]);
|
||||
try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
const updateComplex = async (data) => {
|
||||
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
const deleteComplex = async (id) => {
|
||||
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
return { complexes, addComplex, updateComplex, deleteComplex };
|
||||
}
|
||||
141
src/pages/realestate/realEstateUtils.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import L from 'leaflet';
|
||||
|
||||
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
||||
export const SAMPLE_COMPLEXES = [
|
||||
{
|
||||
id: 1,
|
||||
name: '래미안 원베일리',
|
||||
address: '서울 서초구 반포동',
|
||||
lat: 37.5065,
|
||||
lng: 126.9942,
|
||||
units: 2990,
|
||||
types: ['59㎡', '84㎡', '114㎡'],
|
||||
avgPricePerPyeong: 9500,
|
||||
subscriptionStart: '2024-01-08',
|
||||
subscriptionEnd: '2024-01-10',
|
||||
resultDate: '2024-01-15',
|
||||
status: '완료',
|
||||
priority: 'high',
|
||||
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '올림픽파크 포레온',
|
||||
address: '서울 강동구 둔촌동',
|
||||
lat: 37.5284,
|
||||
lng: 127.1340,
|
||||
units: 12032,
|
||||
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
||||
avgPricePerPyeong: 3800,
|
||||
subscriptionStart: '2022-12-05',
|
||||
subscriptionEnd: '2022-12-07',
|
||||
resultDate: '2022-12-12',
|
||||
status: '완료',
|
||||
priority: 'high',
|
||||
tags: ['대단지', '역세권', '재건축'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '역대 최대 규모 재건축 단지.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '힐스테이트 동탄',
|
||||
address: '경기 화성시 동탄2신도시',
|
||||
lat: 37.2001,
|
||||
lng: 127.0724,
|
||||
units: 1534,
|
||||
types: ['59㎡', '74㎡', '84㎡'],
|
||||
avgPricePerPyeong: 1850,
|
||||
subscriptionStart: '2026-04-10',
|
||||
subscriptionEnd: '2026-04-12',
|
||||
resultDate: '2026-04-17',
|
||||
status: '청약예정',
|
||||
priority: 'normal',
|
||||
tags: ['동탄2', '신도시', 'SRT'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '롯데캐슬 마곡',
|
||||
address: '서울 강서구 마곡동',
|
||||
lat: 37.5626,
|
||||
lng: 126.8295,
|
||||
units: 868,
|
||||
types: ['59㎡', '84㎡'],
|
||||
avgPricePerPyeong: 4200,
|
||||
subscriptionStart: '2026-03-20',
|
||||
subscriptionEnd: '2026-03-22',
|
||||
resultDate: '2026-03-27',
|
||||
status: '청약중',
|
||||
priority: 'high',
|
||||
tags: ['마곡', '9호선', '공항철도'],
|
||||
naverUrl: '',
|
||||
floorPlanUrl: '',
|
||||
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_CONFIG = {
|
||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||
|
||||
export const EMPTY_FORM = {
|
||||
name: '', address: '', lat: '', lng: '',
|
||||
units: '', types: '', avgPricePerPyeong: '',
|
||||
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||
status: '청약예정', priority: 'normal',
|
||||
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||
};
|
||||
|
||||
export const TABS = ['목록', '일정', '분석'];
|
||||
|
||||
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
||||
export const formatDate = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
export const formatPrice = (v) => {
|
||||
if (!v) return '-';
|
||||
return `${v.toLocaleString()}만원`;
|
||||
};
|
||||
|
||||
export const getDDays = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
const target = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||
if (diff === 0) return 'D-Day';
|
||||
if (diff > 0) return `D-${diff}`;
|
||||
return `D+${Math.abs(diff)}`;
|
||||
};
|
||||
|
||||
export const createMarkerIcon = (status, isSelected = false) => {
|
||||
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||
const size = isSelected ? 18 : 12;
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${cfg.color};
|
||||
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||
transition:all 0.2s;
|
||||
"></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -(size / 2 + 4)],
|
||||
});
|
||||
};
|
||||
31
src/pages/realestate/realEstateUtils.test.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate, formatPrice, getDDays } from './realEstateUtils.js';
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('만원 표기 / falsy → -', () => {
|
||||
expect(formatPrice(4500)).toBe('4,500만원');
|
||||
expect(formatPrice(0)).toBe('-');
|
||||
expect(formatPrice(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDDays (오늘 기준)', () => {
|
||||
const iso = (off) => {
|
||||
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + off);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||
};
|
||||
it('오늘=D-Day, 미래=D-N, 과거=D+N, null→null', () => {
|
||||
expect(getDDays(iso(0))).toBe('D-Day');
|
||||
expect(getDDays(iso(4))).toBe('D-4');
|
||||
expect(getDDays(iso(-2))).toBe('D+2');
|
||||
expect(getDDays(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('유효 날짜 한국어 포맷 / falsy → -', () => {
|
||||
expect(formatDate('2026-04-10')).toContain('2026');
|
||||
expect(formatDate(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
55
src/pages/saju/Compatibility.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './_shell/tokens.css';
|
||||
import './_shell/shell.css';
|
||||
import useViewportMode from './_shell/useViewportMode';
|
||||
import BottomNav from './_shell/BottomNav';
|
||||
import DesktopHeader from './_shell/DesktopHeader';
|
||||
import MatchMobile from './views/match.mobile.jsx';
|
||||
import MatchDesktop from './views/match.desktop.jsx';
|
||||
import { compatInterpret } from '../../api';
|
||||
|
||||
const EMPTY_PERSON = {
|
||||
name: '', year: '', month: '', day: '', hour: null,
|
||||
gender: 'male', calendar_type: 'solar',
|
||||
};
|
||||
|
||||
export default function Compatibility() {
|
||||
const mode = useViewportMode();
|
||||
const navigate = useNavigate();
|
||||
const [personA, setPersonA] = useState({ ...EMPTY_PERSON });
|
||||
const [personB, setPersonB] = useState({ ...EMPTY_PERSON });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
setError(null);
|
||||
if (!personA.year || !personA.month || !personA.day ||
|
||||
!personB.year || !personB.month || !personB.day) {
|
||||
setError('두 사람의 생년월일을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await compatInterpret({ person_a: personA, person_b: personB });
|
||||
navigate(`/saju/compatibility/result?cid=${res.reading_id}`);
|
||||
} catch (err) {
|
||||
setError(err?.message || '궁합 풀이에 실패했어요.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const props = {
|
||||
personA, personB, onChangeA: setPersonA, onChangeB: setPersonB,
|
||||
onSubmit: handleSubmit, loading, error,
|
||||
};
|
||||
return (
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' && <DesktopHeader />}
|
||||
{mode === 'desktop' ? <MatchDesktop {...props} /> : <MatchMobile {...props} />}
|
||||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/pages/saju/CompatibilityResult.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import './_shell/tokens.css';
|
||||
import './_shell/shell.css';
|
||||
import useViewportMode from './_shell/useViewportMode';
|
||||
import BottomNav from './_shell/BottomNav';
|
||||
import DesktopHeader from './_shell/DesktopHeader';
|
||||
import TopRibbon from './_shell/TopRibbon';
|
||||
import TitleBlock from './_shell/TitleBlock';
|
||||
import Mascot from './_shell/Mascot';
|
||||
import MascotBubble from './_shell/MascotBubble';
|
||||
import OrnateFrame from './_shell/OrnateFrame';
|
||||
import PrimaryButton from './_shell/PrimaryButton';
|
||||
import GhostButton from './_shell/GhostButton';
|
||||
import MatchResultDesktop from './views/match-result.desktop.jsx';
|
||||
import { compatGetReading } from '../../api';
|
||||
|
||||
export default function CompatibilityResult() {
|
||||
const mode = useViewportMode();
|
||||
const [params] = useSearchParams();
|
||||
const cid = params.get('cid');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cid) return;
|
||||
compatGetReading(parseInt(cid, 10))
|
||||
.then(setResult)
|
||||
.catch((e) => setError(e?.message || '결과를 가져오지 못했어요.'));
|
||||
}, [cid]);
|
||||
|
||||
const interp = result?.interpretation_json || {};
|
||||
const strengths = interp.strengths || [];
|
||||
const challenges = interp.challenges || [];
|
||||
|
||||
return (
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' && <DesktopHeader />}
|
||||
{result && mode === 'desktop' ? (
|
||||
<MatchResultDesktop result={result} />
|
||||
) : (
|
||||
<main className="page paper-bg screen-in">
|
||||
<TopRibbon color="#4E6B5C" opacity={0.6} />
|
||||
<div style={{ maxWidth: mode === 'desktop' ? 720 : 'none', margin: '0 auto', padding: '24px 20px 40px' }}>
|
||||
{!cid && (
|
||||
<>
|
||||
<TitleBlock title="궁합 결과" gold="#4E6B5C" />
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<MascotBubble tone="green" tail={false} text="궁합을 먼저 보세요." style={{ margin: '0 auto 20px' }} />
|
||||
<Link to="/saju/compatibility">
|
||||
<PrimaryButton color="#4E6B5C" full={false}>궁합 입력하러 가기</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{cid && !result && !error && (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||||
<MascotBubble tone="green" tail={false}
|
||||
text="호령이 두 사주를 비교 중이에요..."
|
||||
style={{ margin: '0 auto' }} />
|
||||
</div>
|
||||
)}
|
||||
{cid && error && (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<MascotBubble tone="green" tail={false}
|
||||
text="궁합 결과를 가져오지 못했어요."
|
||||
style={{ margin: '0 auto 20px' }} />
|
||||
<GhostButton color="#4E6B5C" full={false} onClick={() => window.location.reload()}>다시 시도</GhostButton>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<>
|
||||
<TitleBlock title="궁합 결과" gold="#4E6B5C"
|
||||
subtitle={`${result.person_a?.name || '사람 A'} × ${result.person_b?.name || '사람 B'}`} />
|
||||
<div style={{ marginTop: 20, textAlign: 'center' }}>
|
||||
<div className="font-title" style={{ fontSize: 48, color: '#4E6B5C' }}>
|
||||
{result.score}<span style={{ fontSize: 18, color: '#9A968D', fontWeight: 500 }}>점</span>
|
||||
</div>
|
||||
</div>
|
||||
{interp.summary && (
|
||||
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 20 }}>
|
||||
<div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', textAlign: 'center', marginBottom: 6 }}>요약</div>
|
||||
<div style={{ fontSize: 13, color: '#1F2A44', lineHeight: 1.7, whiteSpace: 'pre-line' }}>
|
||||
{interp.summary}
|
||||
</div>
|
||||
</OrnateFrame>
|
||||
)}
|
||||
{strengths.length > 0 && (
|
||||
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}>
|
||||
<div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', marginBottom: 8 }}>강점</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}>
|
||||
{strengths.map((s, i) => (<li key={i}>{s}</li>))}
|
||||
</ul>
|
||||
</OrnateFrame>
|
||||
)}
|
||||
{challenges.length > 0 && (
|
||||
<OrnateFrame color="#C04A4A" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}>
|
||||
<div className="font-title" style={{ fontSize: 13, color: '#C04A4A', marginBottom: 8 }}>주의할 점</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}>
|
||||
{challenges.map((s, i) => (<li key={i}>{s}</li>))}
|
||||
</ul>
|
||||
</OrnateFrame>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||