feat(lotto): 3탭 구조 재배치(브리핑/분석/구매)

This commit is contained in:
2026-04-15 08:33:08 +09:00
parent bf1c23e66a
commit 064c983ca1
5 changed files with 511 additions and 451 deletions

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 && (
<nav className="lotto-tabs">
{TABS.map(t => (
<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} />
{/* ── 구매 기록 ── */}
<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>
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>
<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

@@ -1509,3 +1509,13 @@
.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,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}
/>
);
}