refactor(portfolio): 보유행/브로커섹션 추출 → PortfolioTab shell 축소

This commit is contained in:
2026-07-10 10:28:30 +09:00
parent ca5adbf4a4
commit 5064dc2b93
3 changed files with 267 additions and 250 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 { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
import PriceSessionBadge from './portfolio/PriceSessionBadge';
import AddHoldingForm from './portfolio/AddHoldingForm'; import AddHoldingForm from './portfolio/AddHoldingForm';
import PortfolioSummary from './portfolio/PortfolioSummary'; import PortfolioSummary from './portfolio/PortfolioSummary';
import AssetHistoryChart from './portfolio/AssetHistoryChart'; import AssetHistoryChart from './portfolio/AssetHistoryChart';
import CashPanel from './portfolio/CashPanel'; import CashPanel from './portfolio/CashPanel';
import BrokerSection from './portfolio/BrokerSection';
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => ( const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
<> <>
@@ -57,254 +56,9 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
<CashPanel pf={pf} /> <CashPanel pf={pf} />
{/* Broker cards stacked */} {/* Broker cards stacked */}
{pf.brokerGroups.map(([broker, items]) => { {pf.brokerGroups.map(([broker, items]) => (
const bSummary = pf.getBrokerSummary(items); <BrokerSection key={broker} broker={broker} items={items} pf={pf} handleSell={handleSell} />
const color = pf.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.totalBuy)} · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '}
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
</span>
{(() => {
const bc = pf.cashList.find((c) => c.broker === broker);
return bc ? (
<span className="pf-cash-badge">
예수금 {formatNumber(bc.cash)}
</span>
) : null;
})()}
</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 = pf.editingId === item.id;
const isDeleting = pf.deleteConfirmId === item.id;
const isSelling = pf.sellConfirmId === item.id;
const sellPrice = item.current_price ?? item.avg_price;
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
return (
<div key={item.id} className="stock-holdings__item pf-item">
{isEditing ? (
<div className="pf-edit-row">
<div className="pf-edit-fields">
<label>
수량
<input
type="number"
min={1}
value={pf.editForm.quantity ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
quantity: Number(e.target.value),
}))
}
/>
</label>
<label>
평균단가
<input
type="number"
min={0}
value={pf.editForm.avg_price ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
avg_price: Number(e.target.value),
}))
}
/>
</label>
<label>
매입가
<input
type="number"
min={0}
value={pf.editForm.purchase_price ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
purchase_price: Number(e.target.value),
}))
}
/>
</label>
</div>
<div className="pf-edit-actions">
<button
className="button primary small"
onClick={() => pf.handleEditSave(item.id)}
disabled={pf.editLoading}
>
{pf.editLoading ? '저장 중...' : '저장'}
</button>
<button
className="button ghost small"
onClick={() => pf.setEditingId(null)}
>
취소
</button>
</div>
</div>
) : (
<>
<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>{formatNumber(item.purchase_price ?? 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)
: '조회 실패'}
<PriceSessionBadge
session={item.price_session}
asOf={item.price_as_of}
/>
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가금액</span>
<strong>
{item.current_price != null && item.quantity != null
? formatNumber(item.current_price * item.quantity)
: '-'}
</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>
<div className="pf-item-actions">
{!isSelling && !isDeleting && (
<button
className="button ghost small"
onClick={() => pf.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={pf.sellLoading}
>
{pf.sellLoading ? '처리 중...' : '매도 확인'}
</button>
<button
className="button ghost small"
onClick={() => pf.setSellConfirmId(null)}
disabled={pf.sellLoading}
>
취소
</button>
</div>
) : isDeleting ? (
<>
<button
className="button ghost small pf-btn-danger"
onClick={() => pf.handleDelete(item.id)}
>
확인
</button>
<button
className="button ghost small"
onClick={() => pf.setDeleteConfirmId(null)}
>
취소
</button>
</>
) : (
<>
<button
className="button ghost small pf-btn-sell"
onClick={() => {
pf.setSellConfirmId(item.id);
pf.setDeleteConfirmId(null);
}}
title="매도"
>
매도
</button>
<button
className="button ghost small"
onClick={() => {
pf.setDeleteConfirmId(item.id);
pf.setSellConfirmId(null);
}}
title="삭제"
>
🗑
</button>
</>
)}
</div>
</>
)}
</div>
);
})}
</div>
</section>
);
})}
{pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && ( {pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
<section className="stock-panel stock-panel--wide"> <section className="stock-panel stock-panel--wide">

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { formatNumber, formatPercent, profitColorClass } from '../../stockUtils';
import HoldingRow from './HoldingRow';
const BrokerSection = ({ broker, items, pf, handleSell }) => {
const bSummary = pf.getBrokerSummary(items);
const color = pf.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.totalBuy)} · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '}
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
</span>
{(() => {
const bc = pf.cashList.find((c) => c.broker === broker);
return bc ? (
<span className="pf-cash-badge">
예수금 {formatNumber(bc.cash)}
</span>
) : null;
})()}
</p>
</div>
</div>
<div className="stock-holdings">
{items.map((item) => (
<HoldingRow key={item.id} item={item} pf={pf} handleSell={handleSell} />
))}
</div>
</section>
);
};
export default BrokerSection;

