From e8091a03914cdaaf5cacc69b90c97550125419c4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:58:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20WatchlistTab=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20+=20wl-*=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20+=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/stock/Stock.css | 118 ++++++++++++++ src/pages/stock/components/WatchlistTab.jsx | 145 ++++++++++++++++++ .../stock/components/WatchlistTab.test.jsx | 31 ++++ 3 files changed, 294 insertions(+) create mode 100644 src/pages/stock/components/WatchlistTab.jsx create mode 100644 src/pages/stock/components/WatchlistTab.test.jsx diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index 3f6deb3..35c4686 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -3232,3 +3232,121 @@ display: none; } } + +/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */ +.wl-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.wl-form__input { + flex: 1 1 140px; + min-width: 120px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + color: inherit; + font-size: 13px; +} +.wl-form__input:focus { + outline: none; + border-color: var(--accent-stock); +} + +.wl-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.wl-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 10px 14px; +} +.wl-row__meta { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.wl-row__name { font-size: 14px; } +.wl-row__ticker { font-size: 12px; color: var(--muted); } +.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; } +.wl-del { + flex: none; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 4px 6px; + border-radius: 6px; +} +.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); } + +.wl-period-toggle { display: flex; gap: 4px; } +.wl-period { + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + border-radius: 8px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} +.wl-period.is-active { + color: var(--accent-stock); + border-color: var(--accent-stock); + background: rgba(148, 163, 184, 0.08); +} + +.wl-alerts { + display: flex; + flex-direction: column; + gap: 10px; +} +.wl-alert { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.wl-alert__head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.wl-kind-badge { + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; +} +.wl-alert__name { font-size: 14px; } +.wl-alert__ticker { font-size: 12px; color: var(--muted); } +.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; } +.wl-alert__body { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} +.wl-cond { font-size: 13px; font-weight: 600; } +.wl-alert__price { font-size: 13px; color: var(--muted); } +.wl-alert__detail { font-size: 12px; color: var(--muted); } diff --git a/src/pages/stock/components/WatchlistTab.jsx b/src/pages/stock/components/WatchlistTab.jsx new file mode 100644 index 0000000..aff97ab --- /dev/null +++ b/src/pages/stock/components/WatchlistTab.jsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import Loading from '../../../components/Loading'; +import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils'; +import { formatNumber } from '../stockUtils'; + +const DAYS_OPTIONS = [ + { value: 1, label: '1D' }, + { value: 7, label: '7D' }, + { value: 30, label: '30D' }, +]; + +const AlertCard = ({ a }) => { + const meta = kindMeta(a.kind); + return ( +
+
+ {meta.label} + {a.name || a.ticker} + {a.ticker} + {relativeTime(a.fired_at)} +
+
+ {conditionLabel(a.condition)} + {a.price != null && {formatNumber(a.price)}원} +
+ {a.detail &&
{a.detail}
} +
+ ); +}; + +const WatchlistTab = ({ wl }) => { + const [form, setForm] = useState({ ticker: '', name: '', note: '' }); + + const submit = async (e) => { + e.preventDefault(); + if (!form.ticker.trim()) return; + await wl.add(form); + setForm({ ticker: '', name: '', note: '' }); + }; + + return ( + <> + {/* 관심종목 관리 */} +
+
+
+

관심종목

+

관심종목 관리

+

등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.

+
+
{wl.loading && }
+
+ +
+ setForm((f) => ({ ...f, ticker: e.target.value }))} + /> + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, note: e.target.value }))} + /> + +
+ + {wl.error &&

{wl.error}

} + + {wl.items.length === 0 ? ( +

아직 관심종목이 없습니다. 종목코드를 추가해 보세요.

+ ) : ( +
    + {wl.items.map((it) => ( +
  • +
    + {it.name || it.ticker} + {it.ticker} + {it.note && {it.note}} +
    + +
  • + ))} +
+ )} +
+ + {/* 최근 시그널 알림 */} +
+
+
+

시그널

+

최근 매매 알림

+

감시 종목에서 발생한 매수·매도 시그널 이력입니다.

+
+
+ {DAYS_OPTIONS.map((o) => ( + + ))} +
+
+ + {wl.alertError &&

{wl.alertError}

} + + {wl.alerts.length === 0 ? ( +

해당 기간에 발생한 알림이 없습니다.

+ ) : ( +
+ {wl.alerts.map((a) => ( + + ))} +
+ )} + +

※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.

+
+ + ); +}; + +export default WatchlistTab; diff --git a/src/pages/stock/components/WatchlistTab.test.jsx b/src/pages/stock/components/WatchlistTab.test.jsx new file mode 100644 index 0000000..ecc4574 --- /dev/null +++ b/src/pages/stock/components/WatchlistTab.test.jsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import WatchlistTab from './WatchlistTab.jsx'; + +const baseWl = { + items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(), + loading: false, error: '', alertError: '', adding: false, + add: vi.fn(), remove: vi.fn(), reload: vi.fn(), +}; + +describe('WatchlistTab', () => { + it('빈 상태: 헤딩과 빈 안내 노출', () => { + render(); + expect(screen.getByText('관심종목 관리')).toBeInTheDocument(); + expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument(); + expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument(); + }); + + it('종목·알림이 있으면 렌더', () => { + const wl = { + ...baseWl, + items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }], + alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }], + }; + render(); + // '삼성전자'는 관심종목 목록 행과 알림 카드 양쪽에 렌더되므로 getAllByText로 확인 + expect(screen.getAllByText('삼성전자')).toHaveLength(2); + expect(screen.getByText('매수')).toBeInTheDocument(); + expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument(); + }); +});