Compare commits
11 Commits
95edc9d232
...
3fa4dbda3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa4dbda3c | |||
| baf34dd7aa | |||
| 4ef76f6cce | |||
| 0bf1233e96 | |||
| ff7ac48c6b | |||
| 329141c732 | |||
| cd3c538eb7 | |||
| 9d2dfad512 | |||
| 42073a5bf3 | |||
| 6b2fcda2af | |||
| acac2cd20e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# Superpowers visual companion (mockup files)
|
||||||
|
.superpowers/
|
||||||
|
|||||||
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
|||||||
|
# Lotto Curator Evolution — Design Spec
|
||||||
|
|
||||||
|
- 일자: 2026-05-11
|
||||||
|
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
|
||||||
|
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
|
||||||
|
|
||||||
|
## 1. 동기와 문제
|
||||||
|
|
||||||
|
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
|
||||||
|
|
||||||
|
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
|
||||||
|
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
|
||||||
|
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
|
||||||
|
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
|
||||||
|
|
||||||
|
## 2. 컨셉
|
||||||
|
|
||||||
|
다음 두 축으로 강화한다.
|
||||||
|
|
||||||
|
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
|
||||||
|
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
|
||||||
|
|
||||||
|
## 3. 주간 사이클
|
||||||
|
|
||||||
|
```
|
||||||
|
토 20:35 추첨
|
||||||
|
│
|
||||||
|
일 03:00 추첨결과 sync (기존)
|
||||||
|
↓
|
||||||
|
채점 잡 (신규) → weekly_review INSERT
|
||||||
|
lotto_purchase auto_graded UPDATE
|
||||||
|
│
|
||||||
|
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
|
||||||
|
├─ build_retrospective(target_draw)
|
||||||
|
├─ collect_candidates(N=30)
|
||||||
|
├─ build_context (+retrospective)
|
||||||
|
├─ Claude 호출 (회고+계층 규칙)
|
||||||
|
└─ briefings INSERT (4계층 picks)
|
||||||
|
│
|
||||||
|
월 09:05 텔레그램 헤드라인 푸시
|
||||||
|
│
|
||||||
|
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
|
||||||
|
│
|
||||||
|
토 20:35 추첨 → 다음 사이클
|
||||||
|
```
|
||||||
|
|
||||||
|
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
|
||||||
|
|
||||||
|
## 4. 결정 카드 (브리핑 탭 메인)
|
||||||
|
|
||||||
|
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
|
||||||
|
|
||||||
|
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
|
||||||
|
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
|
||||||
|
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
|
||||||
|
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
|
||||||
|
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
|
||||||
|
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
|
||||||
|
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
|
||||||
|
|
||||||
|
### 4계층 위계
|
||||||
|
|
||||||
|
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
|
||||||
|
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
|
||||||
|
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
|
||||||
|
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
|
||||||
|
|
||||||
|
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage` 에 `lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
|
||||||
|
|
||||||
|
### 분석 탭은 "Deep Dive" 자료실로 강등
|
||||||
|
|
||||||
|
- 라벨 변경: `📊 분석·통계` → `📚 자료실 / Deep Dive`
|
||||||
|
- 첫 진입 시 모든 패널 접힘
|
||||||
|
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
|
||||||
|
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
|
||||||
|
|
||||||
|
## 5. 데이터 모델
|
||||||
|
|
||||||
|
### 신규 테이블 — `weekly_review`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE weekly_review (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draw_no INTEGER NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
|
||||||
|
curator_avg_match REAL,
|
||||||
|
curator_best_tier TEXT, -- 안정 | 균형 | 공격
|
||||||
|
curator_best_match INTEGER,
|
||||||
|
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
|
||||||
|
|
||||||
|
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
|
||||||
|
user_avg_match REAL,
|
||||||
|
user_best_match INTEGER,
|
||||||
|
user_5plus_prizes INTEGER,
|
||||||
|
|
||||||
|
-- 패턴 갭 (서사 재료)
|
||||||
|
user_pattern_summary TEXT,
|
||||||
|
draw_pattern_summary TEXT,
|
||||||
|
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `lotto_purchase` 컬럼 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
|
||||||
|
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
|
||||||
|
```
|
||||||
|
|
||||||
|
### `briefings.picks` 구조 변경
|
||||||
|
|
||||||
|
JSON 컬럼을 4계층 구조로 마이그레이션:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"core": [/* 5세트 */],
|
||||||
|
"bonus": [/* 5세트 */],
|
||||||
|
"extended": [/* 5세트 */],
|
||||||
|
"pool": [/* 5세트 */]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
|
||||||
|
|
||||||
|
## 6. 큐레이터 변경
|
||||||
|
|
||||||
|
### 출력 스키마 (`agent-office/curator/schema.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CuratorOutput(BaseModel):
|
||||||
|
core_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
|
||||||
|
narrative: Narrative # retrospective(60자 이내) 필드 추가
|
||||||
|
confidence: int # 0~100
|
||||||
|
```
|
||||||
|
|
||||||
|
### SYSTEM_PROMPT 추가 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
회고 규칙:
|
||||||
|
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
|
||||||
|
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
|
||||||
|
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
|
||||||
|
|
||||||
|
계층별 큐레이션 규칙:
|
||||||
|
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
|
||||||
|
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
|
||||||
|
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
|
||||||
|
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
|
||||||
|
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
|
||||||
|
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_retrospective(target_draw_no: int) -> dict | None:
|
||||||
|
last = lotto_get_review(target_draw_no - 1)
|
||||||
|
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
|
||||||
|
if not last:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"last_draw": {
|
||||||
|
"draw_no": last["draw_no"],
|
||||||
|
"curator_avg": last["curator_avg_match"],
|
||||||
|
"curator_best_tier": last["curator_best_tier"],
|
||||||
|
"user_avg": last["user_avg_match"],
|
||||||
|
"user_5plus": last["user_5plus_prizes"],
|
||||||
|
"pattern_delta": last["pattern_delta"],
|
||||||
|
},
|
||||||
|
"trend_4w": {
|
||||||
|
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
|
||||||
|
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
|
||||||
|
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 후보 풀 N=30
|
||||||
|
|
||||||
|
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
|
||||||
|
|
||||||
|
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
실행: 매주 일요일 03:00 KST (cron)
|
||||||
|
입력: 가장 최근 sync된 추첨 회차
|
||||||
|
처리:
|
||||||
|
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
|
||||||
|
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
|
||||||
|
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
|
||||||
|
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
|
||||||
|
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
|
||||||
|
6) weekly_review UPSERT (draw_no 유니크)
|
||||||
|
7) lotto_purchase 채점:
|
||||||
|
- 일치 3개 → prize=5000, auto_graded=1
|
||||||
|
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
|
||||||
|
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
|
||||||
|
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
|
||||||
|
호출하여 텔레그램 별도 알림 트리거
|
||||||
|
- numbers NULL 인 행은 스킵
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
|
||||||
|
|
||||||
|
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
|
||||||
|
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
|
||||||
|
|
||||||
|
```
|
||||||
|
🎟 1154회 · 큐레이션 떴음
|
||||||
|
|
||||||
|
"이번 주는 안정 +1, 콜드 누적 보강."
|
||||||
|
신뢰도 72 · 분배 안정 3·균형 1·공격 1
|
||||||
|
|
||||||
|
▸ 회고: 너 2.0 / 나 1.8
|
||||||
|
너 저번호 편향 → 보너스 고번호 보강
|
||||||
|
|
||||||
|
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
|
||||||
|
```
|
||||||
|
|
||||||
|
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
|
||||||
|
|
||||||
|
## 9. 프론트 변경
|
||||||
|
|
||||||
|
### 파일 변경 맵
|
||||||
|
|
||||||
|
| 파일 | 종류 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
|
||||||
|
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
|
||||||
|
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
|
||||||
|
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
|
||||||
|
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
|
||||||
|
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
|
||||||
|
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
|
||||||
|
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
|
||||||
|
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
|
||||||
|
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
|
||||||
|
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
|
||||||
|
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
|
||||||
|
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
|
||||||
|
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
|
||||||
|
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
|
||||||
|
|
||||||
|
### 컴포넌트 격리 원칙
|
||||||
|
|
||||||
|
- `DecisionCard` 는 `briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
|
||||||
|
- `TierSection` 은 `tier`, `picks`, `rationale` 만 받아 4번 재사용.
|
||||||
|
- `BulkPurchaseButton` 은 `draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
|
||||||
|
|
||||||
|
## 10. 백엔드 변경
|
||||||
|
|
||||||
|
### `web-backend/lotto/`
|
||||||
|
|
||||||
|
| 파일 | 종류 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
|
||||||
|
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
|
||||||
|
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
|
||||||
|
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
|
||||||
|
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
|
||||||
|
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
|
||||||
|
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
|
||||||
|
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
|
||||||
|
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
|
||||||
|
|
||||||
|
### `web-backend/agent-office/`
|
||||||
|
|
||||||
|
| 파일 | 종류 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app/curator/retrospective.py` | 신규 | build_retrospective |
|
||||||
|
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
|
||||||
|
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
|
||||||
|
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
|
||||||
|
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
|
||||||
|
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
|
||||||
|
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
|
||||||
|
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
|
||||||
|
|
||||||
|
## 11. API 추가·변경
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
|
||||||
|
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
|
||||||
|
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
|
||||||
|
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
|
||||||
|
|
||||||
|
기존 엔드포인트는 그대로 유지(스키마 호환).
|
||||||
|
|
||||||
|
## 12. 에러 처리 / 격리
|
||||||
|
|
||||||
|
| 단계 | 실패 | 처리 |
|
||||||
|
|------|------|------|
|
||||||
|
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
|
||||||
|
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
|
||||||
|
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
|
||||||
|
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
|
||||||
|
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
|
||||||
|
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
|
||||||
|
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
|
||||||
|
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
|
||||||
|
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
|
||||||
|
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
|
||||||
|
|
||||||
|
## 13. 테스트
|
||||||
|
|
||||||
|
### 백엔드 (`lotto/`)
|
||||||
|
|
||||||
|
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
|
||||||
|
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
|
||||||
|
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
|
||||||
|
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
|
||||||
|
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
|
||||||
|
|
||||||
|
### 큐레이터 (`agent-office/curator/`)
|
||||||
|
|
||||||
|
- `build_retrospective`: review 1건 / 4건 / 0건
|
||||||
|
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
|
||||||
|
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
|
||||||
|
- `telegram_lotto.format`: retrospective 있음·없음
|
||||||
|
|
||||||
|
### 프론트
|
||||||
|
|
||||||
|
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
|
||||||
|
- `TierModeToggle` 단위: onChange 콜백 정확
|
||||||
|
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
|
||||||
|
- 자료실 탭 수동: 첫 진입 모두 접힘
|
||||||
|
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
|
||||||
|
|
||||||
|
## 14. 운영 점검 (배포 후 1주차)
|
||||||
|
|
||||||
|
수동으로 확인:
|
||||||
|
|
||||||
|
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
|
||||||
|
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
|
||||||
|
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
|
||||||
|
4. 결정 카드 렌더링 정상(모바일 + PC)
|
||||||
|
5. 원클릭 구매 정확 N건 INSERT
|
||||||
|
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
|
||||||
|
|
||||||
|
## 15. Out of Scope
|
||||||
|
|
||||||
|
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
|
||||||
|
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
|
||||||
|
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
|
||||||
|
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)
|
||||||
15
src/api.js
15
src/api.js
@@ -680,3 +680,18 @@ export const getBatchJob = (id) => apiGet(`/api/music/generate-b
|
|||||||
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||||
export const listGenres = () => apiGet('/api/music/genres');
|
export const listGenres = () => apiGet('/api/music/genres');
|
||||||
|
|
||||||
|
// === 주간 회고 (weekly_review) ===
|
||||||
|
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
|
||||||
|
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
|
||||||
|
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
|
||||||
|
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getReviewHistory = (limit = 4) =>
|
||||||
|
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
|
||||||
|
|
||||||
|
// === 큐레이터 4계층 원클릭 구매 ===
|
||||||
|
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
||||||
|
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SwipeableView from '../../components/SwipeableView';
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||||
{ id: 'analysis', label: '📊 분석·통계' },
|
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
|
||||||
{ id: 'purchase', label: '💰 구매·성과' },
|
{ id: 'purchase', label: '💰 구매·성과' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-list__head {
|
.lotto-purchase-list__head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1033,7 +1033,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-row {
|
.lotto-purchase-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -1068,6 +1068,21 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row__hits {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
|
||||||
|
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
|
||||||
|
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
|
||||||
|
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
|
||||||
|
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
|
||||||
|
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
|
||||||
|
|
||||||
.is-pos { color: #97c9aa; }
|
.is-pos { color: #97c9aa; }
|
||||||
.is-neg { color: #f7a8a5; }
|
.is-neg { color: #f7a8a5; }
|
||||||
.is-prize { color: #fdd4b1; }
|
.is-prize { color: #fdd4b1; }
|
||||||
@@ -1098,8 +1113,8 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
|
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
|
||||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
|
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,7 +1158,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-list__head,
|
.lotto-purchase-list__head,
|
||||||
.lotto-purchase-row {
|
.lotto-purchase-row {
|
||||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-purchase-list__head span:nth-child(4),
|
.lotto-purchase-list__head span:nth-child(4),
|
||||||
@@ -1526,3 +1541,14 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-section-fold { margin-bottom: 14px; }
|
||||||
|
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
|
||||||
|
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
|
||||||
|
|
||||||
|
.trend-chart { display: block; margin: 0 auto; }
|
||||||
|
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.dot--curator { background: #b8a8ff; }
|
||||||
|
.dot--user { background: #76e09a; }
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const PurchasePanel = ({
|
|||||||
<span>투자금</span>
|
<span>투자금</span>
|
||||||
<span>당첨금</span>
|
<span>당첨금</span>
|
||||||
<span>손익</span>
|
<span>손익</span>
|
||||||
|
<span>채점</span>
|
||||||
<span>메모</span>
|
<span>메모</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +153,14 @@ const PurchasePanel = ({
|
|||||||
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||||
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="lotto-purchase-row__hits">
|
||||||
|
{(rec.results || []).map((r, i) => (
|
||||||
|
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
|
||||||
|
))}
|
||||||
|
{(rec.results || []).some((r) => r.correct >= 4) && (
|
||||||
|
<span className="prize-flag">🚨 4등↑ 확인 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||||
<div className="lotto-purchase-row__actions">
|
<div className="lotto-purchase-row__actions">
|
||||||
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||||
|
|||||||
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getReviewHistory } from '../../../api';
|
||||||
|
|
||||||
|
export default function PurchaseTrendChart() {
|
||||||
|
const [reviews, setReviews] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (reviews.length === 0) return null;
|
||||||
|
|
||||||
|
const maxAvg = Math.max(
|
||||||
|
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
|
||||||
|
2.5
|
||||||
|
);
|
||||||
|
const w = 320, h = 80, pad = 16;
|
||||||
|
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
|
||||||
|
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
|
||||||
|
|
||||||
|
const line = (key) => reviews
|
||||||
|
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
|
||||||
|
.filter(p => p.y != null)
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
|
||||||
|
<h3>너 vs 큐레이터 평균 일치 수</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width={w} height={h} className="trend-chart">
|
||||||
|
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
|
||||||
|
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<div className="trend-legend">
|
||||||
|
<span><span className="dot dot--curator" /> 큐레이터</span>
|
||||||
|
<span><span className="dot dot--user" /> 너</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { bulkPurchase } from '../../../../api';
|
||||||
|
import { MODES } from './TierModeToggle';
|
||||||
|
|
||||||
|
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await bulkPurchase({
|
||||||
|
draw_no: drawNo,
|
||||||
|
tier_mode: tierMode,
|
||||||
|
sets: mode.sets,
|
||||||
|
amount: mode.amount,
|
||||||
|
});
|
||||||
|
onSuccess?.();
|
||||||
|
alert(`${mode.sets}세트 구매 기록 완료!`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`구매 기록 실패: ${e?.message || e}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
|
||||||
|
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import RetrospectiveBox from './RetrospectiveBox';
|
||||||
|
import TierModeToggle, { MODES } from './TierModeToggle';
|
||||||
|
import TierSection from './TierSection';
|
||||||
|
import BulkPurchaseButton from './BulkPurchaseButton';
|
||||||
|
import './decision.css';
|
||||||
|
|
||||||
|
const TIER_CHAIN = {
|
||||||
|
core: ['core'],
|
||||||
|
core_bonus: ['core', 'bonus'],
|
||||||
|
core_bonus_extended: ['core', 'bonus', 'extended'],
|
||||||
|
full: ['core', 'bonus', 'extended', 'pool'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'lotto.tier_mode';
|
||||||
|
|
||||||
|
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
|
||||||
|
const [tierMode, setTierMode] = useState(() =>
|
||||||
|
localStorage.getItem(STORAGE_KEY) || 'core'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, tierMode);
|
||||||
|
}, [tierMode]);
|
||||||
|
|
||||||
|
const visibleTiers = TIER_CHAIN[tierMode];
|
||||||
|
|
||||||
|
const totalSets = useMemo(
|
||||||
|
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
|
||||||
|
[briefing, visibleTiers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 분배 칩 — 보이는 계층의 risk_tag 합산
|
||||||
|
const balance = useMemo(() => {
|
||||||
|
const acc = { '안정': 0, '균형': 0, '공격': 0 };
|
||||||
|
for (const t of visibleTiers) {
|
||||||
|
for (const p of (briefing?.picks?.[t] || [])) {
|
||||||
|
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [briefing, visibleTiers]);
|
||||||
|
|
||||||
|
if (!briefing) return null;
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lc-card">
|
||||||
|
<header className="lc-head">
|
||||||
|
<div>
|
||||||
|
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}회</p>
|
||||||
|
<h3 className="lc-title">{briefing.narrative.headline}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="lc-conf">
|
||||||
|
<div className="lc-conf__num">{briefing.confidence}</div>
|
||||||
|
<div className="lc-conf__lbl">CONFIDENCE</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RetrospectiveBox briefing={briefing} review={review} />
|
||||||
|
|
||||||
|
<p className="lc-headline-3">
|
||||||
|
{(briefing.narrative.summary_3lines || []).join(' · ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="lc-balance">
|
||||||
|
<div className="lc-balance__chips">
|
||||||
|
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
|
||||||
|
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
|
||||||
|
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TierModeToggle value={tierMode} onChange={setTierMode} />
|
||||||
|
|
||||||
|
{visibleTiers.map(tier => {
|
||||||
|
const picks = briefing.picks?.[tier] || [];
|
||||||
|
const idxBase = cursor;
|
||||||
|
cursor += picks.length;
|
||||||
|
return (
|
||||||
|
<TierSection
|
||||||
|
key={tier}
|
||||||
|
tier={tier}
|
||||||
|
picks={picks}
|
||||||
|
rationale={briefing.tier_rationale?.[tier]}
|
||||||
|
indexBase={idxBase}
|
||||||
|
totalSets={totalSets}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="lc-actions">
|
||||||
|
<BulkPurchaseButton
|
||||||
|
drawNo={briefing.draw_no}
|
||||||
|
tierMode={tierMode}
|
||||||
|
onSuccess={onPurchaseSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
|
||||||
|
|
||||||
|
export default function PickCard({ pick, index, total }) {
|
||||||
|
const role = pick.risk_tag;
|
||||||
|
return (
|
||||||
|
<div className="lc-set">
|
||||||
|
<div className="lc-set__head">
|
||||||
|
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}>● {role}</span>
|
||||||
|
<span className="lc-set__idx">Set {index + 1} / {total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lc-balls">
|
||||||
|
{pick.numbers.map(n => (
|
||||||
|
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="lc-set__reason">{pick.reason}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function RetrospectiveBox({ briefing, review }) {
|
||||||
|
const retro = briefing?.narrative?.retrospective;
|
||||||
|
if (!retro) return null;
|
||||||
|
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
|
||||||
|
return (
|
||||||
|
<aside className="lc-retro">
|
||||||
|
<p className="lc-retro__time">▸ 지난 주 {drawNo ? `${drawNo}회` : ''} 회고</p>
|
||||||
|
<p className="lc-retro__body">{retro}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const MODES = [
|
||||||
|
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
|
||||||
|
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
|
||||||
|
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
|
||||||
|
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TierModeToggle({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="lc-toggle" role="tablist">
|
||||||
|
{MODES.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === m.key}
|
||||||
|
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(m.key)}
|
||||||
|
>
|
||||||
|
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
|
||||||
|
<span className="lc-toggle__lbl">{m.label}</span>
|
||||||
|
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}원</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MODES };
|
||||||
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import PickCard from './PickCard';
|
||||||
|
|
||||||
|
const TIER_TITLE = {
|
||||||
|
core: '코어 (필수, 5세트)',
|
||||||
|
bonus: '보너스 (+5)',
|
||||||
|
extended: '확장 (+5)',
|
||||||
|
pool: '풀 (+5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
|
||||||
|
if (!picks?.length) return null;
|
||||||
|
return (
|
||||||
|
<section className={`lc-tier lc-tier--${tier}`}>
|
||||||
|
<header className="lc-tier__head">
|
||||||
|
<h4>{TIER_TITLE[tier]}</h4>
|
||||||
|
{rationale && tier !== 'core' && (
|
||||||
|
<p className="lc-tier__rationale">{rationale}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{picks.map((p, i) => (
|
||||||
|
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/pages/lotto/components/decision/decision.css
Normal file
52
src/pages/lotto/components/decision/decision.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
|
||||||
|
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
|
||||||
|
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
|
||||||
|
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
|
||||||
|
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
|
||||||
|
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
|
||||||
|
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
|
||||||
|
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
|
||||||
|
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
|
||||||
|
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
|
||||||
|
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
|
||||||
|
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
|
||||||
|
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
|
||||||
|
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
|
||||||
|
.lc-balance__chips { display: flex; gap: 8px; }
|
||||||
|
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
|
||||||
|
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
|
||||||
|
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
|
||||||
|
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
|
||||||
|
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
|
||||||
|
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||||
|
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
|
||||||
|
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
|
||||||
|
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
|
||||||
|
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
|
||||||
|
.lc-tier { margin-bottom: 14px; }
|
||||||
|
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
|
||||||
|
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
|
||||||
|
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
|
||||||
|
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
|
||||||
|
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
|
||||||
|
padding: 14px; margin-bottom: 10px; }
|
||||||
|
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
|
||||||
|
.lc-set__role--stable { color: #76e09a; }
|
||||||
|
.lc-set__role--balance { color: #ffce6e; }
|
||||||
|
.lc-set__role--aggro { color: #ff8aa0; }
|
||||||
|
.lc-set__idx { font-size: 10px; opacity: 0.4; }
|
||||||
|
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
|
||||||
|
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||||
|
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
|
||||||
|
font-size: 14px; min-width: 160px; }
|
||||||
|
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
|
||||||
|
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
||||||
|
|
||||||
|
const normalizePicks = (picks) => {
|
||||||
|
if (Array.isArray(picks)) {
|
||||||
|
return { core: picks, bonus: [], extended: [], pool: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
core: picks?.core || [],
|
||||||
|
bonus: picks?.bonus || [],
|
||||||
|
extended: picks?.extended || [],
|
||||||
|
pool: picks?.pool || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function useBriefing() {
|
export default function useBriefing() {
|
||||||
const [briefing, setBriefing] = useState(null);
|
const [briefing, setBriefing] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -12,7 +24,7 @@ export default function useBriefing() {
|
|||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const data = await getLatestBriefing();
|
const data = await getLatestBriefing();
|
||||||
setBriefing(data);
|
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,7 +45,7 @@ export default function useBriefing() {
|
|||||||
try {
|
try {
|
||||||
const data = await getLatestBriefing();
|
const data = await getLatestBriefing();
|
||||||
if (data && data.generated_at !== prevGen) {
|
if (data && data.generated_at !== prevGen) {
|
||||||
setBriefing(data);
|
setBriefing({ ...data, picks: normalizePicks(data.picks) });
|
||||||
setRegenerating(false);
|
setRegenerating(false);
|
||||||
clearInterval(pollingRef.current);
|
clearInterval(pollingRef.current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||||
|
bulkPurchase as apiBulkPurchase,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import { emptyPurchaseForm } from '../lottoUtils';
|
import { emptyPurchaseForm } from '../lottoUtils';
|
||||||
|
|
||||||
@@ -94,6 +95,12 @@ export default function usePurchases() {
|
|||||||
} catch { refreshPurchases(); }
|
} catch { refreshPurchases(); }
|
||||||
}, [refreshPurchases]);
|
}, [refreshPurchases]);
|
||||||
|
|
||||||
|
const handleBulkPurchase = useCallback(async (params) => {
|
||||||
|
const result = await apiBulkPurchase(params);
|
||||||
|
await refreshPurchases();
|
||||||
|
return result;
|
||||||
|
}, [refreshPurchases]);
|
||||||
|
|
||||||
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,5 +108,6 @@ export default function usePurchases() {
|
|||||||
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||||
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||||
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||||
|
handleBulkPurchase,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/pages/lotto/hooks/useReview.js
Normal file
23
src/pages/lotto/hooks/useReview.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getLatestReview, getReviewHistory } from '../../../api';
|
||||||
|
|
||||||
|
export default function useReview() {
|
||||||
|
const [latest, setLatest] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancel = false;
|
||||||
|
Promise.all([getLatestReview(), getReviewHistory(4)])
|
||||||
|
.then(([l, h]) => {
|
||||||
|
if (cancel) return;
|
||||||
|
setLatest(l);
|
||||||
|
setHistory(h);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => !cancel && setLoading(false));
|
||||||
|
return () => { cancel = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { latest, history, loading };
|
||||||
|
}
|
||||||
@@ -40,18 +40,21 @@ export default function AnalysisTab() {
|
|||||||
<PerformanceBanner perf={ld.perfStats} />
|
<PerformanceBanner perf={ld.perfStats} />
|
||||||
|
|
||||||
{/* 종합 추론 번호 추천 */}
|
{/* 종합 추론 번호 추천 */}
|
||||||
<CombinedRecommendPanel
|
<details className="lotto-section-fold">
|
||||||
combined={ld.combined}
|
<summary>종합 추론 추천</summary>
|
||||||
history={ld.combinedHistory}
|
<CombinedRecommendPanel
|
||||||
loading={ld.combinedLoading}
|
combined={ld.combined}
|
||||||
histLoading={ld.combinedHistLoading}
|
history={ld.combinedHistory}
|
||||||
onRun={ld.runCombinedRecommend}
|
loading={ld.combinedLoading}
|
||||||
onCopy={copyNumbers}
|
histLoading={ld.combinedHistLoading}
|
||||||
/>
|
onRun={ld.runCombinedRecommend}
|
||||||
|
onCopy={copyNumbers}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 최신 회차 + 시뮬레이션 추천 */}
|
{/* 최신 회차 */}
|
||||||
<div className="lotto-grid">
|
<details className="lotto-section-fold">
|
||||||
{/* Latest Draw */}
|
<summary>최신 회차</summary>
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -87,8 +90,11 @@ export default function AnalysisTab() {
|
|||||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* Simulation Picks */}
|
{/* Simulation Picks */}
|
||||||
|
<details className="lotto-section-fold">
|
||||||
|
<summary>시뮬레이션 추천</summary>
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -163,19 +169,24 @@ export default function AnalysisTab() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
{/* 이번 주 공략 리포트 */}
|
{/* 이번 주 공략 리포트 */}
|
||||||
<ReportPanel
|
<details className="lotto-section-fold">
|
||||||
report={ld.report}
|
<summary>이번 주 공략 리포트</summary>
|
||||||
history={ld.reportHistory}
|
<ReportPanel
|
||||||
loading={ld.reportLoading}
|
report={ld.report}
|
||||||
onRefresh={ld.refreshReport}
|
history={ld.reportHistory}
|
||||||
onSelectDrw={ld.loadSpecificReport}
|
loading={ld.reportLoading}
|
||||||
/>
|
onRefresh={ld.refreshReport}
|
||||||
|
onSelectDrw={ld.loadSpecificReport}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 통계 분석 */}
|
{/* 통계 분석 */}
|
||||||
<section className="lotto-panel lotto-panel--wide">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>통계 분석</summary>
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Analysis</p>
|
<p className="lotto-panel__eyebrow">Analysis</p>
|
||||||
@@ -237,9 +248,12 @@ export default function AnalysisTab() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 전체 번호 분포 */}
|
{/* 전체 번호 분포 */}
|
||||||
<section className="lotto-panel lotto-panel--wide">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>전체 회차 번호 분포</summary>
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||||
@@ -263,12 +277,18 @@ export default function AnalysisTab() {
|
|||||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 내 번호 패턴 */}
|
{/* 내 번호 패턴 */}
|
||||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
<details className="lotto-section-fold">
|
||||||
|
<summary>내 번호 패턴</summary>
|
||||||
|
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 수동 추천 */}
|
{/* 수동 추천 */}
|
||||||
<section className="lotto-panel">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>수동 추천</summary>
|
||||||
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
||||||
@@ -365,9 +385,12 @@ export default function AnalysisTab() {
|
|||||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 추천 히스토리 */}
|
{/* 추천 히스토리 */}
|
||||||
<section className="lotto-panel">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>추천 히스토리</summary>
|
||||||
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">History</p>
|
<p className="lotto-panel__eyebrow">History</p>
|
||||||
@@ -423,6 +446,7 @@ export default function AnalysisTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import useBriefing from '../hooks/useBriefing';
|
import useBriefing from '../hooks/useBriefing';
|
||||||
import BriefingHeader from '../components/briefing/BriefingHeader';
|
import useReview from '../hooks/useReview';
|
||||||
import BriefingSummary from '../components/briefing/BriefingSummary';
|
import DecisionCard from '../components/decision/DecisionCard';
|
||||||
import PickSetCard from '../components/briefing/PickSetCard';
|
|
||||||
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
||||||
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
|
|
||||||
|
|
||||||
export default function BriefingTab() {
|
export default function BriefingTab() {
|
||||||
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
|
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
|
||||||
|
const { latest: review } = useReview();
|
||||||
|
|
||||||
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
|
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
|
||||||
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
|
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="briefing-tab">
|
<div className="briefing-tab">
|
||||||
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
|
<DecisionCard briefing={briefing} review={review} />
|
||||||
<BriefingSummary narrative={briefing.narrative} />
|
|
||||||
<div className="briefing-picks">
|
|
||||||
<h3>이번 주 5세트</h3>
|
|
||||||
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
|
|
||||||
</div>
|
|
||||||
<CuratorUsageFooter />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import usePurchases from '../hooks/usePurchases';
|
import usePurchases from '../hooks/usePurchases';
|
||||||
import PurchasePanel from '../components/PurchasePanel';
|
import PurchasePanel from '../components/PurchasePanel';
|
||||||
|
import PurchaseTrendChart from '../components/PurchaseTrendChart';
|
||||||
|
|
||||||
export default function PurchaseTab() {
|
export default function PurchaseTab() {
|
||||||
const pur = usePurchases();
|
const pur = usePurchases();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PurchasePanel
|
<>
|
||||||
records={pur.purchases}
|
<PurchaseTrendChart />
|
||||||
stats={pur.purchaseStats}
|
<PurchasePanel
|
||||||
loading={pur.purchaseLoading}
|
records={pur.purchases}
|
||||||
formOpen={pur.purchaseFormOpen}
|
stats={pur.purchaseStats}
|
||||||
form={pur.purchaseForm}
|
loading={pur.purchaseLoading}
|
||||||
formSaving={pur.purchaseFormSaving}
|
formOpen={pur.purchaseFormOpen}
|
||||||
formError={pur.purchaseFormError}
|
form={pur.purchaseForm}
|
||||||
editId={pur.purchaseEditId}
|
formSaving={pur.purchaseFormSaving}
|
||||||
onFormOpen={pur.handlePurchaseFormOpen}
|
formError={pur.purchaseFormError}
|
||||||
onFormClose={pur.handlePurchaseFormClose}
|
editId={pur.purchaseEditId}
|
||||||
onFormChange={pur.handlePurchaseFormChange}
|
onFormOpen={pur.handlePurchaseFormOpen}
|
||||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
onFormClose={pur.handlePurchaseFormClose}
|
||||||
onEditStart={pur.handlePurchaseEditStart}
|
onFormChange={pur.handlePurchaseFormChange}
|
||||||
onDelete={pur.handlePurchaseDelete}
|
onFormSubmit={pur.handlePurchaseFormSubmit}
|
||||||
/>
|
onEditStart={pur.handlePurchaseEditStart}
|
||||||
|
onDelete={pur.handlePurchaseDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user