merge: PortfolioTab 모듈 분해 리팩토링 (671→74줄 shell + portfolio/ 8모듈)
This commit is contained in:
20
CLAUDE.md
20
CLAUDE.md
@@ -245,7 +245,7 @@ npm run preview # 빌드 결과물 미리보기
|
|||||||
|
|
||||||
## 기능 모듈 구조 (컨벤션)
|
## 기능 모듈 구조 (컨벤션)
|
||||||
|
|
||||||
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`).
|
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`, `src/pages/realestate/`).
|
||||||
|
|
||||||
```
|
```
|
||||||
src/pages/<feature>/
|
src/pages/<feature>/
|
||||||
@@ -258,6 +258,24 @@ src/pages/<feature>/
|
|||||||
|
|
||||||
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
|
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
|
||||||
|
|
||||||
|
**중첩 적용** — 기존 기능의 대형 하위 컴포넌트도 같은 규칙으로 in-place 분해한다. 예: `src/pages/stock/components/PortfolioTab.jsx`(671줄)는 74줄 shell + `stock/components/portfolio/` 하위 모듈로 분해됨:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/stock/components/
|
||||||
|
├── PortfolioTab.jsx # shell(74줄): 에러 + 관리 section 조합 + CashPanel + brokerGroups.map(BrokerSection) + 빈 상태
|
||||||
|
└── portfolio/
|
||||||
|
├── portfolioUtils.js # formatPriceTime (순수) + portfolioUtils.test.js
|
||||||
|
├── PriceSessionBadge.jsx # NXT 세션 뱃지 + PriceSessionBadge.test.jsx
|
||||||
|
├── AddHoldingForm.jsx # ({ pf }) 종목 추가 폼 (addFormOpen 가드)
|
||||||
|
├── PortfolioSummary.jsx # ({ pf }) 총매입/평가/손익 요약 카드
|
||||||
|
├── AssetHistoryChart.jsx # ({ asset, pf, handleSaveSnapshot }) recharts 자산 추이
|
||||||
|
├── CashPanel.jsx # ({ pf }) 예수금 테이블(인라인 편집/삭제) + 추가 폼
|
||||||
|
├── BrokerSection.jsx # ({ broker, items, pf, handleSell }) 브로커 섹션 + HoldingRow 반복
|
||||||
|
└── HoldingRow.jsx # ({ item, pf, handleSell }) 보유 1행 (view/편집/매도확인/삭제확인 4상태)
|
||||||
|
```
|
||||||
|
|
||||||
|
`pf`(usePortfolio 훅 객체 전체)를 props로 스레딩하는 behavior-preserving 순수 추출. 각 하위는 실제 참조하는 `pf.*`/`asset.*` 심볼만 사용.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
||||||
|
|||||||
@@ -227,4 +227,6 @@ apiPost('/api/travel/sync');
|
|||||||
|
|
||||||
### 기능 모듈 구조
|
### 기능 모듈 구조
|
||||||
|
|
||||||
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`)
|
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`, `realestate`)
|
||||||
|
|
||||||
|
기존 기능의 대형 하위 컴포넌트도 같은 규칙으로 in-place 분해합니다. 예: `stock/components/PortfolioTab.jsx`(671줄) → 74줄 shell + `stock/components/portfolio/`(AddHoldingForm·PortfolioSummary·AssetHistoryChart·CashPanel·BrokerSection·HoldingRow·PriceSessionBadge + portfolioUtils).
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# 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에서 부여.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# PortfolioTab.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 3)
|
||||||
|
|
||||||
|
- **작성일**: 2026-07-10
|
||||||
|
- **역할/저장소**: FE (`web-ui`)
|
||||||
|
- **범위**: FE 대형 컴포넌트 분해 sweep의 **3번째 서브프로젝트**. 컨벤션은 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 적용만.
|
||||||
|
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목표
|
||||||
|
|
||||||
|
`src/pages/stock/components/PortfolioTab.jsx`(671줄)는 StockTrade의 포트폴리오 탭 컴포넌트로, **로컬 상태 없이 `pf`/`asset` 훅을 props로 받는 단일 대형 표현 컴포넌트**다. 논리 블록(추가폼/총자산요약/자산추이차트/예수금패널/브로커섹션/보유행/NXT뱃지)을 `stock/components/portfolio/` 하위 표현 컴포넌트로 동작 보존 분해한다.
|
||||||
|
|
||||||
|
비목표: 동작/스타일 변경, prop shape 정제(개별 prop으로 좁히기는 후속), HoldingRow 내부 상태 추가분할.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현 구조 맵 (PortfolioTab.jsx 671줄)
|
||||||
|
|
||||||
|
- `formatPriceTime`(순수, 9-14), `PriceSessionBadge`(16-27) — NXT 뱃지
|
||||||
|
- `PortfolioTab({ pf, asset, handleSell, handleSaveSnapshot })`:
|
||||||
|
- 에러(31-33)
|
||||||
|
- 관리 section(36-282): 헤더(refresh/추가 토글) + **추가 폼**(66-153) + **총자산 요약 카드**(155-196) + **자산 추이 차트**(198-281)
|
||||||
|
- **예수금 패널**(285-409): 예수금 테이블(inline edit/delete) + 예수금 추가 폼
|
||||||
|
- **브로커 섹션 반복**(412-659): `pf.brokerGroups.map` → 브로커별 section + `items.map` → **보유 행**(447-654, view/edit/매도확인/삭제확인 4상태)
|
||||||
|
- 빈 상태(661-667)
|
||||||
|
|
||||||
|
의존: `Loading`(src/components), recharts(AreaChart 등), `stockUtils`(formatNumber/formatPercent/toNumeric/profitColorClass/numFitClass).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 목표 file map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/stock/components/
|
||||||
|
├── PortfolioTab.jsx # shell (~70줄): 에러 + 관리 section(헤더 inline + AddHoldingForm + PortfolioSummary + AssetHistoryChart) + CashPanel + brokerGroups.map(BrokerSection) + 빈상태
|
||||||
|
└── portfolio/
|
||||||
|
├── portfolioUtils.js # formatPriceTime (순수)
|
||||||
|
├── portfolioUtils.test.js # (신규)
|
||||||
|
├── PriceSessionBadge.jsx # ({ session, asOf })
|
||||||
|
├── AddHoldingForm.jsx # ({ pf })
|
||||||
|
├── PortfolioSummary.jsx # ({ pf })
|
||||||
|
├── AssetHistoryChart.jsx # ({ asset, pf, handleSaveSnapshot })
|
||||||
|
├── CashPanel.jsx # ({ pf })
|
||||||
|
├── BrokerSection.jsx # ({ broker, items, pf, handleSell }) — HoldingRow 반복
|
||||||
|
└── HoldingRow.jsx # ({ item, pf, handleSell })
|
||||||
|
```
|
||||||
|
|
||||||
|
**props 스레딩(behavior-preserving):** 각 하위에 `pf`(훅 객체 전체)/`asset`/핸들러를 그대로 전달. 컴포넌트 본문은 현 `pf.xxx`/`asset.xxx` 참조를 그대로 사용 → prop shape·동작 불변.
|
||||||
|
|
||||||
|
**import 경로 규칙:**
|
||||||
|
- `portfolio/*.jsx` → stockUtils `'../../stockUtils'`; Loading `'../../../../components/Loading'`; portfolioUtils `'./portfolioUtils'`.
|
||||||
|
- `HoldingRow.jsx` → `PriceSessionBadge` `'./PriceSessionBadge'`.
|
||||||
|
- `PortfolioTab.jsx`(stock/components/) → 하위 `'./portfolio/*'`. (기존 Loading `'../../../components/Loading'`, stockUtils `'../stockUtils'`는 shell이 계속 쓰는 것만 유지.)
|
||||||
|
|
||||||
|
각 컴포넌트가 실제 사용하는 심볼만 import(빌드/lint가 누락·미사용 검출).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 컴포넌트별 책임 (분해 후 검증 관점)
|
||||||
|
|
||||||
|
- `PriceSessionBadge`(~20줄): session이 NXT_AFTER/NXT_PRE일 때만 뱃지. `formatPriceTime`(utils) 사용.
|
||||||
|
- `AddHoldingForm`(~90줄): `pf.addFormOpen`일 때 폼. `pf.addForm`/`pf.setAddForm`/`pf.handleAddSubmit`/`pf.addLoading`/`pf.addError`.
|
||||||
|
- `PortfolioSummary`(~45줄): `pf.portfolioHoldings.length>0`일 때 총매입/평가/손익/수익률 + 예수금/총자산 카드.
|
||||||
|
- `AssetHistoryChart`(~85줄): `asset.assetHistory*` + `asset.setAssetHistoryDays` + `handleSaveSnapshot` + `pf.totalAssets`. recharts AreaChart.
|
||||||
|
- `CashPanel`(~120줄): `pf.cashList` 테이블(inline edit: `pf.cashEditing*`/`pf.handleCashInline*`, delete: `pf.handleCashDelete`) + 예수금 추가 폼(`pf.cashForm`/`pf.handleCashSave`/…).
|
||||||
|
- `BrokerSection`(~55줄): `pf.getBrokerSummary`/`pf.brokerColors`/`pf.cashList` + `items.map(HoldingRow)`.
|
||||||
|
- `HoldingRow`(~200줄): 개별 종목. view/edit(`pf.editingId`/`pf.editForm`/`pf.handleEditSave`…)/매도확인(`pf.sellConfirmId`/`handleSell`/`pf.sellLoading`)/삭제확인(`pf.deleteConfirmId`/`pf.handleDelete`). `PriceSessionBadge` 사용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 테스트 (안전망 — 현재 PortfolioTab 테스트 0)
|
||||||
|
|
||||||
|
1. **`portfolioUtils.test.js`(신규, 필수)** — `formatPriceTime`: 유효 ISO → "HH:MM"(제로패드), falsy/invalid → "". (현 구현 동작 고정.)
|
||||||
|
2. **`PriceSessionBadge.test.jsx`(신규, 스모크)** — session='NXT_AFTER'→"NXT" 렌더, 'NXT_PRE'→"NXT 프리" 렌더, 그 외(예: undefined/'REGULAR')→렌더 없음(null).
|
||||||
|
3. 나머지 표현 컴포넌트는 props 스레딩 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동).
|
||||||
|
4. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러. 최종 개발서버 `/stock/trade` 포트폴리오 탭에서 추가/요약/차트/예수금(인라인 편집·삭제)/브로커/보유행(편집·매도확인·삭제확인)이 리팩토링 전과 동일 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 완료 기준
|
||||||
|
|
||||||
|
- [ ] PortfolioTab.jsx가 shell(~70줄) + portfolio/ 7컴포넌트 + portfolioUtils(+test)로 분해, 동작/스타일 불변.
|
||||||
|
- [ ] `portfolioUtils.test.js`·`PriceSessionBadge.test.jsx` 통과, 전체 `npm run test:run` green.
|
||||||
|
- [ ] `npm run lint`·`npm run build` 통과.
|
||||||
|
- [ ] `/stock/trade` 포트폴리오 탭 수동 검증 동일 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리스크 / 오픈 이슈
|
||||||
|
|
||||||
|
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/스모크/수동으로 위험 최소화.
|
||||||
|
- **prop drilling**: `pf` 전체 전달은 behavior-preserving 우선(정제는 후속). 각 컴포넌트가 쓰는 `pf.*`만 실제 참조.
|
||||||
|
- **HoldingRow ~200줄**: 단일 책임(보유 1행). 내부 edit/매도/삭제 분할은 후속 follow-up.
|
||||||
|
- **다음 서브프로젝트**: InstaCards(1013) → MusicStudio(1936).
|
||||||
@@ -1,30 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Loading from '../../../components/Loading';
|
import Loading from '../../../components/Loading';
|
||||||
import {
|
import AddHoldingForm from './portfolio/AddHoldingForm';
|
||||||
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
import PortfolioSummary from './portfolio/PortfolioSummary';
|
||||||
Tooltip as ChartTooltip,
|
import AssetHistoryChart from './portfolio/AssetHistoryChart';
|
||||||
} from 'recharts';
|
import CashPanel from './portfolio/CashPanel';
|
||||||
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../stockUtils';
|
import BrokerSection from './portfolio/BrokerSection';
|
||||||
|
|
||||||
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')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||||
<>
|
<>
|
||||||
@@ -63,600 +43,22 @@ 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>
|
||||||
|
|
||||||
{/* 예수금 패널 */}
|
{/* 예수금 패널 */}
|
||||||
<section className="stock-panel stock-panel--wide">
|
<CashPanel pf={pf} />
|
||||||
<div className="stock-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="stock-panel__eyebrow">예수금 관리</p>
|
|
||||||
<h3>증권사별 예수금</h3>
|
|
||||||
<p className="stock-panel__sub">
|
|
||||||
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pf.cashList.length > 0 && (
|
|
||||||
<div className="pf-cash-table">
|
|
||||||
{pf.cashList.map((item) => {
|
|
||||||
const isEditing = pf.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={pf.cashEditingValue}
|
|
||||||
onChange={(e) => pf.setCashEditingValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') pf.handleCashInlineSave(item.broker);
|
|
||||||
if (e.key === 'Escape') pf.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={() => pf.handleCashInlineSave(item.broker)}
|
|
||||||
disabled={pf.cashEditSaving}
|
|
||||||
>
|
|
||||||
{pf.cashEditSaving ? '저장 중' : '저장'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button ghost small"
|
|
||||||
onClick={pf.handleCashInlineCancel}
|
|
||||||
disabled={pf.cashEditSaving}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="button ghost small"
|
|
||||||
onClick={() => pf.handleCashInlineEdit(item)}
|
|
||||||
title="수정"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button ghost small pf-btn-danger"
|
|
||||||
onClick={() => pf.handleCashDelete(item.broker)}
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{pf.cashList.length === 0 && (
|
|
||||||
<p className="stock-empty" style={{ fontSize: 13 }}>
|
|
||||||
등록된 예수금이 없습니다.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form className="pf-cash-form" onSubmit={pf.handleCashSave}>
|
|
||||||
<label>
|
|
||||||
증권사명
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={pf.cashForm.broker}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setCashForm((p) => ({ ...p, broker: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="KB증권"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
예수금 (원)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={pf.cashForm.cash}
|
|
||||||
onChange={(e) =>
|
|
||||||
pf.setCashForm((p) => ({ ...p, cash: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="1500000"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="button primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={pf.cashSaving}
|
|
||||||
>
|
|
||||||
{pf.cashSaving ? '저장 중...' : '저장'}
|
|
||||||
</button>
|
|
||||||
{pf.cashError && <p className="stock-error">{pf.cashError}</p>}
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 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">
|
||||||
|
|||||||
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;
|
||||||
48
src/pages/stock/components/portfolio/BrokerSection.jsx
Normal file
48
src/pages/stock/components/portfolio/BrokerSection.jsx
Normal 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;
|
||||||
132
src/pages/stock/components/portfolio/CashPanel.jsx
Normal file
132
src/pages/stock/components/portfolio/CashPanel.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatNumber } from '../../stockUtils';
|
||||||
|
|
||||||
|
const CashPanel = ({ pf }) => (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">예수금 관리</p>
|
||||||
|
<h3>증권사별 예수금</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pf.cashList.length > 0 && (
|
||||||
|
<div className="pf-cash-table">
|
||||||
|
{pf.cashList.map((item) => {
|
||||||
|
const isEditing = pf.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={pf.cashEditingValue}
|
||||||
|
onChange={(e) => pf.setCashEditingValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') pf.handleCashInlineSave(item.broker);
|
||||||
|
if (e.key === 'Escape') pf.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={() => pf.handleCashInlineSave(item.broker)}
|
||||||
|
disabled={pf.cashEditSaving}
|
||||||
|
>
|
||||||
|
{pf.cashEditSaving ? '저장 중' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={pf.handleCashInlineCancel}
|
||||||
|
disabled={pf.cashEditSaving}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.handleCashInlineEdit(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => pf.handleCashDelete(item.broker)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.cashList.length === 0 && (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||||
|
등록된 예수금이 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="pf-cash-form" onSubmit={pf.handleCashSave}>
|
||||||
|
<label>
|
||||||
|
증권사명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.cashForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
예수금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.cashForm.cash}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="1500000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={pf.cashSaving}
|
||||||
|
>
|
||||||
|
{pf.cashSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
{pf.cashError && <p className="stock-error">{pf.cashError}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CashPanel;
|
||||||
215
src/pages/stock/components/portfolio/HoldingRow.jsx
Normal file
215
src/pages/stock/components/portfolio/HoldingRow.jsx
Normal 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;
|
||||||
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;
|
||||||
17
src/pages/stock/components/portfolio/PriceSessionBadge.jsx
Normal file
17
src/pages/stock/components/portfolio/PriceSessionBadge.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
6
src/pages/stock/components/portfolio/portfolioUtils.js
Normal file
6
src/pages/stock/components/portfolio/portfolioUtils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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')}`;
|
||||||
|
};
|
||||||
14
src/pages/stock/components/portfolio/portfolioUtils.test.js
Normal file
14
src/pages/stock/components/portfolio/portfolioUtils.test.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user