Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
289 lines
18 KiB
Markdown
289 lines
18 KiB
Markdown
# PortfolioTab.jsx 분해 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** `PortfolioTab.jsx`(671줄)를 `stock/components/portfolio/` 하위 표현 컴포넌트 7개 + `portfolioUtils`로 **동작 보존 분해**한다.
|
||
|
||
**Architecture:** 인라인 JSX 블록을 컴포넌트로 리프트(가드는 컴포넌트 내부 early-return으로 이동, `pf`/`asset`/핸들러를 props로 그대로 전달). `formatPriceTime`→`portfolioUtils.js`. `PortfolioTab.jsx`는 조합 shell(~70줄)로 축소. 각 Task는 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
|
||
|
||
**Tech Stack:** React 18 + Vite, recharts, Vitest + @testing-library/react.
|
||
|
||
## Global Constraints
|
||
|
||
- **Behavior-preserving 순수 추출**: 리프트하는 JSX 본문은 verbatim(참조하는 `pf.*`/`asset.*`은 이제 props). 래핑 가드(`pf.addFormOpen &&`, `pf.portfolioHoldings.length > 0 &&` 등)는 컴포넌트 내부 `if (!cond) return null;`로 이동. `pf`(훅 객체 전체)를 각 하위에 그대로 전달 → prop shape·동작 불변. 스타일/클래스명 불변.
|
||
- 경로: `portfolio/*.jsx` → stockUtils `'../../stockUtils'`, Loading `'../../../../components/Loading'`, portfolioUtils `'./portfolioUtils'`; `HoldingRow` → PriceSessionBadge `'./PriceSessionBadge'`; `PortfolioTab.jsx` → 하위 `'./portfolio/*'` (기존 `'../stockUtils'`·`'../../../components/Loading'`는 shell이 쓰는 것만 유지).
|
||
- 각 파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 빌드가 누락 검출.
|
||
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
|
||
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js(x)` 동일 디렉토리.
|
||
- 커밋은 web-ui, 변경 파일만 `git add`. 커밋 trailer:
|
||
```
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: `portfolioUtils.js` + `PriceSessionBadge` 추출 + 테스트
|
||
|
||
**Files:**
|
||
- Create: `src/pages/stock/components/portfolio/portfolioUtils.js`
|
||
- Create: `src/pages/stock/components/portfolio/portfolioUtils.test.js`
|
||
- Create: `src/pages/stock/components/portfolio/PriceSessionBadge.jsx`
|
||
- Create: `src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx`
|
||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `formatPriceTime(iso)` (portfolioUtils); `PriceSessionBadge({ session, asOf })` (default export).
|
||
|
||
- [ ] **Step 1: Write failing tests** — Create `portfolioUtils.test.js`:
|
||
|
||
```js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { formatPriceTime } from './portfolioUtils.js';
|
||
|
||
describe('formatPriceTime', () => {
|
||
it('유효 ISO → HH:MM (제로패드)', () => {
|
||
expect(formatPriceTime('2026-07-10T09:05:00')).toBe('09:05');
|
||
expect(formatPriceTime('2026-07-10T18:30:00')).toBe('18:30');
|
||
});
|
||
it('falsy/invalid → 빈 문자열', () => {
|
||
expect(formatPriceTime('')).toBe('');
|
||
expect(formatPriceTime(null)).toBe('');
|
||
expect(formatPriceTime('not-a-date')).toBe('');
|
||
});
|
||
});
|
||
```
|
||
Create `PriceSessionBadge.test.jsx`:
|
||
```jsx
|
||
import { describe, it, expect } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import PriceSessionBadge from './PriceSessionBadge.jsx';
|
||
|
||
describe('PriceSessionBadge', () => {
|
||
it('NXT_AFTER → "NXT", NXT_PRE → "NXT 프리"', () => {
|
||
const { unmount } = render(<PriceSessionBadge session="NXT_AFTER" />);
|
||
expect(screen.getByText('NXT')).toBeInTheDocument();
|
||
unmount();
|
||
render(<PriceSessionBadge session="NXT_PRE" />);
|
||
expect(screen.getByText('NXT 프리')).toBeInTheDocument();
|
||
});
|
||
it('그 외 세션 → 렌더 없음', () => {
|
||
const { container } = render(<PriceSessionBadge session="REGULAR" />);
|
||
expect(container).toBeEmptyDOMElement();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify fail**
|
||
|
||
Run: `npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx`
|
||
Expected: FAIL — unresolved imports.
|
||
|
||
- [ ] **Step 3: Create files** — `portfolioUtils.js` (formatPriceTime을 현 PortfolioTab.jsx:9-14에서 verbatim):
|
||
```js
|
||
export const formatPriceTime = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
};
|
||
```
|
||
`PriceSessionBadge.jsx` (현 PortfolioTab.jsx:16-27 본문 verbatim + import/export):
|
||
```jsx
|
||
import React from 'react';
|
||
import { formatPriceTime } from './portfolioUtils';
|
||
|
||
const PriceSessionBadge = ({ session, asOf }) => {
|
||
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
|
||
const isPre = session === 'NXT_PRE';
|
||
const label = isPre ? 'NXT 프리' : 'NXT';
|
||
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
|
||
const time = formatPriceTime(asOf);
|
||
return (
|
||
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
|
||
{label}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
export default PriceSessionBadge;
|
||
```
|
||
|
||
- [ ] **Step 4: Wire PortfolioTab.jsx** — `formatPriceTime`(9-14)와 `PriceSessionBadge`(16-27) 정의 삭제, import 추가:
|
||
```js
|
||
import PriceSessionBadge from './portfolio/PriceSessionBadge';
|
||
```
|
||
(PortfolioTab 내부 보유행에서 `<PriceSessionBadge .../>` 사용은 그대로 — import한 것을 씀.)
|
||
|
||
- [ ] **Step 5: Run tests + build**
|
||
|
||
Run: `npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx` → PASS.
|
||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규/수정 0 에러.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
```bash
|
||
git add src/pages/stock/components/portfolio/portfolioUtils.js src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.jsx src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx src/pages/stock/components/PortfolioTab.jsx
|
||
git commit -m "refactor(portfolio): formatPriceTime util + PriceSessionBadge 추출 + 테스트"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 관리 section 3 컴포넌트 추출 (AddHoldingForm / PortfolioSummary / AssetHistoryChart)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/stock/components/portfolio/AddHoldingForm.jsx`
|
||
- Create: `src/pages/stock/components/portfolio/PortfolioSummary.jsx`
|
||
- Create: `src/pages/stock/components/portfolio/AssetHistoryChart.jsx`
|
||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `AddHoldingForm({ pf })`, `PortfolioSummary({ pf })`, `AssetHistoryChart({ asset, pf, handleSaveSnapshot })` (default exports).
|
||
|
||
- [ ] **Step 1: Create the three components (JSX 본문 verbatim, 가드는 내부 early-return)**
|
||
|
||
`AddHoldingForm.jsx`:
|
||
```jsx
|
||
import React from 'react';
|
||
```
|
||
컴포넌트: `const AddHoldingForm = ({ pf }) => { if (!pf.addFormOpen) return null; return ( <form className="pf-add-form" ... > ... </form> ); };` — `return` 내부의 `<form>...</form>`은 현 PortfolioTab.jsx:67-152의 `<form>` JSX를 verbatim(내부의 `pf.addForm`/`pf.setAddForm`/`pf.handleAddSubmit`/`pf.addLoading`/`pf.addError` 참조 그대로). `export default AddHoldingForm;`.
|
||
|
||
`PortfolioSummary.jsx`:
|
||
```jsx
|
||
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"> ... </div> ); };` — 내부는 현 PortfolioTab.jsx:157-195의 `<div className="pf-total-summary">...</div>` JSX verbatim(`pf.portfolioSummary`/`pf.totalCash`/`pf.totalAssets` 참조 그대로). `export default PortfolioSummary;`.
|
||
|
||
`AssetHistoryChart.jsx`:
|
||
```jsx
|
||
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> );` — 내부는 현 PortfolioTab.jsx:199-281의 `<div className="pf-asset-history">...</div>` JSX verbatim(`asset.assetHistory*`/`asset.setAssetHistoryDays`/`asset.snapshotSaving`/`handleSaveSnapshot`/`pf.totalAssets` 참조 그대로). `export default AssetHistoryChart;`.
|
||
|
||
> 각 파일은 위 import만(실제 사용 심볼). AddHoldingForm은 stockUtils/Loading 불필요(폼만).
|
||
|
||
- [ ] **Step 2: Wire PortfolioTab.jsx** — 관리 `<section>` 내부에서 위 3개 인라인 블록을 컴포넌트 호출로 교체:
|
||
- `{pf.addFormOpen && (<form ...>...</form>)}` → `<AddHoldingForm pf={pf} />`
|
||
- `{pf.portfolioHoldings.length > 0 && (<div className="pf-total-summary">...</div>)}` → `<PortfolioSummary pf={pf} />`
|
||
- `<div className="pf-asset-history">...</div>` → `<AssetHistoryChart asset={asset} pf={pf} handleSaveSnapshot={handleSaveSnapshot} />`
|
||
import 추가:
|
||
```js
|
||
import AddHoldingForm from './portfolio/AddHoldingForm';
|
||
import PortfolioSummary from './portfolio/PortfolioSummary';
|
||
import AssetHistoryChart from './portfolio/AssetHistoryChart';
|
||
```
|
||
PortfolioTab.jsx 상단의 이제-미사용 import 정리: recharts(`ResponsiveContainer/AreaChart/Area/XAxis/YAxis/Tooltip`)가 AssetHistoryChart로 이동했으니 shell에서 제거. `Loading`은 shell 헤더가 계속 사용(`pf.portfolioLoading`) → 유지. stockUtils 심볼 중 shell(브로커 섹션 등 아직 인라인)이 계속 쓰는 것은 유지. (미사용 import 0 되도록 — 빌드/lint 확인.)
|
||
|
||
- [ ] **Step 3: Run tests + build + lint**
|
||
|
||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 3파일 + PortfolioTab.jsx 0 에러.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add src/pages/stock/components/portfolio/AddHoldingForm.jsx src/pages/stock/components/portfolio/PortfolioSummary.jsx src/pages/stock/components/portfolio/AssetHistoryChart.jsx src/pages/stock/components/PortfolioTab.jsx
|
||
git commit -m "refactor(portfolio): 추가폼/총자산요약/자산추이차트 컴포넌트 추출"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `CashPanel` 추출 (예수금 패널)
|
||
|
||
**Files:**
|
||
- Create: `src/pages/stock/components/portfolio/CashPanel.jsx`
|
||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `CashPanel({ pf })` (default export).
|
||
|
||
- [ ] **Step 1: Create CashPanel.jsx**
|
||
```jsx
|
||
import React from 'react';
|
||
import { formatNumber } from '../../stockUtils';
|
||
```
|
||
컴포넌트: `const CashPanel = ({ pf }) => ( <section className="stock-panel stock-panel--wide"> ... </section> );` — 내부는 현 PortfolioTab.jsx:285-409의 예수금 `<section>...</section>` JSX verbatim(`pf.cashList`/`pf.cashEditingBroker`/`pf.cashEditingValue`/`pf.setCashEditingValue`/`pf.handleCashInlineSave`/`pf.handleCashInlineCancel`/`pf.handleCashInlineEdit`/`pf.handleCashDelete`/`pf.cashEditSaving`/`pf.cashForm`/`pf.setCashForm`/`pf.handleCashSave`/`pf.cashSaving`/`pf.cashError` 참조 그대로). `export default CashPanel;`.
|
||
|
||
- [ ] **Step 2: Wire PortfolioTab.jsx** — 예수금 `<section>...</section>`(285-409) 인라인 블록을 `<CashPanel pf={pf} />`로 교체. import 추가:
|
||
```js
|
||
import CashPanel from './portfolio/CashPanel';
|
||
```
|
||
(shell이 formatNumber를 예수금 외 다른 곳에서 안 쓰게 되면 정리 — 단 브로커 섹션이 아직 인라인이면 formatNumber 유지. 빌드/lint로 확인.)
|
||
|
||
- [ ] **Step 3: Run tests + build + lint**
|
||
|
||
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 파일 + PortfolioTab.jsx 0 에러.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
```bash
|
||
git add src/pages/stock/components/portfolio/CashPanel.jsx src/pages/stock/components/PortfolioTab.jsx
|
||
git commit -m "refactor(portfolio): 예수금 패널 CashPanel 컴포넌트 추출"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `HoldingRow` + `BrokerSection` 추출 → PortfolioTab shell 축소 + 검증
|
||
|
||
**Files:**
|
||
- Create: `src/pages/stock/components/portfolio/HoldingRow.jsx`
|
||
- Create: `src/pages/stock/components/portfolio/BrokerSection.jsx`
|
||
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PriceSessionBadge`(Task 1).
|
||
- Produces: `HoldingRow({ item, pf, handleSell })`, `BrokerSection({ broker, items, pf, handleSell })` (default exports).
|
||
|
||
- [ ] **Step 1: Create HoldingRow.jsx**
|
||
```jsx
|
||
import React from 'react';
|
||
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../../stockUtils';
|
||
import PriceSessionBadge from './PriceSessionBadge';
|
||
```
|
||
컴포넌트: `const HoldingRow = ({ item, pf, handleSell }) => { ...상수 계산... return ( <div key={item.id} className="stock-holdings__item pf-item"> ... </div> ); };` — 내부는 현 PortfolioTab.jsx:447-654의 `items.map((item) => { ... return (<div ...>...</div>); })` 콜백 본문 verbatim(상수 `profitAmt/profitRate/profitAmtN/profitRateN/isEditing/isDeleting/isSelling/sellPrice/saleAmount` 계산 + `<div>` JSX; `pf.editingId`/`pf.editForm`/`pf.setEditForm`/`pf.handleEditSave`/`pf.editLoading`/`pf.setEditingId`/`pf.handleEditStart`/`pf.sellConfirmId`/`pf.setSellConfirmId`/`pf.sellLoading`/`pf.deleteConfirmId`/`pf.setDeleteConfirmId`/`pf.handleDelete`/`handleSell` 참조 그대로). map 콜백의 `return`을 컴포넌트 `return`으로. `key`는 BrokerSection의 map에서 부여하므로 HoldingRow 내부 최상위 `<div>`의 `key={item.id}`는 제거 가능(무해하나 React는 map 자식에만 key 필요) — **동작 보존 위해 그대로 두거나 제거 무관**; BrokerSection map에서 `<HoldingRow key={item.id} .../>`로 key 부여. `export default HoldingRow;`.
|
||
|
||
- [ ] **Step 2: Create BrokerSection.jsx**
|
||
```jsx
|
||
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={...}> ...헤더... <div className="stock-holdings"> {items.map((item) => <HoldingRow key={item.id} item={item} pf={pf} handleSell={handleSell} />)} </div> </section> ); };` — 섹션 헤더(현 PortfolioTab.jsx:421-445) verbatim, `<div className="stock-holdings">` 내부의 `items.map`을 `HoldingRow` 호출로 교체. `key={broker}`는 PortfolioTab의 map에서 부여하므로 내부 유지/제거 무관. `export default BrokerSection;`.
|
||
|
||
- [ ] **Step 3: Reduce PortfolioTab.jsx to shell** — 브로커 섹션 인라인 블록(412-659) `{pf.brokerGroups.map(([broker, items]) => { ... })}`을 다음으로 교체:
|
||
```jsx
|
||
{pf.brokerGroups.map(([broker, items]) => (
|
||
<BrokerSection key={broker} broker={broker} items={items} pf={pf} handleSell={handleSell} />
|
||
))}
|
||
```
|
||
import 추가: `import BrokerSection from './portfolio/BrokerSection';`. (HoldingRow는 BrokerSection이 import.) PortfolioTab.jsx 상단 import 최종 정리: shell이 실제 쓰는 것만 — `React`, `Loading`(관리 section 헤더 spinner), 6개 하위 컴포넌트(PriceSessionBadge는 이제 HoldingRow가 쓰므로 shell import 제거), stockUtils 심볼 중 shell이 직접 쓰는 것(브로커 섹션 이동 후 shell엔 거의 없음 → 미사용 제거). `PriceSessionBadge` import를 shell에서 제거(HoldingRow로 이동). 미사용 import 0 되도록 build/lint 확인. 결과 PortfolioTab.jsx ~70줄 shell.
|
||
|
||
- [ ] **Step 4: Run full suite + lint + build**
|
||
|
||
Run: `npm run test:run` → 전체 green(회귀 0). `npm run lint` → 0 새 에러. `npm run build` → `✓ built`. `wc -l src/pages/stock/components/PortfolioTab.jsx` ~70줄 확인.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
```bash
|
||
git add src/pages/stock/components/portfolio/HoldingRow.jsx src/pages/stock/components/portfolio/BrokerSection.jsx src/pages/stock/components/PortfolioTab.jsx
|
||
git commit -m "refactor(portfolio): 보유행/브로커섹션 추출 → PortfolioTab shell 축소"
|
||
```
|
||
|
||
- [ ] **Step 6: Manual verification (dev server)**
|
||
|
||
`npm run dev` → `http://localhost:3007/stock/trade` 포트폴리오 탭. 종목 추가 폼, 총자산 요약, 자산 추이 차트(기간·스냅샷), 예수금(인라인 편집·삭제·추가), 브로커 섹션(요약·예수금 뱃지), 보유행(현재가·NXT 뱃지·편집·매도확인·삭제확인)이 리팩토링 전과 **동일 동작**인지 확인.
|
||
|
||
---
|
||
|
||
## Self-Review 결과
|
||
|
||
**Spec coverage** (설계 §1–§7):
|
||
- §3 file map(portfolioUtils/7컴포넌트/shell) → Task 1/2/3/4 ✅
|
||
- §4 컴포넌트별 책임 → 각 Task의 props·JSX 범위 매핑 ✅
|
||
- §5 테스트(portfolioUtils + PriceSessionBadge 스모크 + build/lint/수동) → Task 1 + 각 Task 검증 + Task 4 수동 ✅
|
||
- §6 완료기준 → 각 Task 검증 ✅
|
||
|
||
**Placeholder scan:** 신규 코드(portfolioUtils/PriceSessionBadge/테스트) 전체 명시. 리프트 컴포넌트는 "현 인라인 JSX(line 범위) verbatim + 가드 early-return + 명시된 import/props"로 지정(플레이스홀더 아님 — 원본 JSX 존재, 참조 심볼·props 명시). ✅
|
||
|
||
**Type consistency:** portfolioUtils/PriceSessionBadge(Task1) ↔ HoldingRow import 일치. 각 컴포넌트 props(`pf`/`asset`/`handleSell`/`handleSaveSnapshot`/`item`/`broker`/`items`) ↔ shell/BrokerSection 전달 일치. 경로(portfolio/* → `../../stockUtils`·`../../../../components/Loading`) 일치. ✅
|
||
|
||
**참고:** Task 순서 의존(PriceSessionBadge→…→HoldingRow/BrokerSection). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. 리프트 시 가드→early-return, key는 부모 map에서 부여.
|