주식 히스토리 API 및 블로그 작성 API 추가
This commit is contained in:
@@ -854,6 +854,31 @@
|
||||
border-color: rgba(243, 167, 167, 0.5) !important;
|
||||
}
|
||||
|
||||
.pf-btn-sell {
|
||||
color: #fbbf24 !important;
|
||||
border-color: rgba(251, 191, 36, 0.5) !important;
|
||||
}
|
||||
|
||||
.pf-sell-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pf-sell-confirm__msg {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pf-sell-confirm__warn {
|
||||
color: #fbbf24;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pf-null-price {
|
||||
color: var(--muted) !important;
|
||||
font-size: 12px !important;
|
||||
@@ -964,6 +989,23 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pf-cash-edit-input {
|
||||
border: 1px solid var(--neon-cyan);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
color: var(--text-bright);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pf-cash-edit-input:focus {
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
.pf-cash-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
@@ -1026,6 +1068,77 @@
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* ── 자산 추이 차트 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-asset-history {
|
||||
margin-top: 20px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line-subtle);
|
||||
}
|
||||
|
||||
.pf-asset-history__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pf-asset-history__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.pf-asset-history__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pf-asset-period-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.pf-asset-period-btn:hover {
|
||||
border-color: var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pf-asset-period-btn.is-active {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border-color: var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.pf-asset-history__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pf-asset-history__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pf-cash-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getVix,
|
||||
getTreasury10Y,
|
||||
getWTI,
|
||||
getAssetHistory,
|
||||
saveAssetSnapshot,
|
||||
} from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import './Stock.css';
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
PieChart, Pie, Cell,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||
AreaChart, Area,
|
||||
} from 'recharts';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
@@ -156,11 +159,28 @@ const StockTrade = () => {
|
||||
/* Portfolio delete */
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
|
||||
/* Portfolio sell */
|
||||
const [sellConfirmId, setSellConfirmId] = useState(null);
|
||||
const [sellLoading, setSellLoading] = useState(false);
|
||||
|
||||
/* Cash (예수금) form */
|
||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||
const [cashSaving, setCashSaving] = useState(false);
|
||||
const [cashError, setCashError] = useState('');
|
||||
|
||||
/* Cash inline edit */
|
||||
const [cashEditingBroker, setCashEditingBroker] = useState(null);
|
||||
const [cashEditingValue, setCashEditingValue] = useState('');
|
||||
const [cashEditSaving, setCashEditSaving] = useState(false);
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* 자산 추이 state */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
const [assetHistory, setAssetHistory] = useState(null);
|
||||
const [assetHistoryLoading, setAssetHistoryLoading] = useState(false);
|
||||
const [assetHistoryDays, setAssetHistoryDays] = useState(30);
|
||||
const [snapshotSaving, setSnapshotSaving] = useState(false);
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* 리포트 탭 state */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
@@ -225,6 +245,59 @@ const StockTrade = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAssetHistory = useCallback(async (days) => {
|
||||
setAssetHistoryLoading(true);
|
||||
try {
|
||||
const data = await getAssetHistory(days);
|
||||
// 백엔드 응답 키: snapshots 또는 history 모두 허용
|
||||
const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []);
|
||||
// 날짜 → total_assets 맵
|
||||
const byDate = {};
|
||||
for (const item of raw) {
|
||||
byDate[item.date] = item.total_assets ?? 0;
|
||||
}
|
||||
// days > 0: 오늘 기준으로 days일치 전체 날짜 생성 후 없는 날은 0 채움
|
||||
// days = 0(전체): 받은 데이터만 날짜순 정렬
|
||||
const toLocalDate = (d) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
let filled;
|
||||
if (days > 0) {
|
||||
const today = new Date();
|
||||
filled = Array.from({ length: days }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - (days - 1 - i));
|
||||
const dateStr = toLocalDate(d);
|
||||
return { date: dateStr, total_assets: byDate[dateStr] ?? 0 };
|
||||
});
|
||||
} else {
|
||||
filled = Object.entries(byDate)
|
||||
.map(([date, total_assets]) => ({ date, total_assets }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
setAssetHistory(filled);
|
||||
} catch {
|
||||
setAssetHistory([]);
|
||||
} finally {
|
||||
setAssetHistoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveSnapshot = async () => {
|
||||
setSnapshotSaving(true);
|
||||
try {
|
||||
await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined);
|
||||
await loadAssetHistory(assetHistoryDays);
|
||||
} catch (err) {
|
||||
alert('스냅샷 저장 실패: ' + (err?.message ?? String(err)));
|
||||
} finally {
|
||||
setSnapshotSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* Lazy load: 탭 전환 시 해당 API만 호출 */
|
||||
useEffect(() => {
|
||||
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
|
||||
@@ -236,6 +309,13 @@ const StockTrade = () => {
|
||||
}
|
||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
||||
|
||||
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
|
||||
useEffect(() => {
|
||||
if (activeTab === TAB_PORTFOLIO) {
|
||||
loadAssetHistory(assetHistoryDays);
|
||||
}
|
||||
}, [activeTab, assetHistoryDays, loadAssetHistory]);
|
||||
|
||||
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
|
||||
useEffect(() => {
|
||||
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
|
||||
@@ -386,6 +466,55 @@ const StockTrade = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashInlineEdit = (item) => {
|
||||
setCashEditingBroker(item.broker);
|
||||
setCashEditingValue(String(item.cash ?? ''));
|
||||
};
|
||||
|
||||
const handleCashInlineSave = async (broker) => {
|
||||
if (cashEditingValue === '') return;
|
||||
setCashEditSaving(true);
|
||||
try {
|
||||
await upsertCash(broker, Number(cashEditingValue));
|
||||
setCashEditingBroker(null);
|
||||
setCashEditingValue('');
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
alert('예수금 수정 실패: ' + (err?.message ?? String(err)));
|
||||
} finally {
|
||||
setCashEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashInlineCancel = () => {
|
||||
setCashEditingBroker(null);
|
||||
setCashEditingValue('');
|
||||
};
|
||||
|
||||
/* ── sell (현재가 매도) ───────────────────────────────────────── */
|
||||
|
||||
const handleSell = async (item) => {
|
||||
const sellPrice = item.current_price ?? item.avg_price;
|
||||
const qty = item.quantity ?? 0;
|
||||
const saleAmount = sellPrice * qty;
|
||||
const broker = item.broker ?? '';
|
||||
|
||||
setSellLoading(true);
|
||||
try {
|
||||
// 기존 예수금에 판매금액 합산
|
||||
const existing = cashList.find((c) => c.broker === broker);
|
||||
const newCash = (existing?.cash ?? 0) + saleAmount;
|
||||
await upsertCash(broker, newCash);
|
||||
await deletePortfolio(item.id);
|
||||
setSellConfirmId(null);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
|
||||
} finally {
|
||||
setSellLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── report sort ─────────────────────────────────────────────── */
|
||||
|
||||
const handleReportSort = (field) => {
|
||||
@@ -951,6 +1080,93 @@ ${holdingsText}${marketText}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 자산 추이 차트 */}
|
||||
<div className="pf-asset-history">
|
||||
<div className="pf-asset-history__head">
|
||||
<p className="pf-asset-history__title">총 자산 추이</p>
|
||||
<div className="pf-asset-history__controls">
|
||||
{[
|
||||
{ label: '7일', value: 7 },
|
||||
{ label: '30일', value: 30 },
|
||||
{ label: '90일', value: 90 },
|
||||
{ label: '전체', value: 0 },
|
||||
].map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`pf-asset-period-btn ${assetHistoryDays === value ? 'is-active' : ''}`}
|
||||
onClick={() => setAssetHistoryDays(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost small"
|
||||
onClick={handleSaveSnapshot}
|
||||
disabled={snapshotSaving || totalAssets == null}
|
||||
title="현재 총 자산을 오늘 날짜로 저장"
|
||||
>
|
||||
{snapshotSaving ? '저장 중...' : '📸 스냅샷'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assetHistoryLoading ? (
|
||||
<div className="pf-asset-history__empty">
|
||||
<Loading type="spinner" message="" />
|
||||
</div>
|
||||
) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart
|
||||
data={assetHistory}
|
||||
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="assetGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.25} />
|
||||
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
|
||||
tickFormatter={(v) => v?.slice(5)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
hide
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<ChartTooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: 'var(--text-dim)', marginBottom: 4 }}
|
||||
formatter={(v) => [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total_assets"
|
||||
stroke="#38bdf8"
|
||||
strokeWidth={2}
|
||||
fill="url(#assetGrad)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#38bdf8' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="pf-asset-history__empty">
|
||||
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 예수금 패널 */}
|
||||
@@ -967,26 +1183,73 @@ ${holdingsText}${marketText}
|
||||
|
||||
{cashList.length > 0 && (
|
||||
<div className="pf-cash-table">
|
||||
{cashList.map((item) => (
|
||||
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||
<span className="pf-cash-broker">{item.broker}</span>
|
||||
<strong className="pf-cash-amount">
|
||||
{formatNumber(item.cash)}원
|
||||
</strong>
|
||||
<span className="pf-cash-date">
|
||||
{item.updated_at
|
||||
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => handleCashDelete(item.broker)}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{cashList.map((item) => {
|
||||
const isEditing = cashEditingBroker === item.broker;
|
||||
return (
|
||||
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||
<span className="pf-cash-broker">{item.broker}</span>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="pf-cash-edit-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={cashEditingValue}
|
||||
onChange={(e) => setCashEditingValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCashInlineSave(item.broker);
|
||||
if (e.key === 'Escape') handleCashInlineCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<strong className="pf-cash-amount">
|
||||
{formatNumber(item.cash)}원
|
||||
</strong>
|
||||
)}
|
||||
<span className="pf-cash-date">
|
||||
{item.updated_at
|
||||
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||
: ''}
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
className="button primary small"
|
||||
onClick={() => handleCashInlineSave(item.broker)}
|
||||
disabled={cashEditSaving}
|
||||
>
|
||||
{cashEditSaving ? '저장 중' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={handleCashInlineCancel}
|
||||
disabled={cashEditSaving}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => handleCashInlineEdit(item)}
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => handleCashDelete(item.broker)}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{cashList.length === 0 && (
|
||||
@@ -1087,6 +1350,9 @@ ${holdingsText}${marketText}
|
||||
const profitRateN = toNumeric(profitRate);
|
||||
const isEditing = editingId === item.id;
|
||||
const isDeleting = deleteConfirmId === item.id;
|
||||
const isSelling = sellConfirmId === item.id;
|
||||
const sellPrice = item.current_price ?? item.avg_price;
|
||||
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1198,14 +1464,41 @@ ${holdingsText}${marketText}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="pf-item-actions">
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => handleEditStart(item)}
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{isDeleting ? (
|
||||
{!isSelling && !isDeleting && (
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => handleEditStart(item)}
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{isSelling ? (
|
||||
<div className="pf-sell-confirm">
|
||||
<span className="pf-sell-confirm__msg">
|
||||
{item.current_price == null && (
|
||||
<small className="pf-sell-confirm__warn">현재가 미조회 — 매입가 기준</small>
|
||||
)}
|
||||
{saleAmount != null
|
||||
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
|
||||
: '매도 처리'}
|
||||
</span>
|
||||
<button
|
||||
className="button small pf-btn-sell"
|
||||
onClick={() => handleSell(item)}
|
||||
disabled={sellLoading}
|
||||
>
|
||||
{sellLoading ? '처리 중...' : '매도 확인'}
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => setSellConfirmId(null)}
|
||||
disabled={sellLoading}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
) : isDeleting ? (
|
||||
<>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
@@ -1215,23 +1508,34 @@ ${holdingsText}${marketText}
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() =>
|
||||
setDeleteConfirmId(null)
|
||||
}
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() =>
|
||||
setDeleteConfirmId(item.id)
|
||||
}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="button ghost small pf-btn-sell"
|
||||
onClick={() => {
|
||||
setSellConfirmId(item.id);
|
||||
setDeleteConfirmId(null);
|
||||
}}
|
||||
title="매도"
|
||||
>
|
||||
매도
|
||||
</button>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => {
|
||||
setDeleteConfirmId(item.id);
|
||||
setSellConfirmId(null);
|
||||
}}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user