dashboard 형태의 UI 수정 및 고도화

This commit is contained in:
2026-03-04 08:29:39 +09:00
parent 618d5f8e6f
commit ccc9f7c634
17 changed files with 1296 additions and 224 deletions

View File

@@ -240,11 +240,11 @@
}
.stock-snapshot__change.is-up {
color: #f3a7a7;
color: #f04452;
}
.stock-snapshot__change.is-down {
color: #9fc5ff;
color: #3b82f6;
}
.stock-schedule {
@@ -291,7 +291,6 @@
.stock-tabs {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.stock-tab {
@@ -452,11 +451,11 @@
}
.stock-profit.is-up {
color: #f3a7a7;
color: #f04452;
}
.stock-profit.is-down {
color: #9fc5ff;
color: #3b82f6;
}
.stock-profit.is-flat {
@@ -1122,6 +1121,115 @@
margin-top: 2px;
}
/* ── F&G Level 설명 ─────────────────────────────────────────────── */
.fg-wrap {
display: flex;
flex-direction: column;
gap: 0;
}
.fg-levels {
display: grid;
gap: 6px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.fg-level {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
transition: background 0.2s ease, border-color 0.2s ease;
}
.fg-level.is-current {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.fg-level__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.fg-level__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.fg-level__label {
font-size: 12px;
font-weight: 700;
}
.fg-level__range {
font-size: 11px;
color: var(--muted);
margin-left: auto;
}
.fg-level__desc {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
padding-left: 16px;
}
/* ── 뉴스 툴바 (탭 + 인라인 필터) ─────────────────────────────── */
.stock-news-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.stock-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: rgba(96, 165, 250, 0.15);
font-size: 10px;
font-weight: 700;
margin-left: 4px;
vertical-align: middle;
}
.stock-tab.is-active .stock-tab-count {
background: rgba(96, 165, 250, 0.3);
}
.stock-news-limit {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
outline: none;
transition: border-color 0.2s ease;
}
.stock-news-limit:hover,
.stock-news-limit:focus {
border-color: rgba(96, 165, 250, 0.4);
}
/* ══════════════════════════════════════════════════════════════════
Report Charts Row
══════════════════════════════════════════════════════════════════ */
@@ -1449,4 +1557,126 @@
.fg-gauge__labels span:nth-child(4) {
display: none;
}
}
/* ══════════════════════════════════════════════════════════════════
VIX Panel
══════════════════════════════════════════════════════════════════ */
.stock-vix {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 0 8px;
text-align: center;
}
.stock-vix__score {
font-size: 52px;
font-weight: 800;
line-height: 1;
transition: color 0.4s ease;
}
.stock-vix__label {
margin: 0;
font-size: 14px;
font-weight: 700;
transition: color 0.4s ease;
}
.stock-vix__legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
justify-content: center;
margin-top: 10px;
font-size: 11px;
}
.stock-vix__legend span {
font-weight: 500;
}
/* ══════════════════════════════════════════════════════════════════
News Card Grid
══════════════════════════════════════════════════════════════════ */
.stock-news-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.stock-news-card {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: rgba(0, 0, 0, 0.2);
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.stock-news-card:hover {
border-color: rgba(96, 165, 250, 0.3);
background: rgba(96, 165, 250, 0.04);
transform: translateY(-2px);
}
.stock-news-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.stock-news-card__date {
font-size: 11px;
color: var(--muted);
}
.stock-news-card__title {
margin: 0;
font-weight: 600;
font-size: 15px;
color: var(--text);
line-height: 1.4;
}
.stock-news-card__summary {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.stock-news-card__link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--accent-stock);
text-decoration: none;
margin-top: auto;
transition: opacity 0.15s;
font-weight: 500;
}
.stock-news-card__link:hover {
opacity: 0.75;
text-decoration: underline;
}
@media (max-width: 640px) {
.stock-news-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews } from '../../api';
import { getStockIndices, getStockNews, getFearAndGreed, getVix } from '../../api';
import Loading from '../../components/Loading';
import FearGreedGauge from '../../components/FearGreedGauge';
import './Stock.css';
const formatDate = (value) => {
@@ -11,21 +12,6 @@ const formatDate = (value) => {
return date.toLocaleString('sv-SE');
};
const toDateValue = (value) => {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
const getLatestBy = (items, key) => {
const filtered = items
.map((item) => ({ ...item, __date: toDateValue(item?.[key]) }))
.filter((item) => item.__date);
if (!filtered.length) return null;
filtered.sort((a, b) => b.__date - a.__date);
return filtered[0]?.[key] ?? null;
};
const normalizeIndices = (data) => {
if (!data) return [];
@@ -65,23 +51,40 @@ const normalizeIndices = (data) => {
};
const getDirection = (change, percent, direction) => {
if (direction === 'red') return 'up';
if (direction === 'blue') return 'down';
const pick = (value) =>
value === undefined || value === null || value === '' ? null : value;
const raw = pick(change) ?? pick(percent);
if (!raw) return '';
const str = String(raw).trim();
if (str.startsWith('-')) return 'down';
if (str.startsWith('+')) return 'up';
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
if (Number.isFinite(numeric)) {
if (numeric > 0) return 'up';
if (numeric < 0) return 'down';
// 숫자 부호로 방향 추출 (percent → change 순서로 시도)
const fromStr = (s) => {
if (s === undefined || s === null || s === '') return null;
const str = String(s).trim();
if (str.startsWith('-')) return 'down';
if (str.startsWith('+')) return 'up';
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
if (Number.isFinite(numeric) && numeric !== 0) {
return numeric > 0 ? 'up' : 'down';
}
return null;
};
// percent 필드가 부호를 가장 신뢰성 있게 포함하는 경우가 많음
const byPercent = fromStr(percent);
if (byPercent) return byPercent;
const byChange = fromStr(change);
if (byChange) return byChange;
// 숫자로 판별 불가 시 direction 필드 fallback
if (direction) {
const d = String(direction).toLowerCase();
if (d === 'red' || d === 'up' || d === 'rise' || d === 'positive') return 'up';
if (d === 'blue' || d === 'down' || d === 'fall' || d === 'negative') return 'down';
}
return '';
};
const getVixLevel = (score) => {
if (score < 12) return { label: '극히 낮음', color: '#22c55e' };
if (score < 20) return { label: '정상', color: '#84cc16' };
if (score < 30) return { label: '보통', color: '#eab308' };
if (score < 40) return { label: '높음', color: '#f97316' };
return { label: '극단', color: '#ef4444' };
};
const Stock = () => {
const [newsDomestic, setNewsDomestic] = useState([]);
const [newsOverseas, setNewsOverseas] = useState([]);
@@ -94,14 +97,13 @@ const Stock = () => {
const [indicesLoading, setIndicesLoading] = useState(false);
const [autoRefreshMs] = useState(180000);
const [fgData, setFgData] = useState(null);
const [vixData, setVixData] = useState(null);
const combinedNews = useMemo(
() => [...newsDomestic, ...newsOverseas],
[newsDomestic, newsOverseas]
);
const latestPublished = useMemo(
() => getLatestBy(combinedNews, 'published_at'),
[combinedNews]
);
const loadNews = async () => {
setLoading(true);
@@ -143,6 +145,19 @@ const Stock = () => {
return () => window.clearInterval(timer);
}, [autoRefreshMs]);
useEffect(() => {
getFearAndGreed()
.then((data) => {
const fg = data?.fear_and_greed ?? data;
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
if (!isNaN(score)) {
setFgData({ score, timestamp: fg?.timestamp ?? null });
}
})
.catch(() => { });
getVix().then(setVixData).catch(() => { });
}, []);
const indexOrder = [
'KOSPI',
'KOSDAQ',
@@ -262,62 +277,53 @@ const Stock = () => {
</div>
</section>
{/* 시장 심리 지표 행 */}
<section className="stock-filter-row">
<div className="stock-panel stock-panel--compact">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">필터</p>
<h3>뉴스 필터</h3>
<p className="stock-panel__sub">
표시할 뉴스 개수를 조정합니다.
</p>
<p className="stock-panel__eyebrow">심리 지표</p>
<h3>Fear & Greed</h3>
<p className="stock-panel__sub">시장 탐욕·공포 지수 (0100)</p>
</div>
</div>
<div className="stock-filter">
<label>
표시 개수
<select
value={limit}
onChange={(event) =>
setLimit(Number(event.target.value))
}
>
{[10, 20, 30, 40].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<p className="stock-filter__note">
최신 뉴스가 먼저 표시됩니다.
</p>
</div>
{fgData ? (
<FearGreedGauge
score={Math.round(fgData.score)}
date={fgData.timestamp ? new Date(fgData.timestamp).toLocaleDateString('ko-KR') : undefined}
showLevels
/>
) : (
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
)}
</div>
<div className="stock-panel stock-panel--compact">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">요약</p>
<h3>뉴스 요약</h3>
<p className="stock-panel__sub">
최신 발행 시각과 기사 수를 확인합니다.
<p className="stock-panel__eyebrow">변동성 지수</p>
<h3>VIX</h3>
<p className="stock-panel__sub">CBOE 공포 지수</p>
</div>
</div>
{vixData ? (
<div className="stock-vix">
<div className="stock-vix__score" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
{vixData.value ?? vixData.vix ?? '--'}
</div>
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
{getVixLevel(vixData.value ?? vixData.vix ?? 0).label}
</p>
<div className="stock-vix__legend">
<span style={{ color: '#22c55e' }}>{'<12'} 극히낮음</span>
<span style={{ color: '#84cc16' }}>12-20 정상</span>
<span style={{ color: '#eab308' }}>20-30 보통</span>
<span style={{ color: '#f97316' }}>30-40 높음</span>
<span style={{ color: '#ef4444' }}>{'40+'} 극단</span>
</div>
</div>
</div>
<div className="stock-status">
<div>
<span>최신 발행</span>
<strong>{formatDate(latestPublished)}</strong>
</div>
<div>
<span>국내</span>
<strong>{newsDomestic.length}</strong>
</div>
<div>
<span>해외</span>
<strong>{newsOverseas.length}</strong>
</div>
</div>
) : (
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
)}
</div>
</section>
@@ -347,46 +353,46 @@ const Stock = () => {
<p className="stock-empty">뉴스가 없습니다.</p>
) : (
<>
<div className="stock-tabs">
<button
type="button"
className={`stock-tab ${newsCategory === 'domestic'
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('domestic')}
<div className="stock-news-toolbar">
<div className="stock-tabs">
<button
type="button"
className={`stock-tab ${newsCategory === 'domestic' ? 'is-active' : ''}`}
onClick={() => setNewsCategory('domestic')}
>
국내 <span className="stock-tab-count">{newsDomestic.length}</span>
</button>
<button
type="button"
className={`stock-tab ${newsCategory === 'overseas' ? 'is-active' : ''}`}
onClick={() => setNewsCategory('overseas')}
>
해외 <span className="stock-tab-count">{newsOverseas.length}</span>
</button>
</div>
<select
className="stock-news-limit"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
>
국내
</button>
<button
type="button"
className={`stock-tab ${newsCategory === 'overseas'
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('overseas')}
>
해외
</button>
{[10, 20, 30, 40].map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</div>
{activeNews.length === 0 ? (
<p className="stock-empty">
해당 카테고리 뉴스가 없습니다.
</p>
) : (
<div className="stock-news">
<div className="stock-news-grid">
{activeNews.map((item) => (
<article
key={item.id ?? item.link}
className="stock-news__item"
className="stock-news-card"
>
<div>
<p className="stock-news__title">
{item.title}
</p>
</div>
<div className="stock-news__meta">
<span>
<div className="stock-news-card__head">
<span className="stock-news-card__date">
{formatDate(item.published_at)}
</span>
{item.sentiment ? (
@@ -394,14 +400,25 @@ const Stock = () => {
{item.sentiment}
</span>
) : null}
</div>
<p className="stock-news-card__title">
{item.title}
</p>
{item.summary && (
<p className="stock-news-card__summary">
{item.summary}
</p>
)}
{item.link && (
<a
href={item.link}
target="_blank"
rel="noreferrer"
className="stock-news-card__link"
>
원문 보기
원문 보기
</a>
</div>
)}
</article>
))}
</div>

View File

@@ -9,7 +9,6 @@ import {
deletePortfolio,
upsertCash,
deleteCash,
getFearAndGreed,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
@@ -81,24 +80,6 @@ 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'];
@@ -130,7 +111,7 @@ const TAB_REPORT = 'report';
const StockTrade = () => {
/* Active tab */
const [activeTab, setActiveTab] = useState(TAB_PORTFOLIO);
const [activeTab, setActiveTab] = useState(TAB_REPORT);
/* ────────────────────────────────────────────────────────────── */
/* 쟁승토리 계좌 (Portfolio) state */
@@ -166,12 +147,6 @@ const StockTrade = () => {
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');
@@ -215,23 +190,6 @@ 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('');
@@ -257,13 +215,6 @@ const StockTrade = () => {
}
}, [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') ?? '';
@@ -1454,63 +1405,6 @@ ${holdingsText}
)}
{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">