From 248835fa541ca45879c751a168fb3014c1368335 Mon Sep 17 00:00:00 2001
From: gahusb
Date: Thu, 19 Mar 2026 23:36:33 +0900
Subject: [PATCH] =?UTF-8?q?stock=20=EC=8B=A4=ED=98=84=EC=86=90=EC=9D=B5=20?=
=?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=A4=84=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20?=
=?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=84=B1=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api.js | 25 ++
src/pages/stock/Stock.css | 547 ++++++++++++++++++++++++++++++++-
src/pages/stock/StockTrade.jsx | 516 ++++++++++++++++++++++++++++++-
3 files changed, 1086 insertions(+), 2 deletions(-)
diff --git a/src/api.js b/src/api.js
index 80badc0..908fe50 100644
--- a/src/api.js
+++ b/src/api.js
@@ -221,6 +221,31 @@ export function clearTodos() {
return apiDelete('/api/todos/done');
}
+// ── 실현손익 내역 API ─────────────────────────────────────────────────────────
+// GET /api/portfolio/sell-history?broker=X&days=N → { records: [...] }
+// POST /api/portfolio/sell-history → 저장된 레코드 반환
+// DELETE /api/portfolio/sell-history/:id → { ok: true }
+
+export function getSellHistory({ broker, days } = {}) {
+ const qs = new URLSearchParams();
+ if (broker && broker !== 'ALL') qs.set('broker', broker);
+ if (days) qs.set('days', String(days));
+ const q = qs.toString();
+ return apiGet(`/api/portfolio/sell-history${q ? '?' + q : ''}`);
+}
+
+export function addSellHistory(record) {
+ return apiPost('/api/portfolio/sell-history', record);
+}
+
+export function updateSellHistory(id, record) {
+ return apiPut(`/api/portfolio/sell-history/${id}`, record);
+}
+
+export function deleteSellHistory(id) {
+ return apiDelete(`/api/portfolio/sell-history/${id}`);
+}
+
// ── 블로그 API ────────────────────────────────────────────────────────────────
// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] }
// POST /api/blog/posts → 새 글 생성
diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css
index 9bc1bde..8b0c062 100644
--- a/src/pages/stock/Stock.css
+++ b/src/pages/stock/Stock.css
@@ -2104,4 +2104,549 @@
.risk-grid {
grid-template-columns: 1fr;
}
-}
\ No newline at end of file
+}
+/* ══════════════════════════════════════════════════════════════════
+ 실현손익 내역
+ ══════════════════════════════════════════════════════════════════ */
+
+.sell-history__filters {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.sell-history__filter-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.sell-history__filter-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--muted);
+ min-width: 36px;
+}
+
+.sell-history__filter-btn {
+ padding: 4px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: transparent;
+ color: var(--muted);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.sell-history__filter-btn:hover {
+ border-color: rgba(255, 255, 255, 0.3);
+ color: var(--text);
+}
+
+.sell-history__filter-btn.is-active {
+ border-color: var(--accent-stock);
+ color: var(--accent-stock);
+ background: rgba(99, 179, 237, 0.08);
+}
+
+.sell-history__summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 10px;
+ margin-bottom: 20px;
+}
+
+.sell-history__summary-card {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.sell-history__summary-card strong {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.sell-history__table-wrap {
+ overflow-x: auto;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+}
+
+.sell-history__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.sell-history__table thead th {
+ padding: 10px 12px;
+ text-align: left;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+ border-bottom: 1px solid var(--line);
+ white-space: nowrap;
+}
+
+.sell-history__table thead th.is-num {
+ text-align: right;
+}
+
+.sell-history__table tbody td {
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ vertical-align: middle;
+ white-space: nowrap;
+ color: var(--text);
+}
+
+.sell-history__table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.sell-history__table tbody tr:hover td {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.sell-history__table td.is-num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.sell-history__name {
+ font-weight: 500;
+ display: block;
+}
+
+.sell-history__ticker {
+ font-size: 11px;
+ color: var(--muted);
+ font-family: monospace;
+}
+
+.sell-history__broker {
+ font-size: 12px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ color: var(--muted);
+}
+
+.sell-history__date {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+@media (max-width: 768px) {
+ .sell-history__summary {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* ══════════════════════════════════════════════════════════════════
+ 매도 내역 수동 추가/수정 폼
+ ══════════════════════════════════════════════════════════════════ */
+
+.sh-form {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 14px;
+ padding: 20px;
+ background: rgba(0, 0, 0, 0.25);
+ margin-bottom: 20px;
+ display: grid;
+ gap: 16px;
+}
+
+.sh-form__title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.sh-form__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 12px;
+}
+
+.sh-form__grid label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.sh-form__grid label input {
+ padding: 7px 10px;
+ border-radius: 8px;
+ border: 1px solid var(--line);
+ background: rgba(0, 0, 0, 0.3);
+ color: var(--text);
+ font-size: 13px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.sh-form__grid label input:focus {
+ outline: none;
+ border-color: var(--accent-stock);
+}
+
+.sh-form__datetime {
+ grid-column: span 2;
+}
+
+.sh-form__preview {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+ padding: 10px 14px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--line);
+ font-size: 13px;
+ color: var(--muted);
+}
+
+.sh-form__preview strong {
+ margin-left: 6px;
+}
+
+.sh-form__actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.sh-row-actions {
+ display: flex;
+ gap: 4px;
+ justify-content: flex-end;
+}
+
+@media (max-width: 640px) {
+ .sh-form__grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .sh-form__datetime {
+ grid-column: span 2;
+ }
+}
+
+/* ══════════════════════════════════════════════════════════════════
+ 실현손익 floating 토글 버튼 (우측 고정)
+ ══════════════════════════════════════════════════════════════════ */
+
+.sh-floating-toggle {
+ position: fixed;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 190;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 14px 10px;
+ border-radius: 12px 0 0 12px;
+ border: 1px solid rgba(251, 191, 36, 0.35);
+ border-right: none;
+ background: rgba(7, 11, 25, 0.92);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ color: #fbbf24;
+ cursor: pointer;
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(251, 191, 36, 0.08) inset;
+ transition: background 0.2s, box-shadow 0.2s, border-color 0.2s, padding 0.2s;
+ white-space: nowrap;
+}
+
+.sh-floating-toggle:hover {
+ background: rgba(251, 191, 36, 0.1);
+ border-color: rgba(251, 191, 36, 0.65);
+ box-shadow: -4px 0 32px rgba(251, 191, 36, 0.15);
+ padding-right: 14px;
+}
+
+.sh-floating-toggle__icon {
+ font-size: 20px;
+ line-height: 1;
+}
+
+.sh-floating-toggle__label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+}
+
+.sh-floating-toggle__badge {
+ background: #fbbf24;
+ color: #000;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 2px 5px;
+ line-height: 1.3;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
+}
+
+/* ══════════════════════════════════════════════════════════════════
+ 실현손익 드로어 (slide-in from right)
+ ══════════════════════════════════════════════════════════════════ */
+
+.sh-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 200;
+ animation: sh-backdrop-in 0.2s ease;
+}
+
+@keyframes sh-backdrop-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.sh-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ height: 100dvh;
+ width: min(520px, 100vw);
+ background: var(--bg, #070b19);
+ border-left: 1px solid var(--line);
+ z-index: 201;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 0;
+ transform: translateX(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ /* 스크롤바 */
+ scrollbar-width: thin;
+ scrollbar-color: var(--line) transparent;
+}
+
+.sh-drawer.is-open {
+ transform: translateX(0);
+}
+
+.sh-drawer__header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 20px 20px 16px;
+ background: var(--bg, #070b19);
+ border-bottom: 1px solid var(--line);
+}
+
+.sh-drawer__eyebrow {
+ margin: 0 0 4px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: #fbbf24;
+}
+
+.sh-drawer__title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.sh-drawer__header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.sh-drawer__close {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 1px solid var(--line);
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+ flex-shrink: 0;
+}
+
+.sh-drawer__close:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+}
+
+/* 드로어 내 섹션들 패딩 */
+.sh-drawer .sell-history__filters,
+.sh-drawer .sell-history__summary,
+.sh-drawer .sh-drawer__list,
+.sh-drawer .sh-form,
+.sh-drawer .sh-drawer__empty {
+ margin: 0;
+ padding: 16px 20px;
+}
+
+.sh-drawer .sell-history__filters {
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 14px;
+}
+
+.sh-drawer .sell-history__summary {
+ grid-template-columns: repeat(2, 1fr);
+ border-bottom: 1px solid var(--line);
+}
+
+.sh-drawer .sh-form {
+ border-radius: 0;
+ border: none;
+ border-bottom: 1px solid var(--line);
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.sh-drawer .sh-form__grid {
+ grid-template-columns: 1fr 1fr;
+}
+
+/* 드로어 카드형 목록 */
+.sh-drawer__list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding-top: 16px;
+ padding-bottom: 24px;
+}
+
+.sh-drawer__item {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 14px;
+ background: rgba(255, 255, 255, 0.02);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ transition: background 0.15s;
+}
+
+.sh-drawer__item:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.sh-drawer__item-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.sh-drawer__item-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--text);
+}
+
+.sh-drawer__item-name code {
+ font-size: 11px;
+ color: var(--muted);
+ background: rgba(255, 255, 255, 0.06);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-family: monospace;
+}
+
+.sh-drawer__item-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.sh-drawer__item-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.sh-drawer__item-metrics {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.sh-drawer__item-metrics > div {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 12px;
+}
+
+.sh-drawer__item-metrics > div span {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.sh-drawer__item-metrics > div strong {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+
+.sh-drawer__empty {
+ color: var(--muted);
+ font-size: 13px;
+ text-align: center;
+ padding-top: 40px !important;
+}
+
+@media (max-width: 480px) {
+ .sh-drawer {
+ width: 100vw;
+ }
+
+ .sh-drawer__item-metrics {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .sh-drawer .sh-form__grid {
+ grid-template-columns: 1fr;
+ }
+
+ .sh-drawer .sh-form__datetime {
+ grid-column: span 1;
+ }
+}
diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx
index 03825b5..12228f2 100644
--- a/src/pages/stock/StockTrade.jsx
+++ b/src/pages/stock/StockTrade.jsx
@@ -15,6 +15,10 @@ import {
getWTI,
getAssetHistory,
saveAssetSnapshot,
+ getSellHistory,
+ addSellHistory,
+ updateSellHistory,
+ deleteSellHistory,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
@@ -124,6 +128,25 @@ const emptyPortfolioForm = {
avg_price: '',
};
+/* ── empty sell-history form ─────────────────────────────────────── */
+
+const toLocalDatetimeValue = (isoStr) => {
+ if (!isoStr) return '';
+ const d = new Date(isoStr);
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+};
+
+const emptySellForm = () => ({
+ broker: '',
+ ticker: '',
+ name: '',
+ quantity: '',
+ avg_price: '',
+ sell_price: '',
+ sold_at: toLocalDatetimeValue(new Date().toISOString()),
+});
+
/* ── TAB IDs ─────────────────────────────────────────────────────── */
const TAB_PORTFOLIO = 'portfolio';
@@ -163,6 +186,22 @@ const StockTrade = () => {
const [sellConfirmId, setSellConfirmId] = useState(null);
const [sellLoading, setSellLoading] = useState(false);
+ /* 실현손익 내역 */
+ const [sellHistory, setSellHistory] = useState([]);
+ const [sellHistoryLoading, setSellHistoryLoading] = useState(false);
+ const [sellHistoryBroker, setSellHistoryBroker] = useState('ALL');
+ const [sellHistoryPeriod, setSellHistoryPeriod] = useState('3M');
+
+ /* 실현손익 드로어 */
+ const [sellDrawerOpen, setSellDrawerOpen] = useState(false);
+
+ /* 실현손익 수동 추가/수정 폼 */
+ const [sellFormOpen, setSellFormOpen] = useState(false);
+ const [sellEditId, setSellEditId] = useState(null); // null = 추가, number = 수정 중 id
+ const [sellForm, setSellForm] = useState(emptySellForm());
+ const [sellFormSaving, setSellFormSaving] = useState(false);
+ const [sellFormError, setSellFormError] = useState('');
+
/* Cash (예수금) form */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
const [cashSaving, setCashSaving] = useState(false);
@@ -231,6 +270,18 @@ const StockTrade = () => {
}
}, []);
+ const loadSellHistory = useCallback(async () => {
+ setSellHistoryLoading(true);
+ try {
+ const data = await getSellHistory();
+ setSellHistory(data?.records ?? (Array.isArray(data) ? data : []));
+ } catch {
+ /* 백엔드 미구현 시 빈 배열 유지 */
+ } finally {
+ setSellHistoryLoading(false);
+ }
+ }, []);
+
const loadBalance = useCallback(async () => {
setBalanceLoading(true);
setBalanceError('');
@@ -302,12 +353,13 @@ const StockTrade = () => {
useEffect(() => {
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
loadPortfolio();
+ loadSellHistory();
} else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio();
}
- }, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
+ }, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
useEffect(() => {
@@ -495,8 +547,12 @@ const StockTrade = () => {
const handleSell = async (item) => {
const sellPrice = item.current_price ?? item.avg_price;
+ const avgPrice = item.avg_price ?? 0;
const qty = item.quantity ?? 0;
const saleAmount = sellPrice * qty;
+ const buyAmount = avgPrice * qty;
+ const realizedProfit = saleAmount - buyAmount;
+ const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
const broker = item.broker ?? '';
setSellLoading(true);
@@ -506,6 +562,29 @@ const StockTrade = () => {
const newCash = (existing?.cash ?? 0) + saleAmount;
await upsertCash(broker, newCash);
await deletePortfolio(item.id);
+
+ // 실현손익 기록 저장 (백엔드)
+ const record = {
+ broker,
+ ticker: item.ticker ?? '',
+ name: item.name ?? item.ticker ?? 'N/A',
+ quantity: qty,
+ avg_price: avgPrice,
+ sell_price: sellPrice,
+ buy_amount: buyAmount,
+ sell_amount: saleAmount,
+ realized_profit: realizedProfit,
+ realized_rate: realizedRate,
+ sold_at: new Date().toISOString(),
+ };
+ try {
+ const saved = await addSellHistory(record);
+ setSellHistory((prev) => [saved ?? record, ...prev]);
+ } catch {
+ /* 백엔드 미구현 시 낙관적 UI 유지 */
+ setSellHistory((prev) => [{ ...record, id: Date.now() }, ...prev]);
+ }
+
setSellConfirmId(null);
await loadPortfolio();
} catch (err) {
@@ -515,6 +594,93 @@ const StockTrade = () => {
}
};
+ const handleDeleteSellRecord = async (id) => {
+ setSellHistory((prev) => prev.filter((r) => r.id !== id));
+ try {
+ await deleteSellHistory(id);
+ } catch {
+ /* 실패 시 목록 재로드로 복구 */
+ loadSellHistory();
+ }
+ };
+
+ /* 수동 추가 폼 열기 */
+ const handleSellFormOpen = () => {
+ setSellEditId(null);
+ setSellForm(emptySellForm());
+ setSellFormError('');
+ setSellFormOpen(true);
+ };
+
+ /* 수정 폼 열기 */
+ const handleSellEditStart = (record) => {
+ setSellEditId(record.id);
+ setSellForm({
+ broker: record.broker ?? '',
+ ticker: record.ticker ?? '',
+ name: record.name ?? '',
+ quantity: String(record.quantity ?? ''),
+ avg_price: String(record.avg_price ?? ''),
+ sell_price: String(record.sell_price ?? ''),
+ sold_at: toLocalDatetimeValue(record.sold_at),
+ });
+ setSellFormError('');
+ setSellFormOpen(true);
+ };
+
+ /* 폼 닫기 */
+ const handleSellFormClose = () => {
+ setSellFormOpen(false);
+ setSellEditId(null);
+ setSellFormError('');
+ };
+
+ /* 폼 제출 (추가 or 수정) */
+ const handleSellFormSubmit = async (e) => {
+ e.preventDefault();
+ setSellFormSaving(true);
+ setSellFormError('');
+
+ const qty = Number(sellForm.quantity);
+ const avgPrice = Number(sellForm.avg_price);
+ const sellPrice = Number(sellForm.sell_price);
+ const buyAmount = avgPrice * qty;
+ const sellAmount = sellPrice * qty;
+ const realizedProfit = sellAmount - buyAmount;
+ const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
+
+ const payload = {
+ broker: sellForm.broker.trim(),
+ ticker: sellForm.ticker.trim(),
+ name: sellForm.name.trim(),
+ quantity: qty,
+ avg_price: avgPrice,
+ sell_price: sellPrice,
+ buy_amount: buyAmount,
+ sell_amount: sellAmount,
+ realized_profit: realizedProfit,
+ realized_rate: realizedRate,
+ sold_at: sellForm.sold_at ? new Date(sellForm.sold_at).toISOString() : new Date().toISOString(),
+ };
+
+ try {
+ if (sellEditId != null) {
+ const updated = await updateSellHistory(sellEditId, payload);
+ setSellHistory((prev) =>
+ prev.map((r) => (r.id === sellEditId ? (updated ?? { ...payload, id: sellEditId }) : r))
+ );
+ } else {
+ const saved = await addSellHistory(payload);
+ setSellHistory((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
+ }
+ handleSellFormClose();
+ } catch (err) {
+ setSellFormError(err?.message ?? String(err));
+ } finally {
+ setSellFormSaving(false);
+ }
+ };
+
/* ── report sort ─────────────────────────────────────────────── */
const handleReportSort = (field) => {
@@ -801,6 +967,37 @@ ${holdingsText}${marketText}
});
}, [portfolioHoldings, reportSortField, reportSortDir]);
+ /* ── derived: 실현손익 필터 ────────────────────────────────────── */
+
+ const sellHistoryBrokers = useMemo(() => {
+ const set = new Set(sellHistory.map((r) => r.broker).filter(Boolean));
+ return ['ALL', ...Array.from(set).sort()];
+ }, [sellHistory]);
+
+ const filteredSellHistory = useMemo(() => {
+ const now = new Date();
+ const periodMs = {
+ '1M': 30 * 86400000,
+ '3M': 90 * 86400000,
+ '6M': 180 * 86400000,
+ '1Y': 365 * 86400000,
+ 'ALL': Infinity,
+ }[sellHistoryPeriod] ?? Infinity;
+ return sellHistory.filter((r) => {
+ if (sellHistoryBroker !== 'ALL' && r.broker !== sellHistoryBroker) return false;
+ const diff = now - new Date(r.sold_at);
+ return diff <= periodMs;
+ });
+ }, [sellHistory, sellHistoryBroker, sellHistoryPeriod]);
+
+ const sellHistorySummary = useMemo(() => {
+ const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
+ const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
+ const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
+ const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
+ return { totalProfit, totalSell, totalBuy, rate, count: filteredSellHistory.length };
+ }, [filteredSellHistory]);
+
/* ── render ───────────────────────────────────────────────────── */
return (
@@ -1555,6 +1752,8 @@ ${holdingsText}${marketText}
)}
+
+ {/* sell history → 드로어로 이동됨 */}
>
)}
@@ -2195,6 +2394,321 @@ ${holdingsText}${marketText}
) : null}
+
+ {/* ── 실현손익 floating 토글 버튼 (마우스 추적) ────────── */}
+ {!sellDrawerOpen && (
+
+ )}
+
+ {/* ════════════════════════════════════════════════════════
+ 실현손익 드로어
+ ════════════════════════════════════════════════════════ */}
+ {sellDrawerOpen && (
+ { setSellDrawerOpen(false); handleSellFormClose(); }}
+ />
+ )}
+
);
};