diff --git a/src/api.js b/src/api.js index ab25655..8cca773 100644 --- a/src/api.js +++ b/src/api.js @@ -8,12 +8,12 @@ const toApiUrl = (path) => { const base = new URL(API_BASE, window.location.origin); // Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory if (!base.pathname.endsWith('/')) { - base.pathname += '/'; + base.pathname += '/'; } - + // Remove leading slash from path to avoid double slashes when joining const cleanPath = path.startsWith('/') ? path.slice(1) : path; - + return new URL(cleanPath, base).toString(); } catch (error) { console.error("Invalid VITE_API_BASE configuration:", error); @@ -57,6 +57,22 @@ export async function apiPost(path, body) { return res.json(); } +export async function apiPut(path, body) { + const res = await fetch(toApiUrl(path), { + method: "PUT", + headers: { + "Accept": "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); + } + return res.json(); +} + export function getLatest() { return apiGet("/api/lotto/latest"); } @@ -120,3 +136,21 @@ export function getTradeBalance() { export function createTradeOrder(payload) { return apiPost("/api/trade/order", payload); } + +// ── 포트폴리오 (수동 입력) API ────────────────────────────────────────────── + +export function getPortfolio() { + return apiGet("/api/portfolio"); +} + +export function addPortfolio(item) { + return apiPost("/api/portfolio", item); +} + +export function updatePortfolio(id, fields) { + return apiPut(`/api/portfolio/${id}`, fields); +} + +export function deletePortfolio(id) { + return apiDelete(`/api/portfolio/${id}`); +} diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index c439647..ce33349 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -623,4 +623,170 @@ .stock-holdings__metric strong { font-size: 14px; } +} + +/* ── Portfolio ────────────────────────────────────────────────────── */ + +.pf-section { + position: relative; +} + +.pf-broker-section { + transition: border-color 0.3s ease, background 0.3s ease; +} + +.pf-add-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + padding: 16px; + border: 1px dashed var(--line); + border-radius: 16px; + background: rgba(0, 0, 0, 0.15); + animation: pf-slide-in 0.25s ease; +} + +@keyframes pf-slide-in { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.pf-add-form label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.pf-add-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-add-form input:focus { + border-color: var(--accent); +} + +.pf-total-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.pf-total-summary__card { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + display: grid; + gap: 6px; + background: rgba(0, 0, 0, 0.2); + font-size: 12px; + color: var(--muted); +} + +.pf-total-summary__card strong { + font-size: 16px; + color: var(--text); +} + +.pf-item { + grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 0.55fr)) auto; +} + +.pf-item-actions { + display: flex; + gap: 4px; + align-items: center; + justify-content: flex-end; +} + +.pf-item-actions .button { + padding: 4px 8px; + min-height: 0; + font-size: 14px; + line-height: 1; +} + +.pf-btn-danger { + color: #f3a7a7 !important; + border-color: rgba(243, 167, 167, 0.5) !important; +} + +.pf-null-price { + color: var(--muted) !important; + font-size: 12px !important; + opacity: 0.7; + font-style: italic; +} + +.pf-edit-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: end; +} + +.pf-edit-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.pf-edit-fields label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.pf-edit-fields input { + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + color: var(--text); + outline: none; +} + +.pf-edit-actions { + display: flex; + gap: 6px; + align-items: center; +} + +@media (max-width: 768px) { + .pf-item { + grid-template-columns: minmax(0, 1fr); + } + + .pf-add-form { + grid-template-columns: 1fr; + } + + .pf-edit-row { + grid-template-columns: 1fr; + } + + .pf-total-summary { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 520px) { + .pf-total-summary { + grid-template-columns: 1fr; + } } \ No newline at end of file diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index 1554bbb..ea8d5cf 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -1,8 +1,18 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { createTradeOrder, getTradeBalance } from '../../api'; +import { + createTradeOrder, + getTradeBalance, + getPortfolio, + addPortfolio, + updatePortfolio, + deletePortfolio, +} from '../../api'; +import Loading from '../../components/Loading'; import './Stock.css'; +/* ── helpers ─────────────────────────────────────────────────────── */ + const formatNumber = (value) => { if (value === null || value === undefined || value === '') return '-'; const numeric = Number(value); @@ -15,7 +25,7 @@ const formatPercent = (value) => { if (typeof value === 'string' && value.includes('%')) return value; const numeric = Number(value); if (Number.isNaN(numeric)) return value; - return `${numeric.toFixed(2)}%`; + return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`; }; const pickFirst = (...values) => @@ -63,10 +73,52 @@ const toNumeric = (value) => { return Number.isNaN(numeric) ? null : numeric; }; +const profitColorClass = (numericValue) => { + if (numericValue > 0) return 'is-up'; + if (numericValue < 0) return 'is-down'; + if (numericValue === 0) return 'is-flat'; + return ''; +}; + +/* ── empty portfolio form ────────────────────────────────────────── */ + +const emptyPortfolioForm = { + broker: '', + ticker: '', + name: '', + quantity: '', + avg_price: '', +}; + +/* ── component ───────────────────────────────────────────────────── */ + const StockTrade = () => { + /* Balance state */ const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); const [balanceError, setBalanceError] = useState(''); + + /* Portfolio state */ + const [portfolio, setPortfolio] = useState(null); + const [portfolioLoading, setPortfolioLoading] = useState(false); + const [portfolioError, setPortfolioError] = useState(''); + + /* Portfolio add form */ + const [addForm, setAddForm] = useState({ ...emptyPortfolioForm }); + const [addFormOpen, setAddFormOpen] = useState(false); + const [addLoading, setAddLoading] = useState(false); + const [addError, setAddError] = useState(''); + + /* Portfolio edit */ + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState({}); + const [editLoading, setEditLoading] = useState(false); + const editOrigRef = useRef({}); + + /* Portfolio delete */ + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + /* Manual order state */ const [manualForm, setManualForm] = useState({ code: '', qty: 1, @@ -78,7 +130,9 @@ const StockTrade = () => { const [manualResult, setManualResult] = useState(null); const [kisModal, setKisModal] = useState(''); - const loadBalance = async () => { + /* ── loaders ─────────────────────────────────────────────────── */ + + const loadBalance = useCallback(async () => { setBalanceLoading(true); setBalanceError(''); try { @@ -89,8 +143,123 @@ const StockTrade = () => { } finally { setBalanceLoading(false); } + }, []); + + const loadPortfolio = useCallback(async () => { + setPortfolioLoading(true); + setPortfolioError(''); + try { + const data = await getPortfolio(); + setPortfolio(data); + } catch (err) { + setPortfolioError(err?.message ?? String(err)); + } finally { + setPortfolioLoading(false); + } + }, []); + + useEffect(() => { + loadBalance(); + loadPortfolio(); + }, [loadBalance, loadPortfolio]); + + /* Auto-refresh portfolio every 3 min */ + useEffect(() => { + const timer = window.setInterval(loadPortfolio, 180000); + return () => window.clearInterval(timer); + }, [loadPortfolio]); + + /* ── portfolio actions ───────────────────────────────────────── */ + + const handleAddSubmit = async (e) => { + e.preventDefault(); + setAddLoading(true); + setAddError(''); + try { + await addPortfolio({ + broker: addForm.broker.trim(), + ticker: addForm.ticker.trim(), + name: addForm.name.trim(), + quantity: Number(addForm.quantity), + avg_price: Number(addForm.avg_price), + }); + setAddForm({ ...emptyPortfolioForm }); + setAddFormOpen(false); + await loadPortfolio(); + } catch (err) { + setAddError(err?.message ?? String(err)); + } finally { + setAddLoading(false); + } }; + const handleEditStart = (item) => { + setEditingId(item.id); + setEditForm({ + quantity: item.quantity, + avg_price: item.avg_price, + broker: item.broker, + name: item.name, + }); + /* 원본 값을 기억해서 diff 비교용 */ + editOrigRef.current = { + quantity: item.quantity, + avg_price: item.avg_price, + broker: item.broker, + name: item.name, + }; + }; + + const handleEditSave = async (id) => { + setEditLoading(true); + try { + /* 변경된 필드만 추출하여 부분 수정 */ + const orig = editOrigRef.current ?? {}; + const diff = {}; + for (const key of Object.keys(editForm)) { + if (editForm[key] !== orig[key]) { + diff[key] = editForm[key]; + } + } + if (Object.keys(diff).length === 0) { + setEditingId(null); + return; + } + await updatePortfolio(id, diff); + setEditingId(null); + await loadPortfolio(); + } catch (err) { + const msg = err?.message ?? String(err); + if (msg.includes('404') || msg.includes('not found')) { + alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); + await loadPortfolio(); + } else { + alert('수정 실패: ' + msg); + } + } finally { + setEditLoading(false); + } + }; + + const handleDelete = async (id) => { + try { + await deletePortfolio(id); + setDeleteConfirmId(null); + await loadPortfolio(); + } catch (err) { + const msg = err?.message ?? String(err); + if (msg.includes('404') || msg.includes('not found')) { + alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); + setDeleteConfirmId(null); + await loadPortfolio(); + } else { + alert('삭제 실패: ' + msg); + } + } + }; + + /* ── manual order ────────────────────────────────────────────── */ + const submitManualOrder = async (event) => { event.preventDefault(); setManualLoading(true); @@ -120,9 +289,7 @@ const StockTrade = () => { } }; - useEffect(() => { - loadBalance(); - }, []); + /* ── derived data ────────────────────────────────────────────── */ const holdings = useMemo(() => { if (!balance) return []; @@ -137,6 +304,56 @@ const StockTrade = () => { const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash; + /* Portfolio grouped by broker */ + const portfolioHoldings = portfolio?.holdings ?? []; + const portfolioSummary = portfolio?.summary ?? {}; + const brokerGroups = useMemo(() => { + const map = {}; + for (const item of portfolioHoldings) { + const broker = item.broker || '기타'; + if (!map[broker]) map[broker] = []; + map[broker].push(item); + } + return Object.entries(map).sort(([a], [b]) => a.localeCompare(b)); + }, [portfolioHoldings]); + + /* broker-level summary (eval_amount가 null인 종목은 안전하게 건너뜀) */ + const getBrokerSummary = (items) => { + let totalBuy = 0; + let totalEvalAmt = 0; + let hasNullPrice = false; + for (const item of items) { + totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0); + if (item.eval_amount != null) { + totalEvalAmt += item.eval_amount; + } else { + hasNullPrice = true; + } + } + const totalProfit = totalEvalAmt - totalBuy; + const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0; + return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; + }; + + /* ── broker color accents (deterministic from name) ──────────── */ + const brokerColors = useMemo(() => { + const palette = [ + { border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' }, + { border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' }, + { border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' }, + { border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' }, + { border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' }, + { border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' }, + ]; + const map = {}; + brokerGroups.forEach(([broker], i) => { + map[broker] = palette[i % palette.length]; + }); + return map; + }, [brokerGroups]); + + /* ── render ───────────────────────────────────────────────────── */ + return (
@@ -167,6 +384,33 @@ const StockTrade = () => { 보유 종목 {holdings.length}
+ {portfolioHoldings.length > 0 && ( + <> +
+ 포트폴리오 종목 + {portfolioHoldings.length} +
+
+ 포트폴리오 평가 + {formatNumber(portfolioSummary.total_eval)} +
+
+ 포트폴리오 손익 + + {formatNumber(portfolioSummary.total_profit)} + {portfolioSummary.total_profit_rate != null && ( + + ({formatPercent(portfolioSummary.total_profit_rate)}) + + )} + +
+ + )} {summary.note ? (

{summary.note}

@@ -174,6 +418,341 @@ const StockTrade = () => { + {/* ── Portfolio sections (broker별) ─────────────────────── */} + {portfolioError ? ( +

{portfolioError}

+ ) : null} + + {/* 총 포트폴리오 요약 + 종목 추가 */} +
+
+
+

포트폴리오

+

수동 입력 종목 관리

+

+ 증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시) +

+
+
+ {portfolioLoading ? ( + + ) : null} + + +
+
+ + {/* Add form */} + {addFormOpen && ( +
+ + + + + + + {addError &&

{addError}

} +
+ )} + + {/* Portfolio total summary */} + {portfolioHoldings.length > 0 && ( +
+ {[ + { label: '총 매입', value: portfolioSummary.total_buy }, + { label: '총 평가', value: portfolioSummary.total_eval }, + { label: '총 손익', value: portfolioSummary.total_profit, isProfit: true }, + { label: '수익률', value: portfolioSummary.total_profit_rate, isRate: true }, + ].map((s) => ( +
+ {s.label} + + {s.isRate ? formatPercent(s.value) : formatNumber(s.value)} + +
+ ))} +
+ )} +
+ + {/* Each broker gets a stacked card */} + {brokerGroups.map(([broker, items]) => { + const bSummary = getBrokerSummary(items); + const color = brokerColors[broker]; + return ( +
+
+
+

+ {broker} +

+

{broker} 보유 현황

+

+ {items.length}종목 · 평가{' '} + {formatNumber(bSummary.totalEval)} · 손익{' '} + + {formatNumber(bSummary.totalProfit)} ( + {formatPercent(bSummary.totalProfitRate)}) + +

+
+
+
+ {items.map((item) => { + const profitAmt = item.profit_amount; + const profitRate = item.profit_rate; + const profitAmtN = toNumeric(profitAmt); + const profitRateN = toNumeric(profitRate); + const isEditing = editingId === item.id; + const isDeleting = deleteConfirmId === item.id; + + return ( +
+ {isEditing ? ( + /* Edit mode */ +
+
+ + +
+
+ + +
+
+ ) : ( + /* Normal display */ + <> +
+

+ {item.name ?? item.ticker ?? 'N/A'} +

+ + {item.ticker ?? ''} + +
+
+ 수량 + {formatNumber(item.quantity)} +
+
+ 매입가 + {formatNumber(item.avg_price)} +
+
+ 현재가 + + {item.current_price != null + ? formatNumber(item.current_price) + : '조회 실패'} + +
+
+ 수익률 + + {profitRate != null + ? formatPercent(profitRate) + : '-'} + +
+
+ 평가손익 + + {profitAmt != null + ? formatNumber(profitAmt) + : '-'} + +
+ {/* action buttons */} +
+ + {isDeleting ? ( + <> + + + + ) : ( + + )} +
+ + )} +
+ ); + })} +
+
+ ); + })} + + {/* ── Existing balance section ─────────────────────────── */} {balanceError ?

{balanceError}

: null}
@@ -224,24 +803,10 @@ const StockTrade = () => { {holdings.map((item, idx) => { const profitLoss = getProfitLoss(item); const profitLossNumeric = toNumeric(profitLoss); - const profitClass = - profitLossNumeric > 0 - ? 'is-up' - : profitLossNumeric < 0 - ? 'is-down' - : profitLossNumeric === 0 - ? 'is-flat' - : ''; + const profitClass = profitColorClass(profitLossNumeric); const profitRate = getProfitRate(item); const profitRateNumeric = toNumeric(profitRate); - const profitRateClass = - profitRateNumeric > 0 - ? 'is-up' - : profitRateNumeric < 0 - ? 'is-down' - : profitRateNumeric === 0 - ? 'is-flat' - : ''; + const profitRateClass = profitColorClass(profitRateNumeric); return (
{
+ {/* ── Manual order section ─────────────────────────────── */}
@@ -396,6 +962,8 @@ const StockTrade = () => { ) : null}
+ + {/* KIS modal */} {kisModal ? (