Compare commits

5 Commits

15 changed files with 783 additions and 452 deletions

View File

@@ -222,7 +222,32 @@ handleGenerate()
## Lotto 고도화 (`/lotto`)
`src/pages/lotto/Functions.jsx`에 4개 신규 섹션 추가:
`src/pages/lotto/Functions.jsx`는 3탭 구조 (`브리핑 / 분석·통계 / 구매·성과`)로 리팩토링되었습니다.
| 탭 | 파일 | 설명 |
|----|------|------|
| 이번 주 브리핑 | `tabs/BriefingTab.jsx` | AI 큐레이터 브리핑 표시 (`components/briefing/` 하위 컴포넌트) |
| 분석·통계 | `tabs/AnalysisTab.jsx` | 시뮬레이션 추천·통계·ReportPanel·수동 추천 |
| 구매·성과 | `tabs/PurchaseTab.jsx` | 구매 내역 CRUD + 성과 통계 |
### 브리핑 전용 컴포넌트 (`components/briefing/`)
| 컴포넌트 | 설명 |
|----------|------|
| `BriefingTab.jsx` | 탭 루트, 브리핑 로드 + 트리거 |
| `BriefingHeader.jsx` | 회차·생성일시 헤더 |
| `BriefingSummary.jsx` | 내러티브 요약 표시 |
| `PickSetCard.jsx` | 번호 세트 1장 카드 |
| `BriefingEmpty.jsx` | 브리핑 없을 때 빈 상태 |
| `CuratorUsageFooter.jsx` | 토큰·비용 집계 푸터 |
### 신규 api.js 헬퍼
- `getLatestBriefing()``GET /api/lotto/briefing/latest`
- `getCuratorUsage(days)``GET /api/lotto/curator/usage?days=N`
- `triggerLottoCurate()``POST /api/agent-office/command` (lotto_agent curate 명령)
### 기존 섹션 (AnalysisTab 내)
| 섹션 | API | 설명 |
|------|-----|------|

View File

@@ -601,3 +601,28 @@ export const getAgentStates = () => apiGet('/api/agent-office/state
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
// --- Lotto Briefing ---
export async function getLatestBriefing() {
const r = await fetch('/api/lotto/briefing/latest');
if (r.status === 404) return null;
if (!r.ok) throw new Error(`briefing fetch failed: ${r.status}`);
return r.json();
}
export async function getCuratorUsage(days = 30) {
const r = await fetch(`/api/lotto/curator/usage?days=${days}`);
if (!r.ok) throw new Error(`usage fetch failed: ${r.status}`);
return r.json();
}
export async function triggerLottoCurate() {
const r = await fetch('/api/agent-office/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: 'lotto', action: 'curate_now', params: {} }),
});
if (!r.ok) throw new Error(`curate trigger failed: ${r.status}`);
return r.json();
}

View File

