stock 실현손익 보여줄 수 있게 화면 구성 추가
This commit is contained in:
25
src/api.js
25
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 → 새 글 생성
|
||||
|
||||
@@ -2105,3 +2105,548 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* sell history → 드로어로 이동됨 */}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2195,6 +2394,321 @@ ${holdingsText}${marketText}
|
||||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user