From ccc9f7c63457c71be03ac24d4bd2982c12c4aa87 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 4 Mar 2026 08:29:39 +0900 Subject: [PATCH] =?UTF-8?q?dashboard=20=ED=98=95=ED=83=9C=EC=9D=98=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 2 + src/api.js | 32 ++++ src/components/FearGreedGauge.jsx | 106 ++++++++++++ src/components/Icons.jsx | 12 ++ src/components/Navbar.css | 6 +- src/components/Navbar.jsx | 2 +- src/components/PageHeader.css | 67 ++++++++ src/components/PageHeader.jsx | 31 ++++ src/pages/home/Home.css | 88 ++++++++++ src/pages/home/Home.jsx | 49 +++++- src/pages/stock/Stock.css | 240 +++++++++++++++++++++++++- src/pages/stock/Stock.jsx | 233 +++++++++++++------------ src/pages/stock/StockTrade.jsx | 108 +----------- src/pages/todo/Todo.css | 271 ++++++++++++++++++++++++++++++ src/pages/todo/Todo.jsx | 240 ++++++++++++++++++++++++++ src/routes.jsx | 21 +++ vite.config.js | 12 ++ 17 files changed, 1296 insertions(+), 224 deletions(-) create mode 100644 src/components/FearGreedGauge.jsx create mode 100644 src/components/PageHeader.css create mode 100644 src/components/PageHeader.jsx create mode 100644 src/pages/todo/Todo.css create mode 100644 src/pages/todo/Todo.jsx diff --git a/src/App.jsx b/src/App.jsx index 1530411..3e20dc8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Outlet } from 'react-router-dom'; import Navbar from './components/Navbar'; +import PageHeader from './components/PageHeader'; import Loading from './components/Loading'; import './App.css'; @@ -10,6 +11,7 @@ function App() {
+
}> diff --git a/src/api.js b/src/api.js index 734ca2a..075bc55 100644 --- a/src/api.js +++ b/src/api.js @@ -155,3 +155,35 @@ export async function getFearAndGreed() { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } + +// VIX 지수 (Yahoo Finance 공개 API) +export async function getVix() { + const res = await fetch('/ext/vix', { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const price = data?.chart?.result?.[0]?.meta?.regularMarketPrice; + if (price === undefined || price === null) throw new Error('VIX 데이터 없음'); + return { value: Math.round(price * 100) / 100 }; +} + +// ── TODO API ───────────────────────────────────────────────────────────────── + +export function getTodos() { + return apiGet('/api/todos'); +} + +export function addTodo(data) { + return apiPost('/api/todos', data); +} + +export function updateTodo(id, data) { + return apiPut(`/api/todos/${id}`, data); +} + +export function deleteTodo(id) { + return apiDelete(`/api/todos/${id}`); +} + +export function clearTodos() { + return apiDelete('/api/todos/done'); +} diff --git a/src/components/FearGreedGauge.jsx b/src/components/FearGreedGauge.jsx new file mode 100644 index 0000000..80bac98 --- /dev/null +++ b/src/components/FearGreedGauge.jsx @@ -0,0 +1,106 @@ +import React from 'react'; + +export 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'; +}; + +export const getFgLabel = (score) => { + if (score <= 25) return '극단적 공포'; + if (score <= 45) return '공포'; + if (score <= 55) return '중립'; + if (score <= 75) return '탐욕'; + return '극단적 탐욕'; +}; + +const FG_LEVELS = [ + { + range: '0 – 25', + label: '극단적 공포', + color: '#ef4444', + desc: '투자자들이 극도로 불안해하는 상태. 역사적으로 매수 기회가 되기도 하나, 하락세가 이어질 수 있습니다.', + }, + { + range: '26 – 45', + label: '공포', + color: '#f97316', + desc: '시장 심리가 위축된 상태. 불확실성이 높고, 매도 압력이 강합니다.', + }, + { + range: '46 – 55', + label: '중립', + color: '#eab308', + desc: '공포와 탐욕이 균형을 이루는 상태. 뚜렷한 방향성 없이 관망세가 지속됩니다.', + }, + { + range: '56 – 75', + label: '탐욕', + color: '#84cc16', + desc: '투자자들이 낙관적이고 시장에 적극 참여하는 상태. 과열 신호를 주의해야 합니다.', + }, + { + range: '76 – 100', + label: '극단적 탐욕', + color: '#22c55e', + desc: '시장이 과열된 상태. 조정 가능성이 높아지므로 리스크 관리가 필요합니다.', + }, +]; + +/** + * Fear & Greed 게이지 컴포넌트 + * @param {{ score: number, date?: string, showLevels?: boolean }} props + */ +const FearGreedGauge = ({ score, date, showLevels = false }) => { + const color = getFgColor(score); + const label = getFgLabel(score); + + return ( +
+
+
+ {score} + {label} + {date && {date}} +
+
+
+
+
+
+ 극단적 공포 + 공포 + 중립 + 탐욕 + 극단적 탐욕 +
+
+
+ + {showLevels && ( +
+ {FG_LEVELS.map((lv) => ( +
+
+ + {lv.label} + {lv.range} +
+

{lv.desc}

+
+ ))} +
+ )} +
+ ); +}; + +export default FearGreedGauge; diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 2d3905f..7d3c40e 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -59,3 +59,15 @@ export const IconLab = () => ); + +export const IconTodo = () => + svg( + <> + + + + + + + + ); diff --git a/src/components/Navbar.css b/src/components/Navbar.css index 11db13a..3999ed2 100644 --- a/src/components/Navbar.css +++ b/src/components/Navbar.css @@ -62,12 +62,14 @@ .sidebar__brand-sub { margin: 0; - font-size: 10px; + font-size: 9px; font-weight: 500; text-transform: uppercase; - letter-spacing: 0.18em; + letter-spacing: 0.12em; color: var(--neon-cyan); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* ── 구분선 ──────────────────────────────────────────────────────────── */ diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index dfd362f..7926365 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -46,7 +46,7 @@ const Navbar = () => { Logo

Jaeoh

-

Dashboard

+

MANAGEMENT ROOM

diff --git a/src/components/PageHeader.css b/src/components/PageHeader.css new file mode 100644 index 0000000..1d6f6a0 --- /dev/null +++ b/src/components/PageHeader.css @@ -0,0 +1,67 @@ +/* ── PageHeader ──────────────────────────────────────────────────────── */ + +.page-header { + padding: 0 0 20px; + margin-bottom: 4px; +} + +.page-header__inner { + display: flex; + flex-direction: column; + gap: 4px; +} + +.page-header__subtitle { + margin: 0; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.28em; + color: var(--page-accent, var(--neon-cyan)); + font-family: var(--font-display, 'Space Grotesk', sans-serif); + display: flex; + align-items: center; + gap: 10px; +} + +.page-header__subtitle::before { + content: ''; + display: block; + width: 20px; + height: 1.5px; + background: var(--page-accent, var(--neon-cyan)); + border-radius: 2px; + box-shadow: 0 0 6px var(--page-accent, var(--neon-cyan)); + flex-shrink: 0; +} + +.page-header__title { + margin: 0; + font-size: clamp(22px, 3vw, 32px); + font-weight: 800; + font-family: var(--font-display, 'Space Grotesk', sans-serif); + color: var(--text-bright, #fff); + letter-spacing: -0.03em; + line-height: 1.1; +} + +.page-header__line { + height: 1px; + background: linear-gradient( + 90deg, + var(--page-accent, var(--neon-cyan)) 0%, + transparent 60% + ); + margin-top: 14px; + opacity: 0.3; +} + +@media (max-width: 768px) { + .page-header { + padding: 0 0 16px; + } + + .page-header__title { + font-size: clamp(18px, 5vw, 24px); + } +} diff --git a/src/components/PageHeader.jsx b/src/components/PageHeader.jsx new file mode 100644 index 0000000..88eb3cd --- /dev/null +++ b/src/components/PageHeader.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { navLinks } from '../routes.jsx'; +import './PageHeader.css'; + +const PageHeader = () => { + const { pathname } = useLocation(); + + // Home 페이지에서는 Hero 섹션이 있으므로 숨김 + if (pathname === '/') return null; + + // stock/trade 같은 하위 경로도 stock로 매칭 + const current = navLinks.find((link) => { + if (link.path === '/') return false; + return pathname === link.path || pathname.startsWith(link.path + '/'); + }); + + if (!current) return null; + + return ( +
+
+

{current.subtitle}

+

{current.label}

+
+
+
+ ); +}; + +export default PageHeader; diff --git a/src/pages/home/Home.css b/src/pages/home/Home.css index 6903891..7dbfbfb 100644 --- a/src/pages/home/Home.css +++ b/src/pages/home/Home.css @@ -367,6 +367,94 @@ padding-top: 4px; } +/* ── Dev Log ─────────────────────────────────────────────────────────── */ + +.home-dev-log { + display: grid; + gap: 8px; +} + +.home-dev-log__empty { + margin: 0; + color: var(--text-muted); + font-size: 13px; + padding: 16px 0; +} + +.home-dev-log__item { + border: 1px solid var(--line); + padding: 14px 18px; + border-radius: var(--radius-md); + background: var(--surface-card); + display: grid; + grid-template-columns: auto 1fr auto; + align-items: start; + gap: 14px; + box-shadow: var(--shadow-card); + transition: border-color 0.2s ease, background 0.2s ease; +} + +.home-dev-log__item:hover { + border-color: rgba(52, 211, 153, 0.25); + background: var(--surface-raised); +} + +.home-dev-log__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #34d399; + box-shadow: 0 0 6px rgba(52, 211, 153, 0.8); + margin-top: 7px; + flex-shrink: 0; +} + +.home-dev-log__content { + display: grid; + gap: 4px; +} + +.home-dev-log__title { + margin: 0; + font-weight: 600; + font-size: 15px; + color: var(--text-bright); + letter-spacing: -0.01em; +} + +.home-dev-log__desc { + margin: 0; + color: var(--text-dim); + font-size: 12px; + line-height: 1.6; +} + +.home-dev-log__date { + font-size: 11px; + color: rgba(52, 211, 153, 0.7); + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; + padding-top: 4px; +} + +.home-dev-log__link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 13px; + color: #34d399; + text-decoration: none; + padding: 8px 0; + transition: opacity 0.2s ease; + font-weight: 500; +} + +.home-dev-log__link:hover { + opacity: 0.75; +} + /* ── Profile ─────────────────────────────────────────────────────────── */ .home-profile { diff --git a/src/pages/home/Home.jsx b/src/pages/home/Home.jsx index 39e7491..c4d45d4 100644 --- a/src/pages/home/Home.jsx +++ b/src/pages/home/Home.jsx @@ -1,13 +1,30 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { navLinks } from '../../routes.jsx'; import { getBlogPosts } from '../../data/blog'; +import { getTodos } from '../../api'; import myPhoto from '../../assets/myPhoto.jpg'; import './Home.css'; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + const Home = () => { const posts = getBlogPosts().slice(0, 3); const highlights = navLinks.filter((link) => link.id !== 'home'); + const [recentDev, setRecentDev] = useState([]); + + useEffect(() => { + getTodos() + .then((todos) => { + if (!Array.isArray(todos)) return; + const now = Date.now(); + const filtered = todos + .filter((t) => t.status === 'done' && t.updated_at && (now - new Date(t.updated_at).getTime()) <= SEVEN_DAYS_MS) + .slice(0, 5); + setRecentDev(filtered); + }) + .catch(() => { /* 조용히 실패 */ }); + }, []); return (
@@ -97,6 +114,36 @@ const Home = () => {
+
+
+

최근 개발

+

최근 7일 내 완료된 태스크를 보여줍니다.

+
+
+ {recentDev.length === 0 ? ( +

완료된 태스크가 없습니다.

+ ) : ( + recentDev.map((todo) => ( +
+ +
+

{todo.title}

+ {todo.description && ( +

{todo.description}

+ )} +
+ + {new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })} + +
+ )) + )} + + Todo 보드 열기 → + +
+
+

