Compare commits
2 Commits
628a47b2ec
...
9380bf331f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9380bf331f | |||
| ba30de718f |
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=https://gahusb.synology.me
|
||||||
34
src/api.js
34
src/api.js
@@ -57,6 +57,22 @@ export async function apiPost(path, body) {
|
|||||||
return res.json();
|
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() {
|
export function getLatest() {
|
||||||
return apiGet("/api/lotto/latest");
|
return apiGet("/api/lotto/latest");
|
||||||
}
|
}
|
||||||
@@ -120,3 +136,21 @@ export function getTradeBalance() {
|
|||||||
export function createTradeOrder(payload) {
|
export function createTradeOrder(payload) {
|
||||||
return apiPost("/api/trade/order", 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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -624,3 +624,169 @@
|
|||||||
font-size: 14px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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';
|
import './Stock.css';
|
||||||
|
|
||||||
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const formatNumber = (value) => {
|
const formatNumber = (value) => {
|
||||||
if (value === null || value === undefined || value === '') return '-';
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -15,7 +25,7 @@ const formatPercent = (value) => {
|
|||||||
if (typeof value === 'string' && value.includes('%')) return value;
|
if (typeof value === 'string' && value.includes('%')) return value;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (Number.isNaN(numeric)) return value;
|
if (Number.isNaN(numeric)) return value;
|
||||||
return `${numeric.toFixed(2)}%`;
|
return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickFirst = (...values) =>
|
const pickFirst = (...values) =>
|
||||||
@@ -63,10 +73,52 @@ const toNumeric = (value) => {
|
|||||||
return Number.isNaN(numeric) ? null : numeric;
|
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 = () => {
|
const StockTrade = () => {
|
||||||
|
/* Balance state */
|
||||||
const [balance, setBalance] = useState(null);
|
const [balance, setBalance] = useState(null);
|
||||||
const [balanceLoading, setBalanceLoading] = useState(false);
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||||
const [balanceError, setBalanceError] = useState('');
|
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({
|
const [manualForm, setManualForm] = useState({
|
||||||
code: '',
|
code: '',
|
||||||
qty: 1,
|
qty: 1,
|
||||||
@@ -78,7 +130,9 @@ const StockTrade = () => {
|
|||||||
const [manualResult, setManualResult] = useState(null);
|
const [manualResult, setManualResult] = useState(null);
|
||||||
const [kisModal, setKisModal] = useState('');
|
const [kisModal, setKisModal] = useState('');
|
||||||
|
|
||||||
const loadBalance = async () => {
|
/* ── loaders ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const loadBalance = useCallback(async () => {
|
||||||
setBalanceLoading(true);
|
setBalanceLoading(true);
|
||||||
setBalanceError('');
|
setBalanceError('');
|
||||||
try {
|
try {
|
||||||
@@ -89,8 +143,123 @@ const StockTrade = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setBalanceLoading(false);
|
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) => {
|
const submitManualOrder = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setManualLoading(true);
|
setManualLoading(true);
|
||||||
@@ -120,9 +289,7 @@ const StockTrade = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
/* ── derived data ────────────────────────────────────────────── */
|
||||||
loadBalance();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const holdings = useMemo(() => {
|
const holdings = useMemo(() => {
|
||||||
if (!balance) return [];
|
if (!balance) return [];
|
||||||
@@ -137,6 +304,56 @@ const StockTrade = () => {
|
|||||||
const deposit =
|
const deposit =
|
||||||
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
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 (
|
return (
|
||||||
<div className="stock">
|
<div className="stock">
|
||||||
<header className="stock-header">
|
<header className="stock-header">
|
||||||
@@ -167,6 +384,33 @@ const StockTrade = () => {
|
|||||||
<span>보유 종목</span>
|
<span>보유 종목</span>
|
||||||
<strong>{holdings.length}</strong>
|
<strong>{holdings.length}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
{portfolioHoldings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ borderTop: '1px solid var(--line)', paddingTop: 8 }}>
|
||||||
|
<span>포트폴리오 종목</span>
|
||||||
|
<strong>{portfolioHoldings.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>포트폴리오 평가</span>
|
||||||
|
<strong>{formatNumber(portfolioSummary.total_eval)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>포트폴리오 손익</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitColorClass(
|
||||||
|
toNumeric(portfolioSummary.total_profit)
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{formatNumber(portfolioSummary.total_profit)}
|
||||||
|
{portfolioSummary.total_profit_rate != null && (
|
||||||
|
<small style={{ marginLeft: 4, fontSize: 11 }}>
|
||||||
|
({formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{summary.note ? (
|
{summary.note ? (
|
||||||
<p className="stock-status__note">{summary.note}</p>
|
<p className="stock-status__note">{summary.note}</p>
|
||||||
@@ -174,6 +418,341 @@ const StockTrade = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* ── Portfolio sections (broker별) ─────────────────────── */}
|
||||||
|
{portfolioError ? (
|
||||||
|
<p className="stock-error">{portfolioError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 총 포트폴리오 요약 + 종목 추가 */}
|
||||||
|
<section className="stock-panel stock-panel--wide pf-section">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">포트폴리오</p>
|
||||||
|
<h3>수동 입력 종목 관리</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{portfolioLoading ? (
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={loadPortfolio}
|
||||||
|
disabled={portfolioLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => setAddFormOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{addFormOpen ? '취소' : '+ 종목 추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{addFormOpen && (
|
||||||
|
<form className="pf-add-form" onSubmit={handleAddSubmit}>
|
||||||
|
<label>
|
||||||
|
증권사
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addForm.ticker}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="005930"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((p) => ({ ...p, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={addForm.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균 매입가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={addForm.avg_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={addLoading}
|
||||||
|
>
|
||||||
|
{addLoading ? '등록 중...' : '종목 등록'}
|
||||||
|
</button>
|
||||||
|
{addError && <p className="stock-error">{addError}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Portfolio total summary */}
|
||||||
|
{portfolioHoldings.length > 0 && (
|
||||||
|
<div className="pf-total-summary">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<div key={s.label} className="pf-total-summary__card">
|
||||||
|
<span>{s.label}</span>
|
||||||
|
<strong
|
||||||
|
className={
|
||||||
|
s.isProfit || s.isRate
|
||||||
|
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Each broker gets a stacked card */}
|
||||||
|
{brokerGroups.map(([broker, items]) => {
|
||||||
|
const bSummary = getBrokerSummary(items);
|
||||||
|
const color = brokerColors[broker];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={broker}
|
||||||
|
className="stock-panel stock-panel--wide pf-broker-section"
|
||||||
|
style={{
|
||||||
|
borderColor: color?.border,
|
||||||
|
background: color?.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="stock-panel__eyebrow"
|
||||||
|
style={{ color: color?.border }}
|
||||||
|
>
|
||||||
|
{broker}
|
||||||
|
</p>
|
||||||
|
<h3>{broker} 보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
{items.length}종목 · 평가{' '}
|
||||||
|
{formatNumber(bSummary.totalEval)} · 손익{' '}
|
||||||
|
<span
|
||||||
|
className={`stock-profit ${profitColorClass(
|
||||||
|
bSummary.totalProfit
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{formatNumber(bSummary.totalProfit)} (
|
||||||
|
{formatPercent(bSummary.totalProfitRate)})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="stock-holdings__item pf-item"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
/* Edit mode */
|
||||||
|
<div className="pf-edit-row">
|
||||||
|
<div className="pf-edit-fields">
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={editForm.quantity ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((p) => ({
|
||||||
|
...p,
|
||||||
|
quantity: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균매입가
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={editForm.avg_price ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm((p) => ({
|
||||||
|
...p,
|
||||||
|
avg_price: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="pf-edit-actions">
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => handleEditSave(item.id)}
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
{editLoading ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Normal display */
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.ticker ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.ticker ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(item.quantity)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(item.avg_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong
|
||||||
|
className={item.current_price == null ? 'pf-null-price' : ''}
|
||||||
|
>
|
||||||
|
{item.current_price != null
|
||||||
|
? formatNumber(item.current_price)
|
||||||
|
: '조회 실패'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitColorClass(profitRateN)}`}
|
||||||
|
>
|
||||||
|
{profitRate != null
|
||||||
|
? formatPercent(profitRate)
|
||||||
|
: '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitColorClass(profitAmtN)}`}
|
||||||
|
>
|
||||||
|
{profitAmt != null
|
||||||
|
? formatNumber(profitAmt)
|
||||||
|
: '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{/* action buttons */}
|
||||||
|
<div className="pf-item-actions">
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => handleEditStart(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteConfirmId(item.id)
|
||||||
|
}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── Existing balance section ─────────────────────────── */}
|
||||||
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
|
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
|
||||||
|
|
||||||
<section className="stock-panel stock-panel--wide">
|
<section className="stock-panel stock-panel--wide">
|
||||||
@@ -224,24 +803,10 @@ const StockTrade = () => {
|
|||||||
{holdings.map((item, idx) => {
|
{holdings.map((item, idx) => {
|
||||||
const profitLoss = getProfitLoss(item);
|
const profitLoss = getProfitLoss(item);
|
||||||
const profitLossNumeric = toNumeric(profitLoss);
|
const profitLossNumeric = toNumeric(profitLoss);
|
||||||
const profitClass =
|
const profitClass = profitColorClass(profitLossNumeric);
|
||||||
profitLossNumeric > 0
|
|
||||||
? 'is-up'
|
|
||||||
: profitLossNumeric < 0
|
|
||||||
? 'is-down'
|
|
||||||
: profitLossNumeric === 0
|
|
||||||
? 'is-flat'
|
|
||||||
: '';
|
|
||||||
const profitRate = getProfitRate(item);
|
const profitRate = getProfitRate(item);
|
||||||
const profitRateNumeric = toNumeric(profitRate);
|
const profitRateNumeric = toNumeric(profitRate);
|
||||||
const profitRateClass =
|
const profitRateClass = profitColorClass(profitRateNumeric);
|
||||||
profitRateNumeric > 0
|
|
||||||
? 'is-up'
|
|
||||||
: profitRateNumeric < 0
|
|
||||||
? 'is-down'
|
|
||||||
: profitRateNumeric === 0
|
|
||||||
? 'is-flat'
|
|
||||||
: '';
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.code ?? `${item.name}-${idx}`}
|
key={item.code ?? `${item.name}-${idx}`}
|
||||||
@@ -301,6 +866,7 @@ const StockTrade = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Manual order section ─────────────────────────────── */}
|
||||||
<section className="stock-panel stock-panel--wide">
|
<section className="stock-panel stock-panel--wide">
|
||||||
<div className="stock-panel__head">
|
<div className="stock-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -396,6 +962,8 @@ const StockTrade = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* KIS modal */}
|
||||||
{kisModal ? (
|
{kisModal ? (
|
||||||
<div className="stock-modal" role="dialog" aria-modal="true">
|
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3007,
|
||||||
|
strictPort: false,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://gahusb.synology.me',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user