refactor(portfolio): 추가폼/총자산요약/자산추이차트 컴포넌트 추출
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
@@ -1,11 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Loading from '../../../components/Loading';
|
import Loading from '../../../components/Loading';
|
||||||
import {
|
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
|
||||||
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
|
||||||
Tooltip as ChartTooltip,
|
|
||||||
} from 'recharts';
|
|
||||||
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../stockUtils';
|
|
||||||
import PriceSessionBadge from './portfolio/PriceSessionBadge';
|
import PriceSessionBadge from './portfolio/PriceSessionBadge';
|
||||||
|
import AddHoldingForm from './portfolio/AddHoldingForm';
|
||||||
|
import PortfolioSummary from './portfolio/PortfolioSummary';
|
||||||
|
import AssetHistoryChart from './portfolio/AssetHistoryChart';
|
||||||
|
|
||||||
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||||
<>
|
<>
|
||||||
@@ -44,222 +43,13 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add form */}
|
{/* Add form */}
|
||||||
{pf.addFormOpen && (
|
<AddHoldingForm pf={pf} />
|
||||||
<form className="pf-add-form" onSubmit={pf.handleAddSubmit}>
|
|
||||||
<label>
|
|
||||||
증권사
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={pf.addForm.broker}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setAddForm((p) => ({ ...p, broker: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="KB증권"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
종목코드
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={pf.addForm.ticker}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="005930"
|
|
||||||
required
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
종목명
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={pf.addForm.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setAddForm((p) => ({ ...p, name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="삼성전자"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
수량
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={1}
|
|
||||||
value={pf.addForm.quantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
평균단가 (원)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={pf.addForm.avg_price}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
매입가 (원)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={pf.addForm.purchase_price}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setAddForm((p) => ({ ...p, purchase_price: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="미입력 시 평균단가로 자동 설정"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="button primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={pf.addLoading}
|
|
||||||
>
|
|
||||||
{pf.addLoading ? '등록 중...' : '종목 등록'}
|
|
||||||
</button>
|
|
||||||
{pf.addError && <p className="stock-error">{pf.addError}</p>}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Portfolio total summary */}
|
{/* Portfolio total summary */}
|
||||||
{pf.portfolioHoldings.length > 0 && (
|
<PortfolioSummary pf={pf} />
|
||||||
<div className="pf-total-summary">
|
|
||||||
{[
|
|
||||||
{ label: '총 매입', value: pf.portfolioSummary.total_buy },
|
|
||||||
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
|
|
||||||
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
|
|
||||||
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
|
|
||||||
].map((s) => {
|
|
||||||
const display = s.isRate ? formatPercent(s.value) : formatNumber(s.value);
|
|
||||||
const profitCls = s.isProfit || s.isRate
|
|
||||||
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<div key={s.label} className="pf-total-summary__card">
|
|
||||||
<span>{s.label}</span>
|
|
||||||
<strong className={`${profitCls} ${numFitClass(display)}`.trim()}>
|
|
||||||
{display}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{pf.totalCash != null && (() => {
|
|
||||||
const display = `${formatNumber(pf.totalCash)}원`;
|
|
||||||
return (
|
|
||||||
<div className="pf-total-summary__card is-cash">
|
|
||||||
<span>예수금 합계</span>
|
|
||||||
<strong className={numFitClass(display)}>{display}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{pf.totalAssets != null && (() => {
|
|
||||||
const display = `${formatNumber(pf.totalAssets)}원`;
|
|
||||||
return (
|
|
||||||
<div className="pf-total-summary__card is-assets">
|
|
||||||
<span>총 자산</span>
|
|
||||||
<strong className={numFitClass(display)}>{display}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 자산 추이 차트 */}
|
{/* 자산 추이 차트 */}
|
||||||
<div className="pf-asset-history">
|
<AssetHistoryChart asset={asset} pf={pf} handleSaveSnapshot={handleSaveSnapshot} />
|
||||||
<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 ${asset.assetHistoryDays === value ? 'is-active' : ''}`}
|
|
||||||
onClick={() => asset.setAssetHistoryDays(value)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button ghost small"
|
|
||||||
onClick={handleSaveSnapshot}
|
|
||||||
disabled={asset.snapshotSaving || pf.totalAssets == null}
|
|
||||||
title="현재 총 자산을 오늘 날짜로 저장"
|
|
||||||
>
|
|
||||||
{asset.snapshotSaving ? '저장 중...' : '📸 스냅샷'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{asset.assetHistoryLoading ? (
|
|
||||||
<div className="pf-asset-history__empty">
|
|
||||||
<Loading type="spinner" message="" />
|
|
||||||
</div>
|
|
||||||
) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
|
||||||
<AreaChart
|
|
||||||
data={asset.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>
|
</section>
|
||||||
|
|
||||||
{/* 예수금 패널 */}
|
{/* 예수금 패널 */}
|
||||||
|
|||||||
95
src/pages/stock/components/portfolio/AddHoldingForm.jsx
Normal file
95
src/pages/stock/components/portfolio/AddHoldingForm.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const AddHoldingForm = ({ pf }) => {
|
||||||
|
if (!pf.addFormOpen) return null;
|
||||||
|
return (
|
||||||
|
<form className="pf-add-form" onSubmit={pf.handleAddSubmit}>
|
||||||
|
<label>
|
||||||
|
증권사
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.ticker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="005930"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균단가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.avg_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매입가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.purchase_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, purchase_price: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="미입력 시 평균단가로 자동 설정"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={pf.addLoading}
|
||||||
|
>
|
||||||
|
{pf.addLoading ? '등록 중...' : '종목 등록'}
|
||||||
|
</button>
|
||||||
|
{pf.addError && <p className="stock-error">{pf.addError}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddHoldingForm;
|
||||||
94
src/pages/stock/components/portfolio/AssetHistoryChart.jsx
Normal file
94
src/pages/stock/components/portfolio/AssetHistoryChart.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../../components/Loading';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
const AssetHistoryChart = ({ asset, pf, handleSaveSnapshot }) => (
|
||||||
|
<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 ${asset.assetHistoryDays === value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => asset.setAssetHistoryDays(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={handleSaveSnapshot}
|
||||||
|
disabled={asset.snapshotSaving || pf.totalAssets == null}
|
||||||
|
title="현재 총 자산을 오늘 날짜로 저장"
|
||||||
|
>
|
||||||
|
{asset.snapshotSaving ? '저장 중...' : '📸 스냅샷'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.assetHistoryLoading ? (
|
||||||
|
<div className="pf-asset-history__empty">
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
</div>
|
||||||
|
) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart
|
||||||
|
data={asset.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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AssetHistoryChart;
|
||||||
49
src/pages/stock/components/portfolio/PortfolioSummary.jsx
Normal file
49
src/pages/stock/components/portfolio/PortfolioSummary.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../../stockUtils';
|
||||||
|
|
||||||
|
const PortfolioSummary = ({ pf }) => {
|
||||||
|
if (!(pf.portfolioHoldings.length > 0)) return null;
|
||||||
|
return (
|
||||||
|
<div className="pf-total-summary">
|
||||||
|
{[
|
||||||
|
{ label: '총 매입', value: pf.portfolioSummary.total_buy },
|
||||||
|
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
|
||||||
|
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
|
||||||
|
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
|
||||||
|
].map((s) => {
|
||||||
|
const display = s.isRate ? formatPercent(s.value) : formatNumber(s.value);
|
||||||
|
const profitCls = s.isProfit || s.isRate
|
||||||
|
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<div key={s.label} className="pf-total-summary__card">
|
||||||
|
<span>{s.label}</span>
|
||||||
|
<strong className={`${profitCls} ${numFitClass(display)}`.trim()}>
|
||||||
|
{display}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pf.totalCash != null && (() => {
|
||||||
|
const display = `${formatNumber(pf.totalCash)}원`;
|
||||||
|
return (
|
||||||
|
<div className="pf-total-summary__card is-cash">
|
||||||
|
<span>예수금 합계</span>
|
||||||
|
<strong className={numFitClass(display)}>{display}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{pf.totalAssets != null && (() => {
|
||||||
|
const display = `${formatNumber(pf.totalAssets)}원`;
|
||||||
|
return (
|
||||||
|
<div className="pf-total-summary__card is-assets">
|
||||||
|
<span>총 자산</span>
|
||||||
|
<strong className={numFitClass(display)}>{display}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PortfolioSummary;
|
||||||
Reference in New Issue
Block a user