Profile

diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index 51024a0..309574a 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -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; + } } \ No newline at end of file diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx index d410c36..0c3941e 100644 --- a/src/pages/stock/Stock.jsx +++ b/src/pages/stock/Stock.jsx @@ -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 = () => {
+ {/* 시장 심리 지표 행 */}
-

필터

-

뉴스 필터

-

- 표시할 뉴스 개수를 조정합니다. -

+

심리 지표

+

Fear & Greed

+

시장 탐욕·공포 지수 (0–100)

-
- -

- 최신 뉴스가 먼저 표시됩니다. -

-
+ {fgData ? ( + + ) : ( +

데이터 없음

+ )}
-

요약

-

뉴스 요약

-

- 최신 발행 시각과 기사 수를 확인합니다. +

변동성 지수

+

VIX

+

CBOE 공포 지수

+
+
+ {vixData ? ( +
+
+ {vixData.value ?? vixData.vix ?? '--'} +
+

+ {getVixLevel(vixData.value ?? vixData.vix ?? 0).label}

+
+ {'<12'} 극히낮음 + 12-20 정상 + 20-30 보통 + 30-40 높음 + {'40+'} 극단 +
-
-
-
- 최신 발행 - {formatDate(latestPublished)} -
-
- 국내 - {newsDomestic.length} -
-
- 해외 - {newsOverseas.length} -
-
+ ) : ( +

데이터 없음

+ )}
@@ -347,46 +353,46 @@ const Stock = () => {

뉴스가 없습니다.

) : ( <> -
- + +
+ {activeNews.length === 0 ? (

해당 카테고리 뉴스가 없습니다.

) : ( -
+
{activeNews.map((item) => (
-
-

- {item.title} -

-
-
- +
+ {formatDate(item.published_at)} {item.sentiment ? ( @@ -394,14 +400,25 @@ const Stock = () => { {item.sentiment} ) : null} +
+

+ {item.title} +

+ {item.summary && ( +

+ {item.summary} +

+ )} + {item.link && ( - 원문 보기 + 원문 보기 → -
+ )}
))}
diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index c798655..c8d5016 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -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 &&

{portfolioError}

} - {/* ── Fear & Greed Index ─────────────────────────── */} -
-
-
-

시장 심리 지표

-

Fear & Greed Index

-

- CNN Fear & Greed 지수로 현재 시장 심리를 파악합니다. -

-
-
- -
-
- {fgError &&

{fgError}

} - {fgData ? ( -
-
-
-
-
-
- 극단적 공포 - 공포 - 중립 - 탐욕 - 극단적 탐욕 -
-
-
- - {Math.round(fgData.score)} - - - {getFgLabel(fgData.score)} - - {fgData.timestamp && ( - - {new Date(fgData.timestamp).toLocaleDateString('ko-KR')} - - )} -
-
- ) : !fgError ? ( -

지수 데이터를 불러오는 중...

- ) : null} -
- {/* ── 자산 배분 + 수익률 차트 ────────────────────── */} {portfolioHoldings.length > 0 && (
diff --git a/src/pages/todo/Todo.css b/src/pages/todo/Todo.css new file mode 100644 index 0000000..caa5980 --- /dev/null +++ b/src/pages/todo/Todo.css @@ -0,0 +1,271 @@ +/* ═══════════════════════════════════════════════════════════════════════ + Todo Page — Cyberpunk Kanban Board + ═══════════════════════════════════════════════════════════════════════ */ + +.todo-page { + display: grid; + gap: 20px; +} + +/* ── Toolbar ─────────────────────────────────────────────────────────── */ + +.todo-toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* ── Add Form ─────────────────────────────────────────────────────────── */ + +.todo-form { + background: var(--surface-card); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 20px; + display: grid; + gap: 14px; + animation: fadeIn 0.2s ease both; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.todo-form__field { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.todo-form__field input, +.todo-form__field textarea { + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + color: var(--text-bright); + outline: none; + font-family: inherit; + font-size: 14px; + resize: vertical; + transition: border-color 0.2s ease; +} + +.todo-form__field input:focus, +.todo-form__field textarea:focus { + border-color: rgba(244, 114, 182, 0.5); + box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.1); +} + +.todo-form__actions { + display: flex; + justify-content: flex-end; +} + +/* ── Error / Loading ─────────────────────────────────────────────────── */ + +.todo-error { + margin: 0; + color: #f9b6b1; + border: 1px solid rgba(249, 182, 177, 0.4); + border-radius: 12px; + padding: 12px; + background: rgba(249, 182, 177, 0.08); + font-size: 13px; +} + +.todo-loading { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + +/* ── Board ───────────────────────────────────────────────────────────── */ + +.todo-board { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + align-items: start; +} + +/* ── Column ──────────────────────────────────────────────────────────── */ + +.todo-col { + background: rgba(10, 18, 45, 0.6); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + gap: 0; + min-height: 200px; + transition: border-color 0.2s ease, background 0.2s ease; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.todo-col.is-drag-over { + border-color: rgba(244, 114, 182, 0.4); + background: rgba(244, 114, 182, 0.04); +} + +.todo-col__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 12px; + border-bottom: 1px solid var(--line); +} + +.todo-col__title { + font-size: 13px; + font-weight: 700; + color: var(--text-bright); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.todo-col__count { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(244, 114, 182, 0.12); + border: 1px solid rgba(244, 114, 182, 0.25); + color: #f472b6; + font-weight: 600; +} + +.todo-col__body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + flex: 1; +} + +.todo-col__empty { + margin: 0; + color: var(--text-muted); + font-size: 12px; + text-align: center; + padding: 20px 0; + opacity: 0.5; +} + +/* ── Card ────────────────────────────────────────────────────────────── */ + +.todo-card { + background: var(--surface-card); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 14px; + display: grid; + gap: 8px; + cursor: grab; + transition: + transform 0.2s ease, + border-color 0.2s ease, + opacity 0.2s ease, + box-shadow 0.2s ease; + box-shadow: var(--shadow-card); +} + +.todo-card:hover { + border-color: rgba(244, 114, 182, 0.25); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.todo-card.is-dragging { + opacity: 0.4; + cursor: grabbing; +} + +.todo-card__title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-bright); + line-height: 1.4; +} + +.todo-card__desc { + margin: 0; + font-size: 12px; + color: var(--text-dim); + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.todo-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 2px; +} + +.todo-card__date { + font-size: 11px; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +.todo-card__actions { + display: flex; + gap: 4px; +} + +.todo-card__btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 8px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.04); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + padding: 0; + line-height: 1; +} + +.todo-card__btn:hover { + background: rgba(244, 114, 182, 0.15); + border-color: rgba(244, 114, 182, 0.4); + color: #f472b6; +} + +.todo-card__btn--danger:hover { + background: rgba(249, 182, 177, 0.15); + border-color: rgba(249, 182, 177, 0.4); + color: #f9b6b1; +} + +/* ── Responsive ──────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .todo-board { + grid-template-columns: 1fr; + } + + .todo-col { + min-height: 120px; + } +} + +@media (max-width: 480px) { + .todo-toolbar { + flex-direction: column; + } + + .todo-toolbar .button { + width: 100%; + justify-content: center; + } +} diff --git a/src/pages/todo/Todo.jsx b/src/pages/todo/Todo.jsx new file mode 100644 index 0000000..b7063f8 --- /dev/null +++ b/src/pages/todo/Todo.jsx @@ -0,0 +1,240 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api'; +import './Todo.css'; + +const COLUMNS = [ + { id: 'todo', label: '할 일' }, + { id: 'in_progress', label: '진행 중' }, + { id: 'done', label: '완료' }, +]; + +const emptyForm = { title: '', description: '' }; + +const Todo = () => { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [form, setForm] = useState(emptyForm); + const [formOpen, setFormOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [dragging, setDragging] = useState(null); + const [dragOver, setDragOver] = useState(null); + const dragItem = useRef(null); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await getTodos(); + setTodos(Array.isArray(data) ? data : []); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleAdd = async (e) => { + e.preventDefault(); + if (!form.title.trim()) return; + setSaving(true); + try { + const created = await addTodo({ ...form, status: 'todo' }); + setTodos((prev) => [created, ...prev]); + setForm(emptyForm); + setFormOpen(false); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setSaving(false); + } + }; + + const handleMove = async (id, newStatus) => { + setTodos((prev) => + prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t)) + ); + try { + await updateTodo(id, { status: newStatus, updated_at: new Date().toISOString() }); + } catch { + load(); + } + }; + + const handleDelete = async (id) => { + setTodos((prev) => prev.filter((t) => t.id !== id)); + try { + await deleteTodo(id); + } catch { + load(); + } + }; + + const handleClear = async () => { + try { + await clearTodos(); + setTodos((prev) => prev.filter((t) => t.status !== 'done')); + } catch (err) { + setError(err?.message ?? String(err)); + } + }; + + /* ── Drag & Drop ─────────────────────────────────────────────── */ + const onDragStart = (e, todo) => { + dragItem.current = todo; + setDragging(todo.id); + e.dataTransfer.effectAllowed = 'move'; + }; + + const onDragEnd = () => { + setDragging(null); + setDragOver(null); + dragItem.current = null; + }; + + const onDragOver = (e, colId) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOver(colId); + }; + + const onDrop = (e, colId) => { + e.preventDefault(); + if (dragItem.current && dragItem.current.status !== colId) { + handleMove(dragItem.current.id, colId); + } + setDragOver(null); + }; + + const byStatus = (status) => todos.filter((t) => t.status === status); + + return ( +
+ {/* 추가 버튼 & 완료 비우기 */} +
+ + +
+ + {/* 추가 폼 */} + {formOpen && ( +
+ +