@@ -1,460 +1,32 @@
import React, { useMemo } from 'react';
import {
fmtKST, Ball, NumberRow, copyNumbers,
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
} from './lottoUtils';
import { useState } from 'react';
import BriefingTab from './tabs/BriefingTab';
import AnalysisTab from './tabs/AnalysisTab';
import PurchaseTab from './tabs/PurchaseTab';
/* ── hooks ──────────────────────────────────────────────────────── */
import useLottoData from './hooks/useLottoData';
import usePurchases from './hooks/usePurchases';
import useManualRecommend from './hooks/useManualRecommend';
const TABS = [
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
{ id: 'analysis', label: '📊 분석·통계' },
{ id: 'purchase', label: '💰 구매·성과' },
];
/* ── components ─────────────────────────────────────────────────── */
import MetricBlock from './components/MetricBlock';
import FrequencyChart from './components/FrequencyChart';
import PerformanceBanner from './components/PerformanceBanner';
import CombinedRecommendPanel from './components/CombinedRecommendPanel';
import ReportPanel from './components/ReportPanel';
import PersonalAnalysisPanel from './components/PersonalAnalysisPanel';
import PurchasePanel from './components/PurchasePanel';
/* ── component ──────────────────────────────────────────────────── */
export default function Functions() {
const ld = useLottoData();
const pur = usePurchases();
const mr = useManualRecommend();
/* ── derived ────────────────────────────────────────────────── */
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
/* ── merged error ───────────────────────────────────────────── */
const error = ld.error || mr.error;
const clearError = () => { ld.setError(''); mr.setError(''); };
/* ── render ──────────────────────────────────────────────────── */
const [tab, setTab] = useState('briefing');
return (
<div className="lotto-functions">
{error ? (
<div className="lotto-alert">
<div>
<p className="lotto-alert__title">오류</p>
<p className="lotto-alert__message">{error}</p>
</div>
<button className="button ghost small" onClick={clearError}>닫기</button>
</div>
) : null}
{/* ── 신뢰도 배너 ── */}
<PerformanceBanner perf={ld.perfStats} />
{/* ── 종합 추론 번호 추천 ── */}
<CombinedRecommendPanel
combined={ld.combined}
history={ld.combinedHistory}
loading={ld.combinedLoading}
histLoading={ld.combinedHistLoading}
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
{/* ── 최신 회차 + 시뮬레이션 추천 ── */}
<div className="lotto-grid">
{/* Latest Draw */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Latest Draw</p>
<h3>최신 회차</h3>
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.latest ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
새로고침
</button>
</div>
</div>
{ld.latest ? (
<>
<div className="lotto-meta">
<div>
<p className="lotto-meta__title">{ld.latest.drawNo}</p>
<p className="lotto-meta__date">{ld.latest.date}</p>
</div>
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={ld.latest.numbers} />
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
{overallMetrics && (
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
)}
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
{/* Simulation Picks */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Simulation Picks</p>
<h3>시뮬레이션 추천</h3>
<p className="lotto-panel__sub">
하루 6 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 </span> : null}
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 </span> : null}
<button className="button ghost small" onClick={ld.refreshBestPicks}
disabled={ld.loading.bestPicks || ld.simulating}>
새로고침
</button>
<button className="button small" onClick={ld.onSimulate}
disabled={ld.simulating || ld.loading.bestPicks}>
{ld.simulating ? '실행 중...' : '지금 실행'}
</button>
</div>
</div>
{ld.simResult && (
<div className="lotto-sim-result">
<p>완료: {ld.simResult.total_generated?.toLocaleString()} 후보 상위 {ld.simResult.best_n_saved} 저장</p>
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
</div>
)}
{ld.bestPicks.length === 0 ? (
<p className="lotto-empty">
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
</p>
) : (
<>
<div className="lotto-picks">
{visibleBestPicks.map((pick) => (
<div key={pick.id} className="lotto-pick">
<span className="lotto-pick__rank">#{pick.rank}</span>
<div className="lotto-pick__content">
<NumberRow nums={pick.numbers} />
<div className="lotto-pick__score">
<span className="lotto-pick__score-label">
{((pick.score_total ?? 0) * 100).toFixed(1)}%
</span>
<div className="lotto-pick__bar">
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
</div>
</div>
</div>
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
복사
</button>
</div>
))}
</div>
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
<button
className="button ghost small lotto-history-toggle"
onClick={() => ld.setBestPicksExpanded((p) => !p)}
aria-expanded={ld.bestPicksExpanded}
>
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<p className="lotto-panel__sub">
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
</p>
</>
)}
</section>
<nav className="lotto-tabs">
{TABS.map(t => (
<button
key={t.id}
className={tab === t.id ? 'active' : ''}
onClick={() => setTab(t.id)}
>{t.label}</button>
))}
</nav>
<div className="lotto-tab-body">
{tab === 'briefing' && <BriefingTab />}
{tab === 'analysis' && <AnalysisTab />}
{tab === 'purchase' && <PurchaseTab />}
</div>
{/* ── 이번 주 공략 리포트 ── */}
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
{/* ── 통계 분석 ── */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
<h3>통계 분석</h3>
<p className="lotto-panel__sub">빈도, Z-score, 분석으로 번호를 분류합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.analysis ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
새로고침
</button>
</div>
</div>
{ld.analysis ? (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🔥 번호 <span>출현 빈도 상위 10</span></p>
<div className="lotto-row">
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
<div className="lotto-row">
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label"> 오버듀 번호 <span>오래 나온 번호 (회차 )</span></p>
<div className="lotto-row">
{(ld.analysis.overdue_numbers ?? []).map((n) => {
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
return (
<div key={n} className="lotto-overdue">
<Ball n={n} />
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="lotto-analysis__stats">
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
<span>
홀수 3:짝수 3 확률{' '}
<strong>
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
</strong>
</span>
</div>
</div>
) : (
<p className="lotto-empty">
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
</p>
)}
</section>
{/* ── 전체 번호 분포 ── */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.statsLoading ? <span className="lotto-chip">로딩 </span> : null}
{ld.stats?.total_draws ? (
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
) : null}
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
새로고침
</button>
</div>
</div>
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
{ld.stats ? (
<FrequencyChart stats={ld.stats} />
) : (
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
{/* ── 내 번호 패턴 ── */}
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
{/* ── 구매 기록 ── */}
<PurchasePanel
records={pur.purchases}
stats={pur.purchaseStats}
loading={pur.purchaseLoading}
formOpen={pur.purchaseFormOpen}
form={pur.purchaseForm}
formSaving={pur.purchaseFormSaving}
formError={pur.purchaseFormError}
editId={pur.purchaseEditId}
onFormOpen={pur.handlePurchaseFormOpen}
onFormClose={pur.handlePurchaseFormClose}
onFormChange={pur.handlePurchaseFormChange}
onFormSubmit={pur.handlePurchaseFormSubmit}
onEditStart={pur.handlePurchaseEditStart}
onDelete={pur.handlePurchaseDelete}
/>
{/* ── 수동 추천 ── */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
<h3>수동 추천</h3>
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{mr.loading.recommend ? <span className="lotto-chip">계산 </span> : null}
</div>
</div>
<div className="lotto-presets">
{mr.presets.map((preset) => (
<button key={preset.name} className="button ghost small"
onClick={() => mr.setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
})}>
{preset.name}
</button>
))}
</div>
<div className="lotto-form">
<label className="lotto-field">
recent_window <span>최근 N회차 가중치 범위</span>
<input type="number" min={20} max={1000} value={mr.params.recent_window}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
recent_weight <span>최근 회차 가중치</span>
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
avoid_recent_k <span>최근 K회차 중복 회피</span>
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
</label>
</div>
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
추천 받기
</button>
{mr.result ? (
<div className="lotto-result">
<div className="lotto-result__meta">
<div>
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
</div>
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
번호 복사
</button>
</div>
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
{mr.historyMetrics && (
<div className="lotto-compare">
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
</div>
)}
{Array.isArray(mr.result.items) && mr.result.items.length ? (
<details className="lotto-details">
<summary>추천 후보 보기</summary>
<div className="lotto-batch">
{mr.result.items.map((item, idx) => (
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
<div className="lotto-batch__meta">
<div>
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
</div>
<NumberRow nums={item.numbers} />
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
</div>
))}
</div>
</details>
) : null}
{mr.result.explain && (
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
</details>
)}
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
{/* ── 추천 히스토리 ── */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
<span className="lotto-chip">{mr.history.length}</span>
{mr.history.length > 5 && (
<button className="button ghost small lotto-history-toggle"
onClick={() => mr.setHistoryExpanded((p) => !p)}
aria-expanded={mr.historyExpanded}>
{mr.historyExpanded ? '접기' : '더보기'}
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
새로고침
</button>
</div>
</div>
{mr.loading.history ? <p className="lotto-empty">불러오는 ...</p> : null}
{mr.history.length === 0 ? (
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
) : (
<div className="lotto-history">
{mr.visibleHistory.map((item) => (
<div key={item.id} className="lotto-history__item">
<div className="lotto-history__meta">
<p>#{item.id}</p>
<p>{fmtKST(item.created_at)}</p>
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<div className="lotto-history__body">
<NumberRow nums={item.numbers} />
<p className="lotto-history__params">
window={item.params?.recent_window}, weight={item.params?.recent_weight},
avoid_k={item.params?.avoid_recent_k}
</p>
</div>
<div className="lotto-history__actions">
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
삭제
</button>
</div>
</div>
))}
<span ref={mr.historyEndRef} />
</div>
)}
</section>
<footer className="lotto-foot">
backend: FastAPI / nginx proxy / DB: sqlite ·{' '}
<a className="lotto-foot__link" href="/lotto-api.md" download>API 스펙 다운로드</a>
</footer>
</div>
);
}

View File

@@ -1475,3 +1475,47 @@
gap: 10px;
}
}
/* ── Briefing UI ──────────────────────────────────────────────────────────── */
.briefing-header { padding: 16px; border-radius: 12px; background: rgba(129,140,248,0.08); margin-bottom: 16px; }
.briefing-header-row { display: flex; justify-content: space-between; align-items: center; }
.briefing-meta { display: flex; gap: 12px; color: #94a3b8; font-size: 0.85rem; margin-top: 4px; flex-wrap: wrap; }
.briefing-confidence strong { color: #e2e8f0; }
.briefing-tokens { font-family: monospace; }
.briefing-confidence-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden; }
.briefing-confidence-bar > div { height: 100%; background: linear-gradient(90deg, #818cf8, #34d399); transition: width .3s; }
.briefing-summary { padding: 12px 16px; background: rgba(0,0,0,0.2); border-radius: 10px; margin-bottom: 16px; }
.briefing-summary h3 { margin: 0 0 8px; }
.briefing-3lines { margin: 0; padding-left: 20px; }
.briefing-hotcold { color: #fbbf24; margin-top: 8px; }
.briefing-warning { color: #f87171; margin-top: 8px; }
.pick-card { padding: 12px; border-radius: 10px; background: rgba(255,255,255,0.04); border-left: 3px solid #64748b; margin-bottom: 8px; }
.pick-card--안정 { border-left-color: #34d399; }
.pick-card--균형 { border-left-color: #fbbf24; }
.pick-card--공격 { border-left-color: #f87171; }
.pick-card-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
.pick-card-balls { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.ball { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; color: #fff; }
.ball--1 { background: #fbbf24; } .ball--2 { background: #60a5fa; } .ball--3 { background: #f87171; }
.ball--4 { background: #94a3b8; } .ball--5 { background: #34d399; }
.pick-card-reason { margin: 0; font-size: 0.85rem; color: #cbd5e1; }
.briefing-empty { text-align: center; padding: 40px 20px; color: #94a3b8; }
.briefing-empty button { margin-top: 12px; padding: 8px 20px; }
.briefing-empty-hint { font-size: 0.85rem; }
.briefing-error { color: #f87171; margin-top: 8px; }
.curator-usage-footer { display: flex; gap: 12px; padding: 10px 14px; background: rgba(0,0,0,0.25); border-radius: 8px; font-size: 0.8rem; color: #94a3b8; margin-top: 24px; flex-wrap: wrap; font-family: monospace; }
@media (max-width: 768px) {
.briefing-meta { font-size: 0.75rem; }
.briefing-tokens { width: 100%; }
.pick-card-balls { justify-content: center; }
}
/* ── Tab navigation ───────────────────────────────────────────────────────── */
.lotto-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.lotto-tabs button { padding: 8px 16px; background: transparent; border: none; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; }
.lotto-tabs button.active { color: #e2e8f0; border-bottom-color: #818cf8; }
.lotto-tab-body { padding-top: 8px; }
@media (max-width: 768px) {
.lotto-tabs { overflow-x: auto; }
.lotto-tabs button { white-space: nowrap; }
}

View File

@@ -0,0 +1,12 @@
export default function BriefingEmpty({ regenerating, onRegenerate, error }) {
return (
<div className="briefing-empty">
<p>아직 이번 브리핑이 없습니다.</p>
<p className="briefing-empty-hint">매주 월요일 07:00 자동 생성됩니다.</p>
<button onClick={onRegenerate} disabled={regenerating}>
{regenerating ? '⏳ 생성 중...' : '지금 생성'}
</button>
{error && <p className="briefing-error"> {error}</p>}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
export default function BriefingHeader({ briefing, regenerating, onRegenerate }) {
const cost = estimateCost(briefing);
const genDate = new Date(briefing.generated_at).toLocaleString('ko-KR');
return (
<div className="briefing-header">
<div className="briefing-header-row">
<h2>🗓 #{briefing.draw_no} 브리핑</h2>
<button onClick={onRegenerate} disabled={regenerating}>
{regenerating ? '⏳ 생성 중...' : '🔄 다시 생성'}
</button>
</div>
<div className="briefing-meta">
<span>{genDate}</span>
<span className="briefing-confidence">
신뢰도 <strong>{briefing.confidence}</strong>/100
</span>
<span className="briefing-tokens">
{fmtTokens(briefing.tokens_input)} in · {fmtTokens(briefing.tokens_output)} out · {fmtUsd(cost)}
</span>
</div>
<div className="briefing-confidence-bar">
<div style={{ width: `${briefing.confidence}%` }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function BriefingSummary({ narrative }) {
return (
<div className="briefing-summary">
<h3>{narrative.headline}</h3>
<ul className="briefing-3lines">
{narrative.summary_3lines.map((line, i) => <li key={i}>{line}</li>)}
</ul>
{narrative.hot_cold_comment && (
<p className="briefing-hotcold">🔥 {narrative.hot_cold_comment}</p>
)}
{narrative.warnings && (
<p className="briefing-warning"> {narrative.warnings}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import useCuratorUsage from '../../hooks/useCuratorUsage';
import { estimateCost, fmtUsd, fmtTokens } from './pricing';
export default function CuratorUsageFooter() {
const { usage } = useCuratorUsage(30);
if (!usage) return null;
const cost = estimateCost(usage);
return (
<div className="curator-usage-footer">
<span>최근 30 큐레이터:</span>
<span>{usage.calls} 호출</span>
<span>{fmtTokens(usage.tokens_input + usage.tokens_output)} tokens</span>
<span>{fmtUsd(cost)}</span>
<span>캐시 {(usage.cache_hit_rate * 100).toFixed(0)}%</span>
</div>
);
}

View File

@@ -0,0 +1,18 @@
const RISK_BADGE = { '안정': '🟢', '균형': '🟡', '공격': '🔴' };
export default function PickSetCard({ pick, index }) {
return (
<div className={`pick-card pick-card--${pick.risk_tag}`}>
<div className="pick-card-header">
<span className="pick-card-index">Set {index + 1}</span>
<span className="pick-card-risk">{RISK_BADGE[pick.risk_tag] || '⚪'} {pick.risk_tag}</span>
</div>
<div className="pick-card-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="pick-card-reason">{pick.reason}</p>
</div>
);
}

View File

@@ -0,0 +1,23 @@
const IN_PER_M = 3.00;
const OUT_PER_M = 15.00;
const CACHE_READ_PER_M = 0.30;
const CACHE_WRITE_PER_M = 3.75;
export function estimateCost({ tokens_input = 0, tokens_output = 0, cache_read = 0, cache_write = 0 }) {
const usd =
(tokens_input / 1_000_000) * IN_PER_M +
(tokens_output / 1_000_000) * OUT_PER_M +
(cache_read / 1_000_000) * CACHE_READ_PER_M +
(cache_write / 1_000_000) * CACHE_WRITE_PER_M;
return usd;
}
export function fmtUsd(usd) {
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(3)}`;
}
export function fmtTokens(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}

View File

@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
export default function useBriefing() {
const [briefing, setBriefing] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [regenerating, setRegenerating] = useState(false);
const pollingRef = useRef(null);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await getLatestBriefing();
setBriefing(data);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const regenerate = useCallback(async () => {
setRegenerating(true); setError('');
try {
const prevGen = briefing?.generated_at;
await triggerLottoCurate();
let attempts = 0;
pollingRef.current = setInterval(async () => {
attempts += 1;
try {
const data = await getLatestBriefing();
if (data && data.generated_at !== prevGen) {
setBriefing(data);
setRegenerating(false);
clearInterval(pollingRef.current);
}
} catch {}
if (attempts >= 40) {
clearInterval(pollingRef.current);
setRegenerating(false);
setError('재생성 타임아웃 (2분)');
}
}, 3000);
} catch (e) {
setError(e.message);
setRegenerating(false);
}
}, [briefing?.generated_at]);
useEffect(() => () => { if (pollingRef.current) clearInterval(pollingRef.current); }, []);
return { briefing, loading, error, regenerating, reload: load, regenerate };
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
import { getCuratorUsage } from '../../../api';
export default function useCuratorUsage(days = 30) {
const [usage, setUsage] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
getCuratorUsage(days)
.then(d => { if (alive) setUsage(d); })
.catch(e => { if (alive) setError(e.message); });
return () => { alive = false; };
}, [days]);
return { usage, error };
}

View File

@@ -0,0 +1,428 @@
import React, { useMemo } from 'react';
import {
fmtKST, Ball, NumberRow, copyNumbers,
buildMetricsFromFrequency, BEST_PICKS_DEFAULT_SHOW,
} from '../lottoUtils';
import useLottoData from '../hooks/useLottoData';
import useManualRecommend from '../hooks/useManualRecommend';
import MetricBlock from '../components/MetricBlock';
import FrequencyChart from '../components/FrequencyChart';
import PerformanceBanner from '../components/PerformanceBanner';
import CombinedRecommendPanel from '../components/CombinedRecommendPanel';
import ReportPanel from '../components/ReportPanel';
import PersonalAnalysisPanel from '../components/PersonalAnalysisPanel';
export default function AnalysisTab() {
const ld = useLottoData();
const mr = useManualRecommend();
const overallMetrics = useMemo(() => buildMetricsFromFrequency(ld.stats?.frequency), [ld.stats]);
const visibleBestPicks = ld.bestPicksExpanded ? ld.bestPicks : ld.bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW);
const error = ld.error || mr.error;
const clearError = () => { ld.setError(''); mr.setError(''); };
return (
<>
{error ? (
<div className="lotto-alert">
<div>
<p className="lotto-alert__title">오류</p>
<p className="lotto-alert__message">{error}</p>
</div>
<button className="button ghost small" onClick={clearError}>닫기</button>
</div>
) : null}
{/* 신뢰도 배너 */}
<PerformanceBanner perf={ld.perfStats} />
{/* 종합 추론 번호 추천 */}
<CombinedRecommendPanel
combined={ld.combined}
history={ld.combinedHistory}
loading={ld.combinedLoading}
histLoading={ld.combinedHistLoading}
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
{/* 최신 회차 + 시뮬레이션 추천 */}
<div className="lotto-grid">
{/* Latest Draw */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Latest Draw</p>
<h3>최신 회차</h3>
<p className="lotto-panel__sub">최신 회차와 번호를 빠르게 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.latest ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshLatest} disabled={ld.loading.latest}>
새로고침
</button>
</div>
</div>
{ld.latest ? (
<>
<div className="lotto-meta">
<div>
<p className="lotto-meta__title">{ld.latest.drawNo}</p>
<p className="lotto-meta__date">{ld.latest.date}</p>
</div>
<button className="button small" onClick={() => copyNumbers(ld.latest.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={ld.latest.numbers} />
<p className="lotto-bonus">보너스 <strong>{ld.latest.bonus}</strong></p>
{overallMetrics && (
<MetricBlock title="당첨 통계 (전체 회차)" metrics={overallMetrics} />
)}
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
{/* Simulation Picks */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Simulation Picks</p>
<h3>시뮬레이션 추천</h3>
<p className="lotto-panel__sub">
하루 6 몬테카를로 시뮬레이션으로 선별된 최적 번호입니다.
</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.bestPicks ? <span className="lotto-chip">로딩 </span> : null}
{ld.simulating ? <span className="lotto-chip lotto-chip--active">분석 </span> : null}
<button className="button ghost small" onClick={ld.refreshBestPicks}
disabled={ld.loading.bestPicks || ld.simulating}>
새로고침
</button>
<button className="button small" onClick={ld.onSimulate}
disabled={ld.simulating || ld.loading.bestPicks}>
{ld.simulating ? '실행 중...' : '지금 실행'}
</button>
</div>
</div>
{ld.simResult && (
<div className="lotto-sim-result">
<p>완료: {ld.simResult.total_generated?.toLocaleString()} 후보 상위 {ld.simResult.best_n_saved} 저장</p>
<p>최고 점수 {((ld.simResult.best_score ?? 0) * 100).toFixed(1)}% / {((ld.simResult.avg_score ?? 0) * 100).toFixed(1)}%</p>
</div>
)}
{ld.bestPicks.length === 0 ? (
<p className="lotto-empty">
{ld.loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}
</p>
) : (
<>
<div className="lotto-picks">
{visibleBestPicks.map((pick) => (
<div key={pick.id} className="lotto-pick">
<span className="lotto-pick__rank">#{pick.rank}</span>
<div className="lotto-pick__content">
<NumberRow nums={pick.numbers} />
<div className="lotto-pick__score">
<span className="lotto-pick__score-label">
{((pick.score_total ?? 0) * 100).toFixed(1)}%
</span>
<div className="lotto-pick__bar">
<span style={{ width: `${(pick.score_total ?? 0) * 100}%` }} />
</div>
</div>
</div>
<button className="button ghost small" onClick={() => copyNumbers(pick.numbers)}>
복사
</button>
</div>
))}
</div>
{ld.bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
<button
className="button ghost small lotto-history-toggle"
onClick={() => ld.setBestPicksExpanded((p) => !p)}
aria-expanded={ld.bestPicksExpanded}
>
{ld.bestPicksExpanded ? '접기' : `모두 보기 (${ld.bestPicks.length}개)`}
<span className={`lotto-history-toggle__icon ${ld.bestPicksExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<p className="lotto-panel__sub">
갱신: {fmtKST(ld.bestPicks[0]?.created_at) || '-'}
{ld.bestPicks[0]?.based_on_draw ? ` · ${ld.bestPicks[0].based_on_draw}회 기준` : ''}
</p>
</>
)}
</section>
</div>
{/* 이번 주 공략 리포트 */}
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
{/* 통계 분석 */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
<h3>통계 분석</h3>
<p className="lotto-panel__sub">빈도, Z-score, 분석으로 번호를 분류합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.loading.analysis ? <span className="lotto-chip">로딩 </span> : null}
<button className="button ghost small" onClick={ld.refreshAnalysis} disabled={ld.loading.analysis}>
새로고침
</button>
</div>
</div>
{ld.analysis ? (
<div className="lotto-analysis">
<div className="lotto-analysis__row">
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🔥 번호 <span>출현 빈도 상위 10</span></p>
<div className="lotto-row">
{(ld.analysis.hot_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label">🧊 콜드 번호 <span>출현 빈도 하위 10</span></p>
<div className="lotto-row">
{(ld.analysis.cold_numbers ?? []).map((n) => <Ball key={n} n={n} />)}
</div>
</div>
<div className="lotto-analysis__group">
<p className="lotto-analysis__label"> 오버듀 번호 <span>오래 나온 번호 (회차 )</span></p>
<div className="lotto-row">
{(ld.analysis.overdue_numbers ?? []).map((n) => {
const stat = (ld.analysis.number_stats ?? []).find((s) => s.number === n);
return (
<div key={n} className="lotto-overdue">
<Ball n={n} />
<span className="lotto-overdue__gap">{stat?.gap ?? '-'}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="lotto-analysis__stats">
<span>역대 합계 평균 <strong>{ld.analysis.mean_sum}</strong></span>
<span>표준편차 <strong>±{ld.analysis.std_sum}</strong></span>
<span>분석 회차 <strong>{ld.analysis.total_draws?.toLocaleString()}</strong></span>
<span>
홀수 3:짝수 3 확률{' '}
<strong>
{ld.analysis.odd_distribution?.['3'] ? `${ld.analysis.odd_distribution['3']}%` : '-'}
</strong>
</span>
</div>
</div>
) : (
<p className="lotto-empty">
{ld.loading.analysis ? '불러오는 중...' : '통계 분석 데이터가 없습니다.'}
</p>
)}
</section>
{/* 전체 번호 분포 */}
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
<h3>전체 회차 번호 분포</h3>
<p className="lotto-panel__sub">1~45 번호가 등장한 횟수를 기준으로 분포를 표시합니다.</p>
</div>
<div className="lotto-panel__actions">
{ld.statsLoading ? <span className="lotto-chip">로딩 </span> : null}
{ld.stats?.total_draws ? (
<span className="lotto-chip">{ld.stats.total_draws}회차</span>
) : null}
<button className="button ghost small" onClick={ld.refreshStats} disabled={ld.statsLoading}>
새로고침
</button>
</div>
</div>
{ld.statsError ? <p className="lotto-empty">{ld.statsError}</p> : null}
{ld.stats ? (
<FrequencyChart stats={ld.stats} />
) : (
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
{/* 내 번호 패턴 */}
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
{/* 수동 추천 */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
<h3>수동 추천</h3>
<p className="lotto-panel__sub">파라미터를 직접 조정해 번호를 추천받을 있습니다.</p>
</div>
<div className="lotto-panel__actions">
{mr.loading.recommend ? <span className="lotto-chip">계산 </span> : null}
</div>
</div>
<div className="lotto-presets">
{mr.presets.map((preset) => (
<button key={preset.name} className="button ghost small"
onClick={() => mr.setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
})}>
{preset.name}
</button>
))}
</div>
<div className="lotto-form">
<label className="lotto-field">
recent_window <span>최근 N회차 가중치 범위</span>
<input type="number" min={20} max={1000} value={mr.params.recent_window}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
recent_weight <span>최근 회차 가중치</span>
<input type="number" step="0.1" min={0.5} max={10} value={mr.params.recent_weight}
onChange={(e) => mr.setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))} />
</label>
<label className="lotto-field">
avoid_recent_k <span>최근 K회차 중복 회피</span>
<input type="number" min={0} max={50} value={mr.params.avoid_recent_k}
onChange={(e) => mr.setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))} />
</label>
</div>
<button className="button primary" onClick={mr.onRecommend} disabled={mr.loading.recommend}>
추천 받기
</button>
{mr.result ? (
<div className="lotto-result">
<div className="lotto-result__meta">
<div>
<p className="lotto-result__id">추천 ID #{mr.result.id}</p>
<p className="lotto-result__based">기준 회차 {mr.result.based_on_latest_draw ?? '-'}</p>
</div>
<button className="button small" onClick={() => copyNumbers(mr.result.numbers)}>
번호 복사
</button>
</div>
{mr.result.numbers && <NumberRow nums={mr.result.numbers} />}
{mr.historyMetrics && (
<div className="lotto-compare">
<MetricBlock title="추천 통계 (히스토리)" metrics={mr.historyMetrics} />
</div>
)}
{Array.isArray(mr.result.items) && mr.result.items.length ? (
<details className="lotto-details">
<summary>추천 후보 보기</summary>
<div className="lotto-batch">
{mr.result.items.map((item, idx) => (
<div key={item.id ?? `candidate-${idx}`} className="lotto-batch__item">
<div className="lotto-batch__meta">
<div>
<p className="lotto-batch__title">후보 #{item.id ?? idx + 1}</p>
<p className="lotto-batch__sub">기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
</div>
<NumberRow nums={item.numbers} />
{item.metrics && <MetricBlock title="후보 통계" metrics={item.metrics} />}
</div>
))}
</div>
</details>
) : null}
{mr.result.explain && (
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(mr.result.explain, null, 2)}</pre>
</details>
)}
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
{/* 추천 히스토리 */}
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">수동 추천 결과를 모아서 확인할 있습니다.</p>
</div>
<div className="lotto-panel__actions">
<span className="lotto-chip">{mr.history.length}</span>
{mr.history.length > 5 && (
<button className="button ghost small lotto-history-toggle"
onClick={() => mr.setHistoryExpanded((p) => !p)}
aria-expanded={mr.historyExpanded}>
{mr.historyExpanded ? '접기' : '더보기'}
<span className={`lotto-history-toggle__icon ${mr.historyExpanded ? 'is-open' : ''}`} aria-hidden></span>
</button>
)}
<button className="button ghost small" onClick={mr.refreshHistory} disabled={mr.loading.history}>
새로고침
</button>
</div>
</div>
{mr.loading.history ? <p className="lotto-empty">불러오는 ...</p> : null}
{mr.history.length === 0 ? (
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
) : (
<div className="lotto-history">
{mr.visibleHistory.map((item) => (
<div key={item.id} className="lotto-history__item">
<div className="lotto-history__meta">
<p>#{item.id}</p>
<p>{fmtKST(item.created_at)}</p>
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<div className="lotto-history__body">
<NumberRow nums={item.numbers} />
<p className="lotto-history__params">
window={item.params?.recent_window}, weight={item.params?.recent_weight},
avoid_k={item.params?.avoid_recent_k}
</p>
</div>
<div className="lotto-history__actions">
<button className="button ghost small" onClick={() => copyNumbers(item.numbers)}>
복사
</button>
<button className="button danger small" onClick={() => mr.onDelete(item.id)}>
삭제
</button>
</div>
</div>
))}
<span ref={mr.historyEndRef} />
</div>
)}
</section>
</>
);
}

View File

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

View File

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