stock 실현손익 보여줄 수 있게 화면 구성 추가

This commit is contained in:
2026-03-19 23:36:33 +09:00
parent b8d6dac70a
commit 248835fa54
3 changed files with 1086 additions and 2 deletions

View File

@@ -221,6 +221,31 @@ export function clearTodos() {
return apiDelete('/api/todos/done'); 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 ──────────────────────────────────────────────────────────────── // ── 블로그 API ────────────────────────────────────────────────────────────────
// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] } // GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] }
// POST /api/blog/posts → 새 글 생성 // POST /api/blog/posts → 새 글 생성

View File

@@ -2105,3 +2105,548 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ══════════════════════════════════════════════════════════════════
실현손익 내역
══════════════════════════════════════════════════════════════════ */
.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;
}
}

View File

@@ -15,6 +15,10 @@ import {
getWTI, getWTI,
getAssetHistory, getAssetHistory,
saveAssetSnapshot, saveAssetSnapshot,
getSellHistory,
addSellHistory,
updateSellHistory,
deleteSellHistory,
} from '../../api'; } from '../../api';
import Loading from '../../components/Loading'; import Loading from '../../components/Loading';
import './Stock.css'; import './Stock.css';
@@ -124,6 +128,25 @@ const emptyPortfolioForm = {
avg_price: '', 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 ─────────────────────────────────────────────────────── */ /* ── TAB IDs ─────────────────────────────────────────────────────── */
const TAB_PORTFOLIO = 'portfolio'; const TAB_PORTFOLIO = 'portfolio';
@@ -163,6 +186,22 @@ const StockTrade = () => {
const [sellConfirmId, setSellConfirmId] = useState(null); const [sellConfirmId, setSellConfirmId] = useState(null);
const [sellLoading, setSellLoading] = useState(false); 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 */ /* Cash (예수금) form */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' }); const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
const [cashSaving, setCashSaving] = useState(false); 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 () => { const loadBalance = useCallback(async () => {
setBalanceLoading(true); setBalanceLoading(true);
setBalanceError(''); setBalanceError('');
@@ -302,12 +353,13 @@ const StockTrade = () => {
useEffect(() => { useEffect(() => {
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) { if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
loadPortfolio(); loadPortfolio();
loadSellHistory();
} else if (activeTab === TAB_AI && !balanceLoaded) { } else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance(); loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) { } else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio(); loadPortfolio();
} }
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]); }, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */ /* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
useEffect(() => { useEffect(() => {
@@ -495,8 +547,12 @@ const StockTrade = () => {
const handleSell = async (item) => { const handleSell = async (item) => {
const sellPrice = item.current_price ?? item.avg_price; const sellPrice = item.current_price ?? item.avg_price;
const avgPrice = item.avg_price ?? 0;
const qty = item.quantity ?? 0; const qty = item.quantity ?? 0;
const saleAmount = sellPrice * qty; const saleAmount = sellPrice * qty;
const buyAmount = avgPrice * qty;
const realizedProfit = saleAmount - buyAmount;
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
const broker = item.broker ?? ''; const broker = item.broker ?? '';
setSellLoading(true); setSellLoading(true);
@@ -506,6 +562,29 @@ const StockTrade = () => {
const newCash = (existing?.cash ?? 0) + saleAmount; const newCash = (existing?.cash ?? 0) + saleAmount;
await upsertCash(broker, newCash); await upsertCash(broker, newCash);
await deletePortfolio(item.id); 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); setSellConfirmId(null);
await loadPortfolio(); await loadPortfolio();
} catch (err) { } 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 ─────────────────────────────────────────────── */ /* ── report sort ─────────────────────────────────────────────── */
const handleReportSort = (field) => { const handleReportSort = (field) => {
@@ -801,6 +967,37 @@ ${holdingsText}${marketText}
}); });
}, [portfolioHoldings, reportSortField, reportSortDir]); }, [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 ───────────────────────────────────────────────────── */ /* ── render ───────────────────────────────────────────────────── */
return ( return (
@@ -1555,6 +1752,8 @@ ${holdingsText}${marketText}
</p> </p>
</section> </section>
)} )}
{/* sell history → 드로어로 이동됨 */}
</> </>
)} )}
@@ -2195,6 +2394,321 @@ ${holdingsText}${marketText}
</div> </div>
</div> </div>
) : null} ) : null}
{/* ── 실현손익 floating 토글 버튼 (마우스 추적) ────────── */}
{!sellDrawerOpen && (
<button
type="button"
className="sh-floating-toggle"
onClick={() => {
setSellDrawerOpen(true);
loadSellHistory();
}}
title="실현손익 내역"
>
<span className="sh-floating-toggle__icon">💹</span>
<span className="sh-floating-toggle__label">실현손익</span>
{sellHistory.length > 0 && (
<span className="sh-floating-toggle__badge">{sellHistory.length}</span>
)}
</button>
)}
{/* ════════════════════════════════════════════════════════
실현손익 드로어
════════════════════════════════════════════════════════ */}
{sellDrawerOpen && (
<div
className="sh-backdrop"
onClick={() => { setSellDrawerOpen(false); handleSellFormClose(); }}
/>
)}
<aside className={`sh-drawer ${sellDrawerOpen ? 'is-open' : ''}`}>
{/* 드로어 헤더 */}
<div className="sh-drawer__header">
<div>
<p className="sh-drawer__eyebrow">실현손익</p>
<h3 className="sh-drawer__title">매도 거래 내역</h3>
</div>
<div className="sh-drawer__header-actions">
{sellHistoryLoading && <Loading type="spinner" message="" />}
<button
className="button ghost small"
onClick={loadSellHistory}
disabled={sellHistoryLoading}
>
새로고침
</button>
<button
className="button primary small"
onClick={sellFormOpen && sellEditId == null ? handleSellFormClose : handleSellFormOpen}
>
{sellFormOpen && sellEditId == null ? '취소' : '+ 추가'}
</button>
<button
className="sh-drawer__close"
type="button"
onClick={() => { setSellDrawerOpen(false); handleSellFormClose(); }}
aria-label="닫기"
>
</button>
</div>
</div>
{/* 수동 추가 / 수정 폼 */}
{sellFormOpen && (
<form className="sh-form" onSubmit={handleSellFormSubmit}>
<div className="sh-form__title">
{sellEditId != null ? '거래 내역 수정' : '매도 내역 수동 추가'}
</div>
<div className="sh-form__grid">
<label>
증권사
<input
type="text"
value={sellForm.broker}
onChange={(e) => setSellForm((p) => ({ ...p, broker: e.target.value }))}
placeholder="KB증권"
required
/>
</label>
<label>
종목코드
<input
type="text"
value={sellForm.ticker}
onChange={(e) => setSellForm((p) => ({ ...p, ticker: e.target.value }))}
placeholder="005930"
/>
</label>
<label>
종목명
<input
type="text"
value={sellForm.name}
onChange={(e) => setSellForm((p) => ({ ...p, name: e.target.value }))}
placeholder="삼성전자"
required
/>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={sellForm.quantity}
onChange={(e) => setSellForm((p) => ({ ...p, quantity: e.target.value }))}
placeholder="10"
required
/>
</label>
<label>
평균 매입가 ()
<input
type="number"
min={0}
step={1}
value={sellForm.avg_price}
onChange={(e) => setSellForm((p) => ({ ...p, avg_price: e.target.value }))}
placeholder="58000"
required
/>
</label>
<label>
매도가 ()
<input
type="number"
min={0}
step={1}
value={sellForm.sell_price}
onChange={(e) => setSellForm((p) => ({ ...p, sell_price: e.target.value }))}
placeholder="62000"
required
/>
</label>
<label className="sh-form__datetime">
매도 일시
<input
type="datetime-local"
value={sellForm.sold_at}
onChange={(e) => setSellForm((p) => ({ ...p, sold_at: e.target.value }))}
required
/>
</label>
</div>
{sellForm.quantity && sellForm.avg_price && sellForm.sell_price && (() => {
const qty = Number(sellForm.quantity);
const buy = Number(sellForm.avg_price) * qty;
const sell = Number(sellForm.sell_price) * qty;
const profit = sell - buy;
const rate = buy > 0 ? (profit / buy) * 100 : 0;
return (
<div className="sh-form__preview">
<span>매도금액 <strong>{formatNumber(Math.round(sell))}</strong></span>
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}</strong></span>
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
</div>
);
})()}
<div className="sh-form__actions">
<button className="button primary" type="submit" disabled={sellFormSaving}>
{sellFormSaving ? '저장 중...' : (sellEditId != null ? '수정 저장' : '추가')}
</button>
<button className="button ghost" type="button" onClick={handleSellFormClose} disabled={sellFormSaving}>
취소
</button>
{sellFormError && <p className="stock-error">{sellFormError}</p>}
</div>
</form>
)}
{/* 필터 바 */}
<div className="sell-history__filters">
<div className="sell-history__filter-group">
<span className="sell-history__filter-label">계좌</span>
{sellHistoryBrokers.map((b) => (
<button
key={b}
type="button"
className={`sell-history__filter-btn ${sellHistoryBroker === b ? 'is-active' : ''}`}
onClick={() => setSellHistoryBroker(b)}
>
{b === 'ALL' ? '전체' : b}
</button>
))}
</div>
<div className="sell-history__filter-group">
<span className="sell-history__filter-label">기간</span>
{[
{ label: '1개월', value: '1M' },
{ label: '3개월', value: '3M' },
{ label: '6개월', value: '6M' },
{ label: '1년', value: '1Y' },
{ label: '전체', value: 'ALL' },
].map(({ label, value }) => (
<button
key={value}
type="button"
className={`sell-history__filter-btn ${sellHistoryPeriod === value ? 'is-active' : ''}`}
onClick={() => setSellHistoryPeriod(value)}
>
{label}
</button>
))}
</div>
</div>
{/* 요약 카드 */}
{filteredSellHistory.length > 0 && (
<div className="sell-history__summary">
<div className="sell-history__summary-card">
<span>거래 횟수</span>
<strong>{sellHistorySummary.count}</strong>
</div>
<div className="sell-history__summary-card">
<span> 매도금액</span>
<strong>{formatNumber(sellHistorySummary.totalSell)}</strong>
</div>
<div className="sell-history__summary-card">
<span>실현손익 합계</span>
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
{formatNumber(Math.round(sellHistorySummary.totalProfit))}
</strong>
</div>
<div className="sell-history__summary-card">
<span>평균 수익률</span>
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.rate)}`}>
{formatPercent(sellHistorySummary.rate)}
</strong>
</div>
</div>
)}
{/* 거래 내역 목록 */}
{filteredSellHistory.length > 0 ? (
<div className="sh-drawer__list">
{filteredSellHistory.map((r) => {
const profitN = r.realized_profit ?? 0;
const rateN = r.realized_rate ?? 0;
return (
<div key={r.id} className="sh-drawer__item">
<div className="sh-drawer__item-top">
<div className="sh-drawer__item-name">
<span>{r.name}</span>
{r.ticker && <code>{r.ticker}</code>}
</div>
<div className="sh-drawer__item-actions">
<button
type="button"
className="button ghost small"
onClick={() => handleSellEditStart(r)}
title="수정"
>
</button>
<button
type="button"
className="button ghost small pf-btn-danger"
onClick={() => handleDeleteSellRecord(r.id)}
title="삭제"
>
🗑
</button>
</div>
</div>
<div className="sh-drawer__item-meta">
<span className="sell-history__broker">{r.broker}</span>
<span className="sell-history__date">
{new Date(r.sold_at).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
<div className="sh-drawer__item-metrics">
<div>
<span>수량</span>
<strong>{formatNumber(r.quantity)}</strong>
</div>
<div>
<span>매입가</span>
<strong>{formatNumber(r.avg_price)}</strong>
</div>
<div>
<span>매도가</span>
<strong>{formatNumber(r.sell_price)}</strong>
</div>
<div>
<span>매도금액</span>
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
</div>
<div>
<span>실현손익</span>
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
{formatNumber(Math.round(profitN))}
</strong>
</div>
<div>
<span>수익률</span>
<strong className={`stock-profit ${profitColorClass(rateN)}`}>
{formatPercent(rateN)}
</strong>
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="stock-empty sh-drawer__empty">
{sellHistory.length === 0
? '아직 매도 기록이 없습니다.'
: '필터 조건에 맞는 기록이 없습니다.'}
</p>
)}
</aside>
</div> </div>
); );
}; };