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:
2026-07-10 10:16:25 +09:00
parent 81c9a5d692
commit 6f98c0a84d
4 changed files with 245 additions and 217 deletions

View File

@@ -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>
{/* 예수금 패널 */} {/* 예수금 패널 */}

View 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;

View 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;

View 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;