UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성

This commit is contained in:
2026-03-04 01:39:26 +09:00
parent 840b0a5300
commit 618d5f8e6f
21 changed files with 3499 additions and 374 deletions

View File

@@ -16,7 +16,7 @@
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-stock);
margin: 0 0 10px;
}
@@ -134,10 +134,11 @@
.stock-panel {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 24px;
border-radius: var(--radius-lg);
padding: 20px;
display: grid;
gap: 16px;
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.stock-panel--wide {
@@ -169,7 +170,7 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
color: var(--accent-stock);
}
.stock-panel__sub {
@@ -211,9 +212,9 @@
}
.stock-snapshot__card.is-highlight {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
border-color: rgba(96, 165, 250, 0.4);
background: rgba(96, 165, 250, 0.06);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.1), var(--shadow-inset);
}
.stock-snapshot__card p {
@@ -305,8 +306,9 @@
}
.stock-tab.is-active {
border-color: rgba(255, 255, 255, 0.5);
border-color: rgba(96, 165, 250, 0.5);
color: var(--text);
background: rgba(96, 165, 250, 0.1);
}
.stock-news__item {
@@ -340,7 +342,14 @@
}
.stock-news__meta a {
color: var(--accent);
color: var(--accent-stock);
text-decoration: none;
transition: opacity 0.15s;
}
.stock-news__meta a:hover {
opacity: 0.8;
text-decoration: underline;
}
.stock-empty {
@@ -918,4 +927,526 @@
.pf-total-summary__card strong {
font-size: 14px;
}
}
/* ── Cash Panel (예수금) ─────────────────────────────────────────── */
.pf-cash-table {
display: grid;
gap: 8px;
}
.pf-cash-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(0, 0, 0, 0.15);
font-size: 13px;
}
.pf-cash-broker {
flex: 1;
font-weight: 600;
color: var(--text);
}
.pf-cash-amount {
font-size: 15px;
color: #93c5fd;
}
.pf-cash-date {
color: var(--muted);
font-size: 11px;
min-width: 80px;
text-align: right;
}
.pf-cash-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: end;
padding: 14px;
border: 1px dashed var(--line);
border-radius: 16px;
background: rgba(0, 0, 0, 0.12);
}
.pf-cash-form label {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.pf-cash-form input {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
color: var(--text);
outline: none;
transition: border-color 0.2s ease;
}
.pf-cash-form input:focus {
border-color: var(--accent);
}
.pf-cash-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(147, 197, 253, 0.15);
border: 1px solid rgba(147, 197, 253, 0.3);
color: #93c5fd;
margin-left: 8px;
vertical-align: middle;
white-space: nowrap;
}
.pf-total-summary__card.is-cash {
border-color: rgba(147, 197, 253, 0.4);
}
.pf-total-summary__card.is-cash strong {
color: #93c5fd;
}
.pf-total-summary__card.is-assets {
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.04);
}
.pf-total-summary__card.is-assets strong {
font-size: 17px;
}
@media (max-width: 768px) {
.pf-cash-form {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 420px) {
.pf-cash-form {
grid-template-columns: 1fr;
}
.pf-cash-row {
flex-wrap: wrap;
gap: 8px;
}
.pf-cash-date {
display: none;
}
}
/* ══════════════════════════════════════════════════════════════════
Fear & Greed Index Panel
══════════════════════════════════════════════════════════════════ */
.fg-panel {
display: flex;
align-items: center;
gap: 28px;
padding: 16px 0 8px;
flex-wrap: wrap;
}
.fg-gauge {
flex: 1;
min-width: 200px;
}
.fg-gauge__track {
position: relative;
height: 14px;
border-radius: 7px;
background: linear-gradient(to right, #ef4444 0%, #f97316 25%, #eab308 50%, #84cc16 75%, #22c55e 100%);
margin-bottom: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.fg-gauge__needle {
position: absolute;
top: -5px;
transform: translateX(-50%);
width: 5px;
height: 24px;
background: #fff;
border-radius: 3px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.fg-gauge__labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--muted);
margin-top: 2px;
}
.fg-score-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 90px;
text-align: center;
}
.fg-score-number {
font-size: 48px;
font-weight: 800;
line-height: 1;
transition: color 0.4s ease;
}
.fg-score-label {
font-size: 14px;
font-weight: 700;
transition: color 0.4s ease;
}
.fg-score-date {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
/* ══════════════════════════════════════════════════════════════════
Report Charts Row
══════════════════════════════════════════════════════════════════ */
.report-charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.report-chart-box {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
}
.report-chart-title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
@media (max-width: 640px) {
.report-charts-row {
grid-template-columns: 1fr;
}
}
/* ══════════════════════════════════════════════════════════════════
Report Table (Sortable)
══════════════════════════════════════════════════════════════════ */
.report-table-wrapper {
overflow-x: auto;
margin-top: 8px;
border-radius: 12px;
border: 1px solid var(--line);
}
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.report-table thead {
background: rgba(0, 0, 0, 0.2);
}
.report-table th {
text-align: left;
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
color: var(--muted);
border-bottom: 1px solid var(--line);
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: color 0.2s;
}
.report-table th:hover {
color: var(--text);
}
.report-sort-icon {
font-size: 10px;
opacity: 0.7;
}
.report-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: var(--text);
vertical-align: middle;
}
.report-table tbody tr:last-child td {
border-bottom: none;
}
.report-table tbody tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.report-td-muted {
color: var(--muted) !important;
font-size: 12px !important;
}
.report-table-name {
margin: 0;
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.report-table-code {
font-size: 11px;
color: var(--muted);
}
.report-rate-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 80px;
}
.report-rate-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
width: 80px;
}
.report-rate-bar__fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.report-rate-bar__fill.is-up {
background: #34d399;
}
.report-rate-bar__fill.is-down {
background: #f87171;
}
/* ══════════════════════════════════════════════════════════════════
AI 투자 코치 패널
══════════════════════════════════════════════════════════════════ */
.ai-coach-settings {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line);
border-radius: 14px;
margin-bottom: 16px;
align-items: end;
}
.ai-coach-settings label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.ai-coach-settings input,
.ai-coach-settings select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 9px 12px;
background: rgba(0, 0, 0, 0.25);
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.ai-coach-settings input:focus,
.ai-coach-settings select:focus {
border-color: var(--accent);
}
.ai-coach-key-row {
display: flex;
gap: 8px;
align-items: center;
}
.ai-coach-key-input {
flex: 1;
min-width: 0;
}
.ai-coach-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.ai-coach-note {
font-size: 11px;
color: var(--muted);
}
/* Result */
.ai-coach-result {
border-top: 1px solid var(--line);
padding-top: 20px;
margin-top: 4px;
}
.ai-coach-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.ai-grade-badge {
width: 60px;
height: 60px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 800;
flex-shrink: 0;
letter-spacing: -1px;
}
.ai-grade-badge.grade-s { background: linear-gradient(135deg, #7c3aed, #4f46e5); color: #fff; }
.ai-grade-badge.grade-a { background: linear-gradient(135deg, #059669, #10b981); color: #fff; }
.ai-grade-badge.grade-b { background: linear-gradient(135deg, #0284c7, #38bdf8); color: #fff; }
.ai-grade-badge.grade-c { background: linear-gradient(135deg, #d97706, #fbbf24); color: #fff; }
.ai-grade-badge.grade-d { background: linear-gradient(135deg, #dc2626, #f87171); color: #fff; }
.ai-score-wrap {
display: flex;
align-items: baseline;
gap: 3px;
}
.ai-score-num {
font-size: 36px;
font-weight: 800;
color: var(--text);
line-height: 1;
}
.ai-score-unit {
font-size: 14px;
color: var(--muted);
}
.ai-summary-text {
flex: 1;
font-size: 16px;
font-weight: 600;
color: var(--text);
margin: 0;
min-width: 140px;
}
.ai-evaluation-text {
font-size: 13px;
color: var(--muted);
line-height: 1.7;
margin: 0 0 20px;
}
.ai-advice-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.ai-advice-card {
background: rgba(129, 140, 248, 0.07);
border: 1px solid rgba(129, 140, 248, 0.2);
border-radius: 14px;
padding: 14px 16px;
transition: background 0.2s;
}
.ai-advice-card:hover {
background: rgba(129, 140, 248, 0.12);
}
.ai-advice-title {
font-size: 13px;
font-weight: 700;
color: #a5b4fc;
margin: 0 0 6px;
}
.ai-advice-body {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
margin: 0;
}
@media (max-width: 700px) {
.ai-coach-settings {
grid-template-columns: 1fr;
}
.ai-advice-list {
grid-template-columns: 1fr;
}
.ai-grade-badge {
width: 48px;
height: 48px;
font-size: 24px;
}
.ai-score-num {
font-size: 28px;
}
}
@media (max-width: 480px) {
.fg-gauge__labels span:nth-child(2),
.fg-gauge__labels span:nth-child(4) {
display: none;
}
}

View File

@@ -7,9 +7,17 @@ import {
addPortfolio,
updatePortfolio,
deletePortfolio,
upsertCash,
deleteCash,
getFearAndGreed,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
import {
PieChart, Pie, Cell,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
} from 'recharts';
/* ── helpers ─────────────────────────────────────────────────────── */
@@ -73,6 +81,28 @@ const toNumeric = (value) => {
return Number.isNaN(numeric) ? null : numeric;
};
/* ── Fear & Greed helpers ──────────────────────────────────────── */
const getFgColor = (score) => {
if (score <= 25) return '#ef4444';
if (score <= 45) return '#f97316';
if (score <= 55) return '#eab308';
if (score <= 75) return '#84cc16';
return '#22c55e';
};
const getFgLabel = (score) => {
if (score <= 25) return '극단적 공포';
if (score <= 45) return '공포';
if (score <= 55) return '중립';
if (score <= 75) return '탐욕';
return '극단적 탐욕';
};
/* ── Chart colors ──────────────────────────────────────────────── */
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
const profitColorClass = (numericValue) => {
if (numericValue > 0) return 'is-up';
if (numericValue < 0) return 'is-down';
@@ -94,6 +124,7 @@ const emptyPortfolioForm = {
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
const TAB_REPORT = 'report';
/* ── component ───────────────────────────────────────────────────── */
@@ -124,6 +155,30 @@ const StockTrade = () => {
/* Portfolio delete */
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* Cash (예수금) form */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
const [cashSaving, setCashSaving] = useState(false);
const [cashError, setCashError] = useState('');
/* ────────────────────────────────────────────────────────────── */
/* 리포트 탭 state */
/* ────────────────────────────────────────────────────────────── */
const [reportSortField, setReportSortField] = useState('profit_rate');
const [reportSortDir, setReportSortDir] = useState('desc');
/* Fear & Greed */
const [fgData, setFgData] = useState(null);
const [fgLoading, setFgLoading] = useState(false);
const [fgError, setFgError] = useState('');
const [fgLoaded, setFgLoaded] = useState(false);
/* AI Coach */
const [aiApiKey, setAiApiKey] = useState('');
const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001');
const [aiResult, setAiResult] = useState(null);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState('');
/* ────────────────────────────────────────────────────────────── */
/* AI 투자 (Balance) state */
/* ────────────────────────────────────────────────────────────── */
@@ -160,6 +215,23 @@ const StockTrade = () => {
}
}, []);
const loadFearAndGreed = useCallback(async () => {
setFgLoading(true);
setFgError('');
try {
const data = await getFearAndGreed();
const fg = data?.fear_and_greed ?? data;
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
if (isNaN(score)) throw new Error('지수 데이터 형식 오류');
setFgData({ score, rating: fg.rating ?? '', timestamp: fg.timestamp ?? null });
setFgLoaded(true);
} catch (err) {
setFgError('F&G 지수 조회 실패: ' + (err?.message ?? String(err)));
} finally {
setFgLoading(false);
}
}, []);
const loadBalance = useCallback(async () => {
setBalanceLoading(true);
setBalanceError('');
@@ -180,9 +252,31 @@ const StockTrade = () => {
loadPortfolio();
} else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio();
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
/* Fear & Greed: 리포트 탭 첫 진입 시 자동 로드 */
useEffect(() => {
if (activeTab === TAB_REPORT && !fgLoaded) {
loadFearAndGreed();
}
}, [activeTab, fgLoaded, loadFearAndGreed]);
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
useEffect(() => {
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
const savedModel = localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001';
setAiApiKey(savedKey);
setAiModel(savedModel);
const today = new Date().toISOString().slice(0, 10);
const cached = localStorage.getItem(`ai_coach_${today}`);
if (cached) {
try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ }
}
}, []);
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
useEffect(() => {
if (activeTab !== TAB_PORTFOLIO) return;
@@ -277,6 +371,126 @@ const StockTrade = () => {
}
};
/* ── cash (예수금) actions ──────────────────────────────────── */
const handleCashSave = async (e) => {
e.preventDefault();
if (!cashForm.broker.trim() || cashForm.cash === '') return;
setCashSaving(true);
setCashError('');
try {
await upsertCash(cashForm.broker.trim(), Number(cashForm.cash));
setCashForm({ broker: '', cash: '' });
await loadPortfolio();
} catch (err) {
setCashError(err?.message ?? String(err));
} finally {
setCashSaving(false);
}
};
const handleCashDelete = async (broker) => {
try {
await deleteCash(broker);
await loadPortfolio();
} catch (err) {
alert('예수금 삭제 실패: ' + (err?.message ?? String(err)));
}
};
/* ── report sort ─────────────────────────────────────────────── */
const handleReportSort = (field) => {
if (reportSortField === field) {
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setReportSortField(field);
setReportSortDir('desc');
}
};
/* ── AI coach ────────────────────────────────────────────────── */
const handleAiCoach = async () => {
if (!aiApiKey.trim() || portfolioHoldings.length === 0) return;
const today = new Date().toISOString().slice(0, 10);
const cacheKey = `ai_coach_${today}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid cache */ }
}
setAiLoading(true);
setAiError('');
const holdingsText = portfolioHoldings
.map((item) =>
`- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}`
)
.join('\n');
const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오를 분석하여 JSON으로만 답하세요.
분석 일자: ${today}
총 매입금액: ${formatNumber(portfolioSummary.total_buy)}
총 평가금액: ${formatNumber(portfolioSummary.total_eval)}
총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
보유 종목 수: ${portfolioHoldings.length}
보유 종목:
${holdingsText}
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
{
"score": 85,
"grade": "A",
"summary": "30자 이내 한줄 평가",
"evaluation": "200자 이내 상세 평가",
"advice": [
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
]
}`;
try {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': aiApiKey.trim(),
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: aiModel,
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Claude API 오류 (${res.status}): ${text.slice(0, 200)}`);
}
const data = await res.json();
const text = data.content?.[0]?.text ?? '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.');
const result = JSON.parse(jsonMatch[0]);
const final = { ...result, generated_at: new Date().toISOString(), cached: false };
localStorage.setItem(cacheKey, JSON.stringify(final));
setAiResult(final);
} catch (err) {
setAiError(err?.message ?? String(err));
} finally {
setAiLoading(false);
}
};
/* ── manual order ────────────────────────────────────────────── */
const submitManualOrder = async (event) => {
@@ -327,6 +541,9 @@ const StockTrade = () => {
const portfolioHoldings = portfolio?.holdings ?? [];
const portfolioSummary = portfolio?.summary ?? {};
const cashList = portfolio?.cash ?? [];
const totalCash = portfolioSummary.total_cash ?? null;
const totalAssets = portfolioSummary.total_assets ?? null;
const brokerGroups = useMemo(() => {
const map = {};
for (const item of portfolioHoldings) {
@@ -370,6 +587,62 @@ const StockTrade = () => {
return map;
}, [brokerGroups]);
/* ── derived: Report ──────────────────────────────────────────── */
const brokerPieData = useMemo(() =>
brokerGroups
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
.filter((d) => d.value > 0),
[brokerGroups]
);
const profitBarData = useMemo(() =>
portfolioHoldings
.filter((item) => item.profit_rate != null)
.map((item) => ({
name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5),
fullName: item.name ?? item.ticker ?? 'N/A',
rate: toNumeric(item.profit_rate) ?? 0,
}))
.sort((a, b) => b.rate - a.rate),
[portfolioHoldings]
);
const maxAbsRate = useMemo(() =>
Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))),
[portfolioHoldings]
);
const sortedHoldings = useMemo(() => {
const getVal = (item) => {
switch (reportSortField) {
case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity;
case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity;
case 'eval_amount': {
const ea = toNumeric(item.eval_amount);
if (ea != null) return ea;
const cp = toNumeric(item.current_price);
const qty = toNumeric(item.quantity);
return cp != null && qty != null ? cp * qty : -Infinity;
}
default: return 0;
}
};
return [...portfolioHoldings].sort((a, b) => {
if (reportSortField === 'name')
return reportSortDir === 'asc'
? (a.name ?? '').localeCompare(b.name ?? '')
: (b.name ?? '').localeCompare(a.name ?? '');
if (reportSortField === 'broker')
return reportSortDir === 'asc'
? (a.broker ?? '').localeCompare(b.broker ?? '')
: (b.broker ?? '').localeCompare(a.broker ?? '');
const av = getVal(a);
const bv = getVal(b);
return reportSortDir === 'asc' ? av - bv : bv - av;
});
}, [portfolioHoldings, reportSortField, reportSortDir]);
/* ── render ───────────────────────────────────────────────────── */
return (
@@ -390,11 +663,9 @@ const StockTrade = () => {
</div>
<div className="stock-card">
<p className="stock-card__title">
{activeTab === TAB_PORTFOLIO
? '쟁승토리 계좌 요약'
: 'AI 투자 요약'}
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
</p>
{activeTab === TAB_PORTFOLIO ? (
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
/* Portfolio summary */
<div className="stock-status">
<div>
@@ -424,6 +695,22 @@ const StockTrade = () => {
<span>보유 종목</span>
<strong>{portfolioHoldings.length}</strong>
</div>
{totalCash != null && (
<div>
<span>예수금 합계</span>
<strong style={{ color: '#93c5fd' }}>
{formatNumber(totalCash)}
</strong>
</div>
)}
{totalAssets != null && (
<div>
<span> 자산</span>
<strong style={{ fontWeight: 700 }}>
{formatNumber(totalAssets)}
</strong>
</div>
)}
</div>
) : (
/* AI balance summary */
@@ -472,6 +759,15 @@ const StockTrade = () => {
<span className="stock-main-tab__label">AI 투자</span>
<span className="stock-main-tab__sub">모의투자</span>
</button>
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_REPORT ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_REPORT)}
>
<span className="stock-main-tab__icon">📊</span>
<span className="stock-main-tab__label">리포트</span>
<span className="stock-main-tab__sub">분석·AI코치</span>
</button>
</div>
{/* ════════════════════════════════════════════════════════
@@ -612,10 +908,102 @@ const StockTrade = () => {
</strong>
</div>
))}
{totalCash != null && (
<div className="pf-total-summary__card is-cash">
<span>예수금 합계</span>
<strong>{formatNumber(totalCash)}</strong>
</div>
)}
{totalAssets != null && (
<div className="pf-total-summary__card is-assets">
<span> 자산</span>
<strong>{formatNumber(totalAssets)}</strong>
</div>
)}
</div>
)}
</section>
{/* 예수금 패널 */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">예수금 관리</p>
<h3>증권사별 예수금</h3>
<p className="stock-panel__sub">
증권사별 예수금을 입력하면 자산에 자동 반영됩니다.
</p>
</div>
</div>
{cashList.length > 0 && (
<div className="pf-cash-table">
{cashList.map((item) => (
<div key={item.id ?? item.broker} className="pf-cash-row">
<span className="pf-cash-broker">{item.broker}</span>
<strong className="pf-cash-amount">
{formatNumber(item.cash)}
</strong>
<span className="pf-cash-date">
{item.updated_at
? new Date(item.updated_at).toLocaleDateString('ko-KR')
: ''}
</span>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleCashDelete(item.broker)}
title="삭제"
>
🗑
</button>
</div>
))}
</div>
)}
{cashList.length === 0 && (
<p className="stock-empty" style={{ fontSize: 13 }}>
등록된 예수금이 없습니다.
</p>
)}
<form className="pf-cash-form" onSubmit={handleCashSave}>
<label>
증권사명
<input
type="text"
value={cashForm.broker}
onChange={(e) =>
setCashForm((p) => ({ ...p, broker: e.target.value }))
}
placeholder="KB증권"
required
/>
</label>
<label>
예수금 ()
<input
type="number"
min={0}
step={1}
value={cashForm.cash}
onChange={(e) =>
setCashForm((p) => ({ ...p, cash: e.target.value }))
}
placeholder="1500000"
required
/>
</label>
<button
className="button primary"
type="submit"
disabled={cashSaving}
>
{cashSaving ? '저장 중...' : '저장'}
</button>
{cashError && <p className="stock-error">{cashError}</p>}
</form>
</section>
{/* Broker cards stacked */}
{brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items);
@@ -649,6 +1037,16 @@ const StockTrade = () => {
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
</span>
{(() => {
const bc = cashList.find(
(c) => c.broker === broker
);
return bc ? (
<span className="pf-cash-badge">
예수금 {formatNumber(bc.cash)}
</span>
) : null;
})()}
</p>
</div>
</div>
@@ -1044,6 +1442,344 @@ const StockTrade = () => {
</>
)}
{/* ════════════════════════════════════════════════════════
TAB 3: 리포트 + AI 코치
════════════════════════════════════════════════════════ */}
{activeTab === TAB_REPORT && (
<>
{portfolioLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Loading type="spinner" message="포트폴리오 로딩 중..." />
</div>
)}
{portfolioError && <p className="stock-error">{portfolioError}</p>}
{/* ── Fear & Greed Index ─────────────────────────── */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">시장 심리 지표</p>
<h3>Fear & Greed Index</h3>
<p className="stock-panel__sub">
CNN Fear &amp; Greed 지수로 현재 시장 심리를 파악합니다.
</p>
</div>
<div className="stock-panel__actions">
<button
className="button ghost small"
onClick={loadFearAndGreed}
disabled={fgLoading}
>
{fgLoading ? '조회 중...' : '새로고침'}
</button>
</div>
</div>
{fgError && <p className="stock-error">{fgError}</p>}
{fgData ? (
<div className="fg-panel">
<div className="fg-gauge">
<div className="fg-gauge__track">
<div
className="fg-gauge__needle"
style={{ left: `${Math.min(100, Math.max(0, fgData.score))}%` }}
/>
</div>
<div className="fg-gauge__labels">
<span>극단적 공포</span>
<span>공포</span>
<span>중립</span>
<span>탐욕</span>
<span>극단적 탐욕</span>
</div>
</div>
<div className="fg-score-display">
<span className="fg-score-number" style={{ color: getFgColor(fgData.score) }}>
{Math.round(fgData.score)}
</span>
<span className="fg-score-label" style={{ color: getFgColor(fgData.score) }}>
{getFgLabel(fgData.score)}
</span>
{fgData.timestamp && (
<span className="fg-score-date">
{new Date(fgData.timestamp).toLocaleDateString('ko-KR')}
</span>
)}
</div>
</div>
) : !fgError ? (
<p className="stock-empty">지수 데이터를 불러오는 ...</p>
) : null}
</section>
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
{portfolioHoldings.length > 0 && (
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
<h3>자산 배분 현황</h3>
</div>
</div>
<div className="report-charts-row">
<div className="report-chart-box">
<p className="report-chart-title">증권사별 자산 배분</p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={brokerPieData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={84}
dataKey="value"
paddingAngle={2}
>
{brokerPieData.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<ChartTooltip
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
/>
<Legend
iconType="circle"
iconSize={8}
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="report-chart-box">
<p className="report-chart-title">종목별 수익률 (%)</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis
dataKey="name"
tick={{ fill: '#9ca3af', fontSize: 10 }}
angle={-40}
textAnchor="end"
interval={0}
/>
<YAxis
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={(v) => `${v}%`}
/>
<ChartTooltip
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
/>
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
{profitBarData.map((entry, i) => (
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</section>
)}
{/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
{portfolioHoldings.length > 0 && (
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">수익률 랭킹</p>
<h3>종목별 상세 현황</h3>
<p className="stock-panel__sub">헤더 클릭으로 정렬</p>
</div>
</div>
<div className="report-table-wrapper">
<table className="report-table">
<thead>
<tr>
{[
{ key: 'name', label: '종목명' },
{ key: 'broker', label: '증권사' },
{ key: 'profit_rate', label: '수익률' },
{ key: 'profit_amount', label: '평가손익' },
{ key: 'eval_amount', label: '평가금액' },
].map(({ key, label }) => (
<th key={key} onClick={() => handleReportSort(key)}>
{label}{' '}
<span className="report-sort-icon">
{reportSortField === key
? reportSortDir === 'asc' ? '↑' : '↓'
: '↕'}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sortedHoldings.map((item) => {
const rateN = toNumeric(item.profit_rate);
const pnlN = toNumeric(item.profit_amount);
const evalAmt = item.eval_amount != null
? item.eval_amount
: item.current_price != null
? item.current_price * item.quantity
: null;
return (
<tr key={item.id}>
<td>
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
<span className="report-table-code">{item.ticker ?? ''}</span>
</td>
<td className="report-td-muted">{item.broker ?? '-'}</td>
<td className={`stock-profit ${profitColorClass(rateN)}`}>
<div className="report-rate-cell">
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
{rateN != null && (
<div className="report-rate-bar">
<div
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }}
/>
</div>
)}
</div>
</td>
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
</td>
<td className="report-td-muted">
{evalAmt != null ? formatNumber(evalAmt) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
<section className="stock-panel stock-panel--wide">
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> 탭에서 종목을 먼저 등록하세요.
</p>
</section>
)}
{/* ── AI 투자 코치 ───────────────────────────────── */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">AI 투자 코치</p>
<h3>오늘의 투자 평가</h3>
<p className="stock-panel__sub">
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
</p>
</div>
</div>
{/* API Key 설정 */}
<div className="ai-coach-settings">
<label>
Anthropic API Key
<div className="ai-coach-key-row">
<input
type="password"
className="ai-coach-key-input"
value={aiApiKey}
onChange={(e) => setAiApiKey(e.target.value)}
placeholder="sk-ant-api03-..."
/>
<button
className="button ghost small"
type="button"
onClick={() => {
localStorage.setItem('ai_coach_key', aiApiKey);
localStorage.setItem('ai_coach_model', aiModel);
}}
>
저장
</button>
</div>
</label>
<label>
AI 모델
<select
value={aiModel}
onChange={(e) => {
setAiModel(e.target.value);
localStorage.setItem('ai_coach_model', e.target.value);
}}
>
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
</select>
</label>
</div>
<div className="ai-coach-actions">
<button
className="button primary"
type="button"
onClick={handleAiCoach}
disabled={aiLoading || !aiApiKey.trim() || portfolioHoldings.length === 0}
>
{aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
</button>
{portfolioHoldings.length === 0 && (
<span className="ai-coach-note">종목 등록 이용 가능합니다.</span>
)}
{aiResult?.generated_at && (
<span className="ai-coach-note">
{aiResult.cached ? '오늘 캐시 결과 · ' : ''}
{new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
</span>
)}
</div>
{aiError && <p className="stock-error" style={{ marginTop: 8 }}>{aiError}</p>}
{aiResult && !aiLoading && (
<div className="ai-coach-result">
<div className="ai-coach-header">
<div className={`ai-grade-badge grade-${(aiResult.grade ?? 'c').toLowerCase()}`}>
{aiResult.grade ?? '?'}
</div>
<div className="ai-score-wrap">
<span className="ai-score-num">{aiResult.score ?? 0}</span>
<span className="ai-score-unit">/ 100</span>
</div>
<p className="ai-summary-text">{aiResult.summary}</p>
</div>
<p className="ai-evaluation-text">{aiResult.evaluation}</p>
{aiResult.advice?.length > 0 && (
<div className="ai-advice-list">
{aiResult.advice.map((a, i) => (
<div key={i} className="ai-advice-card">
<p className="ai-advice-title">{a.title}</p>
<p className="ai-advice-body">{a.body}</p>
</div>
))}
</div>
)}
<button
className="button ghost small"
type="button"
style={{ marginTop: 16, fontSize: 11 }}
onClick={() => {
const today = new Date().toISOString().slice(0, 10);
localStorage.removeItem(`ai_coach_${today}`);
setAiResult(null);
}}
>
다시 평가받기 (캐시 삭제)
</button>
</div>
)}
</section>
</>
)}
{/* KIS modal */}
{kisModal ? (
<div className="stock-modal" role="dialog" aria-modal="true">