View File

@@ -0,0 +1,215 @@
import React from 'react';
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../../stockUtils';
import PriceSessionBadge from './PriceSessionBadge';
const HoldingRow = ({ item, pf, handleSell }) => {
const profitAmt = item.profit_amount;
const profitRate = item.profit_rate;
const profitAmtN = toNumeric(profitAmt);
const profitRateN = toNumeric(profitRate);
const isEditing = pf.editingId === item.id;
const isDeleting = pf.deleteConfirmId === item.id;
const isSelling = pf.sellConfirmId === item.id;
const sellPrice = item.current_price ?? item.avg_price;
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
return (
<div key={item.id} className="stock-holdings__item pf-item">
{isEditing ? (
<div className="pf-edit-row">
<div className="pf-edit-fields">
<label>
수량
<input
type="number"
min={1}
value={pf.editForm.quantity ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
quantity: Number(e.target.value),
}))
}
/>
</label>
<label>
평균단가
<input
type="number"
min={0}
value={pf.editForm.avg_price ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
avg_price: Number(e.target.value),
}))
}
/>
</label>
<label>
매입가
<input
type="number"
min={0}
value={pf.editForm.purchase_price ?? ''}
onChange={(e) =>
pf.setEditForm((p) => ({
...p,
purchase_price: Number(e.target.value),
}))
}
/>
</label>
</div>
<div className="pf-edit-actions">
<button
className="button primary small"
onClick={() => pf.handleEditSave(item.id)}
disabled={pf.editLoading}
>
{pf.editLoading ? '저장 중...' : '저장'}
</button>
<button
className="button ghost small"
onClick={() => pf.setEditingId(null)}
>
취소
</button>
</div>
</div>
) : (
<>
<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>{formatNumber(item.purchase_price ?? 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)
: '조회 실패'}
<PriceSessionBadge
session={item.price_session}
asOf={item.price_as_of}
/>
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가금액</span>
<strong>
{item.current_price != null && item.quantity != null
? formatNumber(item.current_price * item.quantity)
: '-'}
</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>
<div className="pf-item-actions">
{!isSelling && !isDeleting && (
<button
className="button ghost small"
onClick={() => pf.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={pf.sellLoading}
>
{pf.sellLoading ? '처리 중...' : '매도 확인'}
</button>
<button
className="button ghost small"
onClick={() => pf.setSellConfirmId(null)}
disabled={pf.sellLoading}
>
취소
</button>
</div>
) : isDeleting ? (
<>
<button
className="button ghost small pf-btn-danger"
onClick={() => pf.handleDelete(item.id)}
>
확인
</button>
<button
className="button ghost small"
onClick={() => pf.setDeleteConfirmId(null)}
>
취소
</button>
</>
) : (
<>
<button
className="button ghost small pf-btn-sell"
onClick={() => {
pf.setSellConfirmId(item.id);
pf.setDeleteConfirmId(null);
}}
title="매도"
>
매도
</button>
<button
className="button ghost small"
onClick={() => {
pf.setDeleteConfirmId(item.id);
pf.setSellConfirmId(null);
}}
title="삭제"
>
🗑
</button>
</>
)}
</div>
</>
)}
</div>
);
};
export default HoldingRow;