Functions.jsx 컴포넌트 분할: 1,583→460줄 (3훅+8컴포넌트+유틸)
- lottoUtils.jsx: 공통 유틸·상수 추출 (Ball, NumberRow, 통계 헬퍼 등) - hooks/useLottoData.js: 핵심 데이터 로드 (최신회차, 통계, 시뮬레이션, 리포트) - hooks/usePurchases.js: 구매 기록 CRUD - hooks/useManualRecommend.js: 수동 추천 + 히스토리 - components/: MetricBlock, FrequencyChart, PerformanceBanner, ConfidenceRing, CombinedRecommendPanel, ReportPanel, PersonalAnalysisPanel, PurchasePanel 분리 - getReport import 누락 버그 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
157
src/pages/lotto/components/CombinedRecommendPanel.jsx
Normal file
157
src/pages/lotto/components/CombinedRecommendPanel.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ballClass, NumberRow, METHOD_META, METHOD_ORDER, SCORE_META, fmtKST } from '../lottoUtils';
|
||||
|
||||
const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
|
||||
const [histExpand, setHistExpand] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="lotto-panel lotto-panel--wide lotto-combined">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">AI · 종합 추론</p>
|
||||
<h3>종합 추론 번호 추천</h3>
|
||||
<p className="lotto-panel__sub">
|
||||
5가지 통계 기법(빈도·지문·갭·공동출현·다양성)을 가중 투표로 합산해
|
||||
최적 6개 번호를 도출합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{loading && <span className="lotto-chip">분석 중…</span>}
|
||||
<button className="button primary small" onClick={onRun} disabled={loading}>
|
||||
{loading ? '추론 중…' : '🔮 종합 추론 실행'}
|
||||
</button>
|
||||
{history.length > 0 && (
|
||||
<button className="button ghost small" onClick={() => setHistExpand(p => !p)}>
|
||||
이력 {history.length}건 {histExpand ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!combined && !loading && (
|
||||
<p className="lotto-empty">버튼을 눌러 종합 추론을 실행하세요.</p>
|
||||
)}
|
||||
|
||||
{combined && (
|
||||
<>
|
||||
{/* 기법별 추천 번호 */}
|
||||
<div className="lotto-combined__methods">
|
||||
{METHOD_ORDER.map((key) => {
|
||||
const meta = METHOD_META[key];
|
||||
const m = combined.methods?.[key];
|
||||
if (!m) return null;
|
||||
return (
|
||||
<div key={key} className="lotto-combined__method">
|
||||
<div className="lotto-combined__method-head">
|
||||
<span className="lotto-combined__method-icon">{meta.icon}</span>
|
||||
<div>
|
||||
<p className="lotto-combined__method-name" style={{ color: meta.color }}>
|
||||
{meta.label}
|
||||
<span className="lotto-combined__method-weight"> ({m.weight_pct}%)</span>
|
||||
</p>
|
||||
<p className="lotto-combined__method-desc">{meta.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-combined__method-nums">
|
||||
{m.numbers.map((n) => {
|
||||
const inFinal = combined.final_numbers.includes(n);
|
||||
return (
|
||||
<span
|
||||
key={n}
|
||||
className={`lotto-ball ${ballClass(n).replace('lotto-ball ', '')} ${inFinal ? 'is-final' : 'is-dim'}`}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 최종 추론 결과 */}
|
||||
<div className="lotto-combined__final">
|
||||
<div className="lotto-combined__final-head">
|
||||
<span className="lotto-combined__final-badge">종합 추론 결과</span>
|
||||
{combined.deduped && (
|
||||
<span className="lotto-chip lotto-chip--muted">중복 (이미 저장됨)</span>
|
||||
)}
|
||||
<button className="button ghost small" onClick={() => onCopy(combined.final_numbers)}>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div className="lotto-combined__final-balls">
|
||||
{combined.final_numbers.map((n) => {
|
||||
const votes = combined.vote_counts?.[String(n)] ?? 0;
|
||||
return (
|
||||
<div key={n} className="lotto-combined__final-ball-wrap">
|
||||
<span className={ballClass(n)}>{n}</span>
|
||||
<span className="lotto-combined__vote-dots">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className={`lotto-combined__vote-dot ${i < votes ? 'is-on' : ''}`} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="lotto-combined__final-sub">
|
||||
● 점은 해당 번호가 채택된 기법 수 (최대 5개)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 점수 바 */}
|
||||
<div className="lotto-combined__scores">
|
||||
<p className="lotto-combined__scores-title">조합 품질 점수</p>
|
||||
{SCORE_META.map(({ key, label, color, weight }) => {
|
||||
const val = combined.scores?.[key] ?? 0;
|
||||
const pct = Math.round(val * 100);
|
||||
return (
|
||||
<div key={key} className="lotto-combined__score-row">
|
||||
<span className="lotto-combined__score-label">{label}</span>
|
||||
<span className="lotto-combined__score-weight">{weight}%</span>
|
||||
<div className="lotto-combined__score-bar-wrap">
|
||||
<div
|
||||
className="lotto-combined__score-bar"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="lotto-combined__score-val">{pct}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="lotto-combined__score-total">
|
||||
종합 점수 <strong>{Math.round((combined.scores?.score_total ?? 0) * 100)}</strong> / 100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="lotto-combined__disclaimer">
|
||||
※ 이 추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 추천 이력 */}
|
||||
{histExpand && (
|
||||
<div className="lotto-combined__history">
|
||||
<p className="lotto-combined__history-title">종합 추론 이력</p>
|
||||
{histLoading && <p className="lotto-empty">로딩 중…</p>}
|
||||
{history.map((item) => (
|
||||
<div key={item.id} className="lotto-combined__history-item">
|
||||
<div className="lotto-combined__history-meta">
|
||||
<span>#{item.id}</span>
|
||||
<span>{fmtKST(item.created_at)}</span>
|
||||
<span>기준 {item.based_on_draw ?? '-'}회</span>
|
||||
</div>
|
||||
<NumberRow nums={item.numbers} />
|
||||
<button className="button ghost small" onClick={() => onCopy(item.numbers)}>복사</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CombinedRecommendPanel;
|
||||
25
src/pages/lotto/components/ConfidenceRing.jsx
Normal file
25
src/pages/lotto/components/ConfidenceRing.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
const ConfidenceRing = ({ score }) => {
|
||||
const r = 28, c = 2 * Math.PI * r;
|
||||
const fill = (score / 100) * c;
|
||||
const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5';
|
||||
return (
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" className="lotto-confidence-ring" aria-hidden>
|
||||
<circle cx="36" cy="36" r={r} stroke="rgba(255,255,255,0.08)" strokeWidth="6" fill="none" />
|
||||
<circle
|
||||
cx="36" cy="36" r={r}
|
||||
stroke={color} strokeWidth="6" fill="none"
|
||||
strokeDasharray={`${fill} ${c - fill}`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 36 36)"
|
||||
/>
|
||||
<text x="36" y="41" textAnchor="middle" fill={color} fontSize="16" fontWeight="600"
|
||||
style={{ fontFamily: 'inherit' }}>
|
||||
{score}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfidenceRing;
|
||||
39
src/pages/lotto/components/FrequencyChart.jsx
Normal file
39
src/pages/lotto/components/FrequencyChart.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildFrequencySeries } from '../lottoUtils';
|
||||
|
||||
const FrequencyChart = ({ stats }) => {
|
||||
const { series, max } = useMemo(() => buildFrequencySeries(stats?.frequency), [stats]);
|
||||
const ticks = useMemo(() => [max, Math.round(max * 0.5), 0], [max]);
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="lotto-chart">
|
||||
<div className="lotto-chart__y">
|
||||
<span>횟수</span>
|
||||
<div className="lotto-chart__ticks">
|
||||
{ticks.map((value) => <span key={value}>{value}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-chart__plot" role="list">
|
||||
{series.map((item) => {
|
||||
const showLabel = item.number === 1 || item.number % 5 === 0;
|
||||
return (
|
||||
<div key={item.number} className="lotto-chart__col" role="listitem">
|
||||
<span
|
||||
className="lotto-chart__bar"
|
||||
style={{ height: `${(item.count / max) * 100}%` }}
|
||||
title={`${item.number}번: ${item.count}회`}
|
||||
aria-label={`${item.number}번 ${item.count}회`}
|
||||
/>
|
||||
<span className="lotto-chart__x" aria-hidden={!showLabel}>
|
||||
{showLabel ? item.number : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrequencyChart;
|
||||
59
src/pages/lotto/components/MetricBlock.jsx
Normal file
59
src/pages/lotto/components/MetricBlock.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { toBucketEntries } from '../lottoUtils';
|
||||
|
||||
const MetricBlock = ({ title, metrics }) => {
|
||||
if (!metrics) return null;
|
||||
const buckets = toBucketEntries(metrics);
|
||||
const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 0), 1) : 1;
|
||||
const odd = Number(metrics.odd) || 0;
|
||||
const even = Number(metrics.even) || 0;
|
||||
const totalOE = odd + even || 1;
|
||||
const oddPct = (odd / totalOE) * 100;
|
||||
|
||||
return (
|
||||
<div className="lotto-metrics">
|
||||
<div className="lotto-metrics__head">
|
||||
<p className="lotto-metrics__title">{title}</p>
|
||||
<span className="lotto-metrics__sum">총 출현 횟수 {metrics.sum ?? '-'}</span>
|
||||
</div>
|
||||
<div className="lotto-metric-cards">
|
||||
<div className="lotto-metric-card">
|
||||
<p className="lotto-metric-card__label">최소 출현</p>
|
||||
<p className="lotto-metric-card__value">{metrics.min ?? '-'}</p>
|
||||
</div>
|
||||
<div className="lotto-metric-card">
|
||||
<p className="lotto-metric-card__label">최대 출현</p>
|
||||
<p className="lotto-metric-card__value">{metrics.max ?? '-'}</p>
|
||||
</div>
|
||||
<div className="lotto-metric-card">
|
||||
<p className="lotto-metric-card__label">출현 편차</p>
|
||||
<p className="lotto-metric-card__value">{metrics.range ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lotto-odd-even">
|
||||
<div className="lotto-odd-even__labels">
|
||||
<span>홀 {odd}</span><span>짝 {even}</span>
|
||||
</div>
|
||||
<div className="lotto-odd-even__bar" aria-hidden>
|
||||
<span className="lotto-odd-even__odd" style={{ width: `${oddPct}%` }} />
|
||||
<span className="lotto-odd-even__even" style={{ width: `${100 - oddPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
{buckets.length ? (
|
||||
<div className="lotto-buckets">
|
||||
{buckets.map(([label, value]) => (
|
||||
<div key={label} className="lotto-bucket">
|
||||
<span className="lotto-bucket__label">{label}</span>
|
||||
<div className="lotto-bucket__bar" aria-hidden>
|
||||
<span style={{ width: `${((Number(value) || 0) / maxBucket) * 100}%` }} />
|
||||
</div>
|
||||
<span className="lotto-bucket__value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricBlock;
|
||||
48
src/pages/lotto/components/PerformanceBanner.jsx
Normal file
48
src/pages/lotto/components/PerformanceBanner.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
const PerformanceBanner = ({ perf }) => {
|
||||
if (!perf || perf.total_checked === 0) return null;
|
||||
const imp = perf.vs_random?.improvement_pct ?? 0;
|
||||
const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0);
|
||||
return (
|
||||
<div className="lotto-perf-banner">
|
||||
<span className="lotto-perf-banner__label">신뢰도 지표</span>
|
||||
<div className="lotto-perf-banner__items">
|
||||
<div className="lotto-perf-banner__item">
|
||||
<span className="lotto-perf-banner__val">{perf.total_checked}</span>
|
||||
<span className="lotto-perf-banner__lbl">검증 회차</span>
|
||||
</div>
|
||||
<div className="lotto-perf-banner__divider" />
|
||||
<div className="lotto-perf-banner__item">
|
||||
<span className="lotto-perf-banner__val">{(perf.avg_correct ?? 0).toFixed(1)}</span>
|
||||
<span className="lotto-perf-banner__lbl">평균 일치수</span>
|
||||
</div>
|
||||
<div className="lotto-perf-banner__divider" />
|
||||
<div className="lotto-perf-banner__item">
|
||||
<span className={`lotto-perf-banner__val ${imp > 0 ? 'is-pos' : ''}`}>
|
||||
{imp > 0 ? '+' : ''}{imp.toFixed(1)}%
|
||||
</span>
|
||||
<span className="lotto-perf-banner__lbl">무작위 대비</span>
|
||||
</div>
|
||||
<div className="lotto-perf-banner__divider" />
|
||||
<div className="lotto-perf-banner__item">
|
||||
<span className="lotto-perf-banner__val">
|
||||
{((perf.rate_3plus ?? 0) * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span className="lotto-perf-banner__lbl">3개↑ 일치율</span>
|
||||
</div>
|
||||
{prizeHits > 0 && (
|
||||
<>
|
||||
<div className="lotto-perf-banner__divider" />
|
||||
<div className="lotto-perf-banner__item">
|
||||
<span className="lotto-perf-banner__val is-prize">{prizeHits}건</span>
|
||||
<span className="lotto-perf-banner__lbl">3~5등 당첨</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceBanner;
|
||||
83
src/pages/lotto/components/PersonalAnalysisPanel.jsx
Normal file
83
src/pages/lotto/components/PersonalAnalysisPanel.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { NumberRow } from '../lottoUtils';
|
||||
|
||||
const PersonalAnalysisPanel = ({ data, loading }) => {
|
||||
const zones = Object.entries(data?.pattern?.zone_avg ?? {});
|
||||
const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1;
|
||||
|
||||
return (
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">My Pattern</p>
|
||||
<h3>내 번호 패턴</h3>
|
||||
{data && data.total_analyzed > 0 && (
|
||||
<p className="lotto-panel__sub">총 {data.total_analyzed}회 추천 기반 분석</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(loading || !data || data.total_analyzed === 0) ? (
|
||||
<p className="lotto-empty">
|
||||
{loading ? '불러오는 중...' : '추천 이력이 없습니다.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="lotto-analysis">
|
||||
<div className="lotto-analysis__row">
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">
|
||||
내가 자주 선택한 번호 <span>TOP 10</span>
|
||||
</p>
|
||||
<NumberRow nums={data.top_picks ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">선택 성향</p>
|
||||
<div className="lotto-personal-tendency">
|
||||
{data.vs_draw_avg?.odd_tendency && (
|
||||
<span className="lotto-personal-tendency__badge">
|
||||
{data.vs_draw_avg.odd_tendency}
|
||||
</span>
|
||||
)}
|
||||
{data.vs_draw_avg?.sum_tendency && (
|
||||
<span className="lotto-personal-tendency__badge">
|
||||
{data.vs_draw_avg.sum_tendency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="lotto-analysis__stats">
|
||||
<span>홀수 평균 <strong>{data.pattern?.avg_odd_count?.toFixed(1)}</strong></span>
|
||||
<span>합계 평균 <strong>{data.pattern?.avg_sum?.toFixed(1)}</strong></span>
|
||||
<span>
|
||||
연속번호 포함률{' '}
|
||||
<strong>
|
||||
{((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}%
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{zones.length > 0 && (
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">구간별 선택 비율</p>
|
||||
<div className="lotto-buckets">
|
||||
{zones.map(([zone, avg]) => (
|
||||
<div key={zone} className="lotto-bucket">
|
||||
<span className="lotto-bucket__label">{zone}</span>
|
||||
<div className="lotto-bucket__bar" aria-hidden>
|
||||
<span style={{ width: `${((Number(avg) || 0) / maxZone) * 100}%` }} />
|
||||
</div>
|
||||
<span className="lotto-bucket__value">{Number(avg).toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalAnalysisPanel;
|
||||
173
src/pages/lotto/components/PurchasePanel.jsx
Normal file
173
src/pages/lotto/components/PurchasePanel.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { fmtWon } from '../lottoUtils';
|
||||
|
||||
const PurchasePanel = ({
|
||||
records, stats, loading,
|
||||
formOpen, form, formSaving, formError, editId,
|
||||
onFormOpen, onFormClose, onFormChange, onFormSubmit,
|
||||
onEditStart, onDelete,
|
||||
}) => {
|
||||
const winRate = stats?.total_records > 0
|
||||
? ((stats.prize_count / stats.total_records) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg';
|
||||
|
||||
return (
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Purchase Tracker</p>
|
||||
<h3>구매 기록</h3>
|
||||
<p className="lotto-panel__sub">구매 내역 기록 및 수익률 추적</p>
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{loading && <span className="lotto-chip">로딩 중</span>}
|
||||
<button className="button small" onClick={onFormOpen} disabled={formOpen}>
|
||||
+ 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 바 */}
|
||||
{stats && stats.total_records > 0 && (
|
||||
<div className="lotto-purchase-stats">
|
||||
<div className="lotto-purchase-stat">
|
||||
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_invested)}</span>
|
||||
<span className="lotto-purchase-stat__lbl">총 투자</span>
|
||||
</div>
|
||||
<div className="lotto-purchase-stat">
|
||||
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_prize)}</span>
|
||||
<span className="lotto-purchase-stat__lbl">총 당첨금</span>
|
||||
</div>
|
||||
<div className="lotto-purchase-stat">
|
||||
<span className={`lotto-purchase-stat__val ${netColor}`}>
|
||||
{(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)}
|
||||
</span>
|
||||
<span className="lotto-purchase-stat__lbl">순손익</span>
|
||||
</div>
|
||||
<div className="lotto-purchase-stat">
|
||||
<span className="lotto-purchase-stat__val">{stats.return_rate?.toFixed(1)}%</span>
|
||||
<span className="lotto-purchase-stat__lbl">회수율</span>
|
||||
</div>
|
||||
<div className="lotto-purchase-stat">
|
||||
<span className="lotto-purchase-stat__val">{winRate}%</span>
|
||||
<span className="lotto-purchase-stat__lbl">당첨률</span>
|
||||
</div>
|
||||
{stats.max_prize > 0 && (
|
||||
<div className="lotto-purchase-stat">
|
||||
<span className="lotto-purchase-stat__val is-prize">{fmtWon(stats.max_prize)}</span>
|
||||
<span className="lotto-purchase-stat__lbl">최대 당첨금</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 입력 폼 */}
|
||||
{formOpen && (
|
||||
<form className="lotto-purchase-form" onSubmit={onFormSubmit}>
|
||||
<p className="lotto-purchase-form__title">
|
||||
{editId != null ? '기록 수정' : '구매 기록 추가'}
|
||||
</p>
|
||||
<div className="lotto-purchase-form__grid">
|
||||
<label className="lotto-field">
|
||||
회차
|
||||
<input
|
||||
type="number" min={1}
|
||||
value={form.draw_no}
|
||||
onChange={(e) => onFormChange('draw_no', e.target.value)}
|
||||
placeholder="예: 1181"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
구매금액
|
||||
<input
|
||||
type="number" step={1000} min={1000}
|
||||
value={form.amount}
|
||||
onChange={(e) => onFormChange('amount', Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
세트 수
|
||||
<input
|
||||
type="number" min={1} max={20}
|
||||
value={form.sets}
|
||||
onChange={(e) => onFormChange('sets', Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="lotto-field">
|
||||
당첨금
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={form.prize}
|
||||
onChange={(e) => onFormChange('prize', Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="lotto-field lotto-purchase-form__note">
|
||||
메모
|
||||
<input
|
||||
type="text"
|
||||
value={form.note}
|
||||
onChange={(e) => onFormChange('note', e.target.value)}
|
||||
placeholder="예: 5등 1개"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{formError && (
|
||||
<p className="lotto-empty" style={{ color: '#f9b6b1' }}>{formError}</p>
|
||||
)}
|
||||
<div className="lotto-purchase-form__actions">
|
||||
<button type="button" className="button ghost small" onClick={onFormClose}>
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="button primary small" disabled={formSaving}>
|
||||
{formSaving ? '저장 중...' : editId != null ? '수정 완료' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 기록 목록 */}
|
||||
{records.length === 0 ? (
|
||||
<p className="lotto-empty">구매 기록이 없습니다.</p>
|
||||
) : (
|
||||
<div className="lotto-purchase-list">
|
||||
<div className="lotto-purchase-list__head">
|
||||
<span>회차</span>
|
||||
<span>투자금</span>
|
||||
<span>당첨금</span>
|
||||
<span>손익</span>
|
||||
<span>메모</span>
|
||||
<span />
|
||||
</div>
|
||||
{records.map((rec) => {
|
||||
const net = (rec.prize ?? 0) - (rec.amount ?? 0);
|
||||
return (
|
||||
<div key={rec.id} className="lotto-purchase-row">
|
||||
<span className="lotto-purchase-row__drw">{rec.draw_no}회</span>
|
||||
<span>{fmtWon(rec.amount)}</span>
|
||||
<span className={(rec.prize ?? 0) > 0 ? 'is-prize' : ''}>
|
||||
{fmtWon(rec.prize)}
|
||||
</span>
|
||||
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||
</span>
|
||||
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||
<div className="lotto-purchase-row__actions">
|
||||
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||
수정
|
||||
</button>
|
||||
<button className="button danger small" onClick={() => onDelete(rec.id)}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchasePanel;
|
||||
142
src/pages/lotto/components/ReportPanel.jsx
Normal file
142
src/pages/lotto/components/ReportPanel.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NumberRow } from '../lottoUtils';
|
||||
import ConfidenceRing from './ConfidenceRing';
|
||||
|
||||
const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
|
||||
const [histExpand, setHistExpand] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Weekly Report</p>
|
||||
<h3>이번 주 공략 리포트</h3>
|
||||
{report && (
|
||||
<p className="lotto-panel__sub">
|
||||
{report.target_drw_no}회 대상 · {report.based_on_draw}회 기준
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="lotto-panel__actions">
|
||||
{loading && <span className="lotto-chip">로딩 중</span>}
|
||||
<button className="button ghost small" onClick={onRefresh} disabled={loading}>
|
||||
새로고침
|
||||
</button>
|
||||
{history?.length > 0 && (
|
||||
<button className="button ghost small" onClick={() => setHistExpand((p) => !p)}>
|
||||
지난 리포트 {histExpand ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지난 리포트 목록 */}
|
||||
{histExpand && history?.length > 0 && (
|
||||
<div className="lotto-report-history">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.drw_no}
|
||||
className="button ghost small"
|
||||
onClick={() => { onSelectDrw(h.drw_no); setHistExpand(false); }}
|
||||
>
|
||||
{h.drw_no}회
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!report && !loading && (
|
||||
<p className="lotto-empty">리포트 데이터가 없습니다.</p>
|
||||
)}
|
||||
{loading && !report && (
|
||||
<p className="lotto-empty">불러오는 중...</p>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* 신뢰도 + 패턴 요약 */}
|
||||
<div className="lotto-report-top">
|
||||
<div className="lotto-report-confidence">
|
||||
<ConfidenceRing score={report.confidence_score ?? 0} />
|
||||
<div>
|
||||
<p className="lotto-report-confidence__title">신뢰도 점수</p>
|
||||
<div className="lotto-report-confidence__factors">
|
||||
{Object.entries(report.confidence_factors ?? {}).map(([k, v]) => (
|
||||
<div key={k} className="lotto-report-confidence__factor">
|
||||
<span className="lotto-report-confidence__factor-lbl">
|
||||
{k === 'data_volume' ? '데이터 충분도'
|
||||
: k === 'pattern_consistency' ? '패턴 안정성'
|
||||
: k === 'recent_trend' ? '최근 트렌드' : k}
|
||||
</span>
|
||||
<div className="lotto-pick__bar">
|
||||
<span style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
<span className="lotto-report-confidence__factor-val">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lotto-report-pattern">
|
||||
<p className="lotto-report-pattern__title">최근 패턴</p>
|
||||
<div className="lotto-report-pattern__stats">
|
||||
<div className="lotto-report-pattern__stat">
|
||||
<span>합계 평균</span>
|
||||
<strong>{report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'}</strong>
|
||||
</div>
|
||||
<div className="lotto-report-pattern__stat">
|
||||
<span>홀수 평균</span>
|
||||
<strong>{report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'}</strong>
|
||||
</div>
|
||||
{(report.recent_pattern?.triple_appear ?? []).length > 0 && (
|
||||
<div className="lotto-report-pattern__stat">
|
||||
<span>3회 연속 출현</span>
|
||||
<NumberRow nums={report.recent_pattern.triple_appear} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핫 / 콜드 / 오버듀 */}
|
||||
<div className="lotto-analysis__row">
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">
|
||||
🔥 핫 번호 <span>최근 10회 과출현</span>
|
||||
</p>
|
||||
<NumberRow nums={report.hot_numbers ?? []} />
|
||||
</div>
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">
|
||||
🧊 콜드 번호 <span>역대 저빈도 10개</span>
|
||||
</p>
|
||||
<NumberRow nums={report.cold_numbers ?? []} />
|
||||
</div>
|
||||
<div className="lotto-analysis__group">
|
||||
<p className="lotto-analysis__label">
|
||||
⏰ 오버듀 <span>가장 오래 미출현</span>
|
||||
</p>
|
||||
<NumberRow nums={report.overdue_numbers ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전략 추천 세트 */}
|
||||
{(report.recommended_sets ?? []).length > 0 && (
|
||||
<div className="lotto-strategy-cards">
|
||||
{report.recommended_sets.map((set, i) => (
|
||||
<div key={i} className="lotto-strategy-card">
|
||||
<p className="lotto-strategy-card__name">{set.strategy}</p>
|
||||
<NumberRow nums={set.numbers} />
|
||||
<p className="lotto-strategy-card__desc">{set.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportPanel;
|
||||
162
src/pages/lotto/hooks/useLottoData.js
Normal file
162
src/pages/lotto/hooks/useLottoData.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getLatest, getStats, getBestPicks, getAnalysis,
|
||||
getPerformanceStats, getLatestReport, getReportHistory, getReport,
|
||||
getPersonalAnalysis, getCombinedRecommend, getCombinedHistory,
|
||||
} from '../../../api';
|
||||
import { readStatsCache, writeStatsCache } from '../lottoUtils';
|
||||
|
||||
export default function useLottoData() {
|
||||
const [latest, setLatest] = useState(null);
|
||||
const [stats, setStats] = useState(() => readStatsCache());
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [statsError, setStatsError] = useState('');
|
||||
const [loading, setLoading] = useState({
|
||||
latest: false, bestPicks: false, analysis: false,
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [bestPicks, setBestPicks] = useState([]);
|
||||
const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const [simulating, setSimulating] = useState(false);
|
||||
const [simResult, setSimResult] = useState(null);
|
||||
|
||||
// 종합 추론
|
||||
const [combined, setCombined] = useState(null);
|
||||
const [combinedLoading, setCombinedLoading] = useState(false);
|
||||
const [combinedHistory, setCombinedHistory] = useState([]);
|
||||
const [combinedHistLoading, setCombinedHistLoading] = useState(false);
|
||||
|
||||
// 신뢰도·리포트·개인분석
|
||||
const [perfStats, setPerfStats] = useState(null);
|
||||
const [report, setReport] = useState(null);
|
||||
const [reportHistory, setReportHistory] = useState([]);
|
||||
const [reportLoading, setReportLoading] = useState(false);
|
||||
const [personalAnalysis, setPersonalAnalysis] = useState(null);
|
||||
const [personalLoading, setPersonalLoading] = useState(false);
|
||||
|
||||
const refreshLatest = useCallback(async () => {
|
||||
setLoading((s) => ({ ...s, latest: true }));
|
||||
setError('');
|
||||
try { setLatest(await getLatest()); }
|
||||
catch (e) { setError(e?.message ?? String(e)); }
|
||||
finally { setLoading((s) => ({ ...s, latest: false })); }
|
||||
}, []);
|
||||
|
||||
const refreshStats = useCallback(async () => {
|
||||
setStatsLoading(true); setStatsError('');
|
||||
try {
|
||||
const cached = readStatsCache();
|
||||
if (cached && !stats) setStats(cached);
|
||||
const data = await getStats();
|
||||
if (!cached || cached.total_draws !== data?.total_draws) {
|
||||
setStats(data); writeStatsCache(data);
|
||||
}
|
||||
} catch (e) { setStatsError(e?.message ?? String(e)); }
|
||||
finally { setStatsLoading(false); }
|
||||
}, [stats]);
|
||||
|
||||
const refreshBestPicks = useCallback(async () => {
|
||||
setLoading((s) => ({ ...s, bestPicks: true }));
|
||||
try { setBestPicks((await getBestPicks(20)).items ?? []); }
|
||||
catch {}
|
||||
finally { setLoading((s) => ({ ...s, bestPicks: false })); }
|
||||
}, []);
|
||||
|
||||
const refreshAnalysis = useCallback(async () => {
|
||||
setLoading((s) => ({ ...s, analysis: true }));
|
||||
try { setAnalysis(await getAnalysis()); }
|
||||
catch {}
|
||||
finally { setLoading((s) => ({ ...s, analysis: false })); }
|
||||
}, []);
|
||||
|
||||
const refreshPerfStats = useCallback(async () => {
|
||||
try { setPerfStats(await getPerformanceStats()); } catch {}
|
||||
}, []);
|
||||
|
||||
const refreshReport = useCallback(async () => {
|
||||
setReportLoading(true);
|
||||
try {
|
||||
const [rep, hist] = await Promise.all([
|
||||
getLatestReport(),
|
||||
getReportHistory(10),
|
||||
]);
|
||||
setReport(rep);
|
||||
setReportHistory(hist?.reports ?? []);
|
||||
} catch {}
|
||||
finally { setReportLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadSpecificReport = useCallback(async (drwNo) => {
|
||||
setReportLoading(true);
|
||||
try { setReport(await getReport(drwNo)); }
|
||||
catch {}
|
||||
finally { setReportLoading(false); }
|
||||
}, []);
|
||||
|
||||
const runCombinedRecommend = useCallback(async () => {
|
||||
setCombinedLoading(true);
|
||||
try {
|
||||
const data = await getCombinedRecommend();
|
||||
setCombined(data);
|
||||
const hist = await getCombinedHistory(30);
|
||||
setCombinedHistory(hist?.items ?? []);
|
||||
} catch (e) { setError(e?.message ?? String(e)); }
|
||||
finally { setCombinedLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadCombinedHistory = useCallback(async () => {
|
||||
setCombinedHistLoading(true);
|
||||
try {
|
||||
const hist = await getCombinedHistory(30);
|
||||
setCombinedHistory(hist?.items ?? []);
|
||||
} catch {}
|
||||
finally { setCombinedHistLoading(false); }
|
||||
}, []);
|
||||
|
||||
const refreshPersonalAnalysis = useCallback(async () => {
|
||||
setPersonalLoading(true);
|
||||
try { setPersonalAnalysis(await getPersonalAnalysis()); }
|
||||
catch {}
|
||||
finally { setPersonalLoading(false); }
|
||||
}, []);
|
||||
|
||||
const onSimulate = useCallback(async () => {
|
||||
const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
|
||||
if (!ok) return;
|
||||
setSimulating(true); setSimResult(null); setError('');
|
||||
try {
|
||||
const { triggerSimulate } = await import('../../../api');
|
||||
const data = await triggerSimulate();
|
||||
setSimResult(data);
|
||||
await refreshBestPicks();
|
||||
} catch (e) { setError(e?.message ?? String(e)); }
|
||||
finally { setSimulating(false); }
|
||||
}, [refreshBestPicks]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
refreshLatest();
|
||||
refreshStats();
|
||||
refreshBestPicks();
|
||||
refreshAnalysis();
|
||||
refreshPerfStats();
|
||||
refreshReport();
|
||||
refreshPersonalAnalysis();
|
||||
loadCombinedHistory();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
latest, loading, error, setError,
|
||||
stats, statsLoading, statsError, refreshStats,
|
||||
refreshLatest,
|
||||
bestPicks, bestPicksExpanded, setBestPicksExpanded, refreshBestPicks,
|
||||
analysis, refreshAnalysis,
|
||||
simulating, simResult, onSimulate,
|
||||
combined, combinedLoading, combinedHistory, combinedHistLoading,
|
||||
runCombinedRecommend,
|
||||
perfStats,
|
||||
report, reportHistory, reportLoading, refreshReport, loadSpecificReport,
|
||||
personalAnalysis, personalLoading,
|
||||
};
|
||||
}
|
||||
75
src/pages/lotto/hooks/useManualRecommend.js
Normal file
75
src/pages/lotto/hooks/useManualRecommend.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { deleteHistory, getHistory, recommend } from '../../../api';
|
||||
import { buildMetricsFromHistory } from '../lottoUtils';
|
||||
|
||||
export default function useManualRecommend() {
|
||||
const [params, setParams] = useState({
|
||||
recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5,
|
||||
});
|
||||
const presets = useMemo(() => [
|
||||
{ name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 },
|
||||
{ name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 },
|
||||
{ name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 },
|
||||
{ name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 },
|
||||
], []);
|
||||
const [result, setResult] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||
const historyEndRef = useRef(null);
|
||||
const prevHistoryExpandedRef = useRef(false);
|
||||
const [loading, setLoading] = useState({ recommend: false, history: false });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]);
|
||||
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
setLoading((s) => ({ ...s, history: true }));
|
||||
setError('');
|
||||
try {
|
||||
const limit = 100; let offset = 0; const allItems = [];
|
||||
while (true) {
|
||||
const data = await getHistory(limit, offset);
|
||||
const items = data.items ?? [];
|
||||
allItems.push(...items);
|
||||
if (items.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
setHistory(allItems);
|
||||
} catch (e) { setError(e?.message ?? String(e)); }
|
||||
finally { setLoading((s) => ({ ...s, history: false })); }
|
||||
}, []);
|
||||
|
||||
const onRecommend = useCallback(async () => {
|
||||
setLoading((s) => ({ ...s, recommend: true })); setError('');
|
||||
try { const data = await recommend(params); setResult(data); await refreshHistory(); }
|
||||
catch (e) { setError(e?.message ?? String(e)); }
|
||||
finally { setLoading((s) => ({ ...s, recommend: false })); }
|
||||
}, [params, refreshHistory]);
|
||||
|
||||
const onDelete = useCallback(async (id) => {
|
||||
if (!confirm(`히스토리 #${id}를 삭제할까요?`)) return;
|
||||
setError('');
|
||||
try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); }
|
||||
catch (e) { setError(e?.message ?? String(e)); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
});
|
||||
}
|
||||
prevHistoryExpandedRef.current = historyExpanded;
|
||||
}, [historyExpanded, visibleHistory.length]);
|
||||
|
||||
useEffect(() => { refreshHistory(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
params, setParams, presets,
|
||||
result, history, historyExpanded, setHistoryExpanded,
|
||||
historyEndRef, loading, error, setError,
|
||||
historyMetrics, visibleHistory,
|
||||
refreshHistory, onRecommend, onDelete,
|
||||
};
|
||||
}
|
||||
105
src/pages/lotto/hooks/usePurchases.js
Normal file
105
src/pages/lotto/hooks/usePurchases.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||
} from '../../../api';
|
||||
import { emptyPurchaseForm } from '../lottoUtils';
|
||||
|
||||
export default function usePurchases() {
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
const [purchaseStats, setPurchaseStats] = useState(null);
|
||||
const [purchaseLoading, setPurchaseLoading] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [purchaseFormOpen, setPurchaseFormOpen] = useState(false);
|
||||
const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm);
|
||||
const [purchaseFormSaving, setPurchaseFormSaving] = useState(false);
|
||||
const [purchaseFormError, setPurchaseFormError] = useState('');
|
||||
const [purchaseEditId, setPurchaseEditId] = useState(null);
|
||||
|
||||
const refreshPurchases = useCallback(async () => {
|
||||
setPurchaseLoading(true);
|
||||
try {
|
||||
const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]);
|
||||
setPurchases(recs?.records ?? []);
|
||||
setPurchaseStats(st);
|
||||
} catch {}
|
||||
finally { setPurchaseLoading(false); }
|
||||
}, []);
|
||||
|
||||
const handlePurchaseFormOpen = useCallback(() => {
|
||||
setPurchaseEditId(null);
|
||||
setPurchaseForm(emptyPurchaseForm());
|
||||
setPurchaseFormError('');
|
||||
setPurchaseFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePurchaseFormClose = useCallback(() => {
|
||||
setPurchaseFormOpen(false);
|
||||
setPurchaseEditId(null);
|
||||
setPurchaseFormError('');
|
||||
}, []);
|
||||
|
||||
const handlePurchaseFormChange = useCallback((field, value) => {
|
||||
setPurchaseForm((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handlePurchaseEditStart = useCallback((rec) => {
|
||||
setPurchaseEditId(rec.id);
|
||||
setPurchaseForm({
|
||||
draw_no: String(rec.draw_no ?? ''),
|
||||
amount: rec.amount ?? 5000,
|
||||
sets: rec.sets ?? 5,
|
||||
prize: rec.prize ?? 0,
|
||||
note: rec.note ?? '',
|
||||
});
|
||||
setPurchaseFormError('');
|
||||
setPurchaseFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePurchaseFormSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
setPurchaseFormSaving(true); setPurchaseFormError('');
|
||||
const payload = {
|
||||
draw_no: Number(purchaseForm.draw_no),
|
||||
amount: Number(purchaseForm.amount),
|
||||
sets: Number(purchaseForm.sets),
|
||||
prize: Number(purchaseForm.prize),
|
||||
note: purchaseForm.note.trim(),
|
||||
};
|
||||
try {
|
||||
if (purchaseEditId != null) {
|
||||
const updated = await updatePurchase(purchaseEditId, payload);
|
||||
setPurchases((prev) =>
|
||||
prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r)
|
||||
);
|
||||
} else {
|
||||
const saved = await addPurchase(payload);
|
||||
setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
|
||||
}
|
||||
try { setPurchaseStats(await getPurchaseStats()); } catch {}
|
||||
handlePurchaseFormClose();
|
||||
} catch (err) {
|
||||
setPurchaseFormError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setPurchaseFormSaving(false);
|
||||
}
|
||||
}, [purchaseForm, purchaseEditId, handlePurchaseFormClose]);
|
||||
|
||||
const handlePurchaseDelete = useCallback(async (id) => {
|
||||
if (!confirm('이 구매 기록을 삭제할까요?')) return;
|
||||
setPurchases((prev) => prev.filter((r) => r.id !== id));
|
||||
try {
|
||||
await deletePurchase(id);
|
||||
try { setPurchaseStats(await getPurchaseStats()); } catch {}
|
||||
} catch { refreshPurchases(); }
|
||||
}, [refreshPurchases]);
|
||||
|
||||
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
purchases, purchaseStats, purchaseLoading,
|
||||
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||
};
|
||||
}
|
||||
141
src/pages/lotto/lottoUtils.jsx
Normal file
141
src/pages/lotto/lottoUtils.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/* ─────────────────────────────────────────────
|
||||
로또 공통 유틸리티
|
||||
───────────────────────────────────────────── */
|
||||
import React from 'react';
|
||||
|
||||
export const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
||||
|
||||
export const fmtWon = (n) => {
|
||||
if (n == null || isNaN(Number(n))) return '-';
|
||||
return new Intl.NumberFormat('ko-KR').format(Math.round(Number(n))) + '원';
|
||||
};
|
||||
|
||||
export const ballClass = (n) => {
|
||||
if (n <= 10) return 'lotto-ball range-a';
|
||||
if (n <= 20) return 'lotto-ball range-b';
|
||||
if (n <= 30) return 'lotto-ball range-c';
|
||||
if (n <= 40) return 'lotto-ball range-d';
|
||||
return 'lotto-ball range-e';
|
||||
};
|
||||
|
||||
export const Ball = ({ n }) => <span className={ballClass(n)}>{n}</span>;
|
||||
|
||||
export const NumberRow = ({ nums }) => (
|
||||
<div className="lotto-row">
|
||||
{nums.map((n) => <Ball key={n} n={n} />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
통계 헬퍼
|
||||
───────────────────────────────────────────── */
|
||||
export const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
|
||||
export const STATS_CACHE_KEY = 'lotto_stats_v1';
|
||||
export const BEST_PICKS_DEFAULT_SHOW = 5;
|
||||
|
||||
export const readStatsCache = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STATS_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.frequency)) return null;
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
export const writeStatsCache = (data) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try { localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data)); } catch {}
|
||||
};
|
||||
|
||||
export const buildFrequencySeries = (frequency) => {
|
||||
const map = new Map();
|
||||
(frequency ?? []).forEach((item) => {
|
||||
const number = Number(item?.number);
|
||||
const count = Number(item?.count) || 0;
|
||||
if (Number.isFinite(number) && number >= 1 && number <= 45) map.set(number, count);
|
||||
});
|
||||
const series = Array.from({ length: 45 }, (_, idx) => ({
|
||||
number: idx + 1, count: map.get(idx + 1) ?? 0,
|
||||
}));
|
||||
const max = Math.max(1, ...series.map((item) => item.count));
|
||||
return { series, max };
|
||||
};
|
||||
|
||||
export const buildMetricsFromCounts = (counts) => {
|
||||
if (!counts?.length) return null;
|
||||
const total = counts.reduce((acc, v) => acc + v, 0);
|
||||
if (!total) return null;
|
||||
const min = Math.min(...counts), max = Math.max(...counts);
|
||||
const odd = counts.reduce((acc, v, idx) => (idx % 2 === 0 ? acc + v : acc), 0);
|
||||
const even = total - odd;
|
||||
const buckets = {
|
||||
'1-10': counts.slice(0, 10).reduce((a, b) => a + b, 0),
|
||||
'11-20': counts.slice(10, 20).reduce((a, b) => a + b, 0),
|
||||
'21-30': counts.slice(20, 30).reduce((a, b) => a + b, 0),
|
||||
'31-40': counts.slice(30, 40).reduce((a, b) => a + b, 0),
|
||||
'41-45': counts.slice(40, 45).reduce((a, b) => a + b, 0),
|
||||
};
|
||||
return { sum: total, min, max, range: max - min, odd, even, buckets };
|
||||
};
|
||||
|
||||
export const buildMetricsFromFrequency = (frequency) => {
|
||||
if (!frequency?.length) return null;
|
||||
const counts = Array.from({ length: 45 }, () => 0);
|
||||
frequency.forEach((item) => {
|
||||
const number = Number(item?.number), count = Number(item?.count) || 0;
|
||||
if (number >= 1 && number <= 45) counts[number - 1] = count;
|
||||
});
|
||||
return buildMetricsFromCounts(counts);
|
||||
};
|
||||
|
||||
export const buildMetricsFromHistory = (items) => {
|
||||
if (!items?.length) return null;
|
||||
const counts = Array.from({ length: 45 }, () => 0);
|
||||
items.forEach((item) => {
|
||||
(item?.numbers ?? []).forEach((value) => {
|
||||
const number = Number(value);
|
||||
if (number >= 1 && number <= 45) counts[number - 1] += 1;
|
||||
});
|
||||
});
|
||||
return buildMetricsFromCounts(counts);
|
||||
};
|
||||
|
||||
export const toBucketEntries = (metrics) => {
|
||||
if (!metrics?.buckets) return [];
|
||||
const ordered = bucketOrder
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
|
||||
.map((key) => [key, metrics.buckets[key]]);
|
||||
const rest = Object.entries(metrics.buckets)
|
||||
.filter(([key]) => !bucketOrder.includes(key))
|
||||
.sort((a, b) => Number(a[0].split('-')[0]) - Number(b[0].split('-')[0]));
|
||||
return [...ordered, ...rest];
|
||||
};
|
||||
|
||||
export const emptyPurchaseForm = () => ({ draw_no: '', amount: 5000, sets: 5, prize: 0, note: '' });
|
||||
|
||||
export const copyNumbers = async (nums) => {
|
||||
const text = nums.join(', ');
|
||||
try { await navigator.clipboard.writeText(text); alert(`복사 완료: ${text}`); }
|
||||
catch { prompt('복사해서 사용하세요:', text); }
|
||||
};
|
||||
|
||||
/* 종합 추론 상수 */
|
||||
export const METHOD_META = {
|
||||
frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
|
||||
fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
|
||||
gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
|
||||
cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
|
||||
diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
|
||||
};
|
||||
|
||||
export const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
|
||||
|
||||
export const SCORE_META = [
|
||||
{ key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
|
||||
{ key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
|
||||
{ key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
|
||||
{ key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
|
||||
{ key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
|
||||
];
|
||||
Reference in New Issue
Block a user