diff --git a/CLAUDE.md b/CLAUDE.md index a14923d..55b7f39 100644 --- a/CLAUDE.md +++ b/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// @@ -258,6 +258,24 @@ src/pages// 규칙: 파일당 단일 책임·권장 ≤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`) diff --git a/README.md b/README.md index b89ea4d..cd3de5f 100644 --- a/README.md +++ b/README.md @@ -227,4 +227,6 @@ apiPost('/api/travel/sync'); ### 기능 모듈 구조 -대형 기능 페이지는 `src/pages//` 아래 **shell(`.jsx`) + `components/` + `hooks/` + `Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`) +대형 기능 페이지는 `src/pages//` 아래 **shell(`.jsx`) + `components/` + `hooks/` + `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). diff --git a/docs/superpowers/plans/2026-07-10-fe-module-refactor-portfoliotab.md b/docs/superpowers/plans/2026-07-10-fe-module-refactor-portfoliotab.md new file mode 100644 index 0000000..c45112c --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-fe-module-refactor-portfoliotab.md @@ -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) + 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(); + expect(screen.getByText('NXT')).toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText('NXT 프리')).toBeInTheDocument(); + }); + it('그 외 세션 → 렌더 없음', () => { + const { container } = render(); + 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 ( + + {label} + + ); +}; + +export default PriceSessionBadge; +``` + +- [ ] **Step 4: Wire PortfolioTab.jsx** — `formatPriceTime`(9-14)와 `PriceSessionBadge`(16-27) 정의 삭제, import 추가: +```js +import PriceSessionBadge from './portfolio/PriceSessionBadge'; +``` +(PortfolioTab 내부 보유행에서 `` 사용은 그대로 — 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 (
...
); };` — `return` 내부의 `
...
`은 현 PortfolioTab.jsx:67-152의 `
` 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 (
...
); };` — 내부는 현 PortfolioTab.jsx:157-195의 `
...
` 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 }) => (
...
);` — 내부는 현 PortfolioTab.jsx:199-281의 `
...
` JSX verbatim(`asset.assetHistory*`/`asset.setAssetHistoryDays`/`asset.snapshotSaving`/`handleSaveSnapshot`/`pf.totalAssets` 참조 그대로). `export default AssetHistoryChart;`. + +> 각 파일은 위 import만(실제 사용 심볼). AddHoldingForm은 stockUtils/Loading 불필요(폼만). + +- [ ] **Step 2: Wire PortfolioTab.jsx** — 관리 `
` 내부에서 위 3개 인라인 블록을 컴포넌트 호출로 교체: +- `{pf.addFormOpen && (...)}` → `` +- `{pf.portfolioHoldings.length > 0 && (
...
)}` → `` +- `
...
` → `` +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 }) => (
...
);` — 내부는 현 PortfolioTab.jsx:285-409의 예수금 `
...
` 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** — 예수금 `
...
`(285-409) 인라인 블록을 ``로 교체. 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 (
...
); };` — 내부는 현 PortfolioTab.jsx:447-654의 `items.map((item) => { ... return (
...
); })` 콜백 본문 verbatim(상수 `profitAmt/profitRate/profitAmtN/profitRateN/isEditing/isDeleting/isSelling/sellPrice/saleAmount` 계산 + `
` 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 내부 최상위 `
`의 `key={item.id}`는 제거 가능(무해하나 React는 map 자식에만 key 필요) — **동작 보존 위해 그대로 두거나 제거 무관**; BrokerSection map에서 ``로 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 (
...헤더...
{items.map((item) => )}
); };` — 섹션 헤더(현 PortfolioTab.jsx:421-445) verbatim, `
` 내부의 `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]) => ( + +))} +``` +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에서 부여. diff --git a/docs/superpowers/specs/2026-07-10-fe-module-refactor-portfoliotab-design.md b/docs/superpowers/specs/2026-07-10-fe-module-refactor-portfoliotab-design.md new file mode 100644 index 0000000..b3b3bec --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-fe-module-refactor-portfoliotab-design.md @@ -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). diff --git a/src/pages/stock/components/PortfolioTab.jsx b/src/pages/stock/components/PortfolioTab.jsx index 2104fad..9665e68 100644 --- a/src/pages/stock/components/PortfolioTab.jsx +++ b/src/pages/stock/components/PortfolioTab.jsx @@ -1,30 +1,10 @@ import React from 'react'; import Loading from '../../../components/Loading'; -import { - ResponsiveContainer, AreaChart, Area, XAxis, YAxis, - Tooltip as ChartTooltip, -} from 'recharts'; -import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../stockUtils'; - -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 ( - - {label} - - ); -}; +import AddHoldingForm from './portfolio/AddHoldingForm'; +import PortfolioSummary from './portfolio/PortfolioSummary'; +import AssetHistoryChart from './portfolio/AssetHistoryChart'; +import CashPanel from './portfolio/CashPanel'; +import BrokerSection from './portfolio/BrokerSection'; const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => ( <> @@ -63,600 +43,22 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
{/* Add form */} - {pf.addFormOpen && ( -
- - - - - - - - {pf.addError &&

{pf.addError}

} -
- )} + {/* Portfolio total summary */} - {pf.portfolioHoldings.length > 0 && ( -
- {[ - { 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 ( -
- {s.label} - - {display} - -
- ); - })} - {pf.totalCash != null && (() => { - const display = `${formatNumber(pf.totalCash)}원`; - return ( -
- 예수금 합계 - {display} -
- ); - })()} - {pf.totalAssets != null && (() => { - const display = `${formatNumber(pf.totalAssets)}원`; - return ( -
- 총 자산 - {display} -
- ); - })()} -
- )} + {/* 자산 추이 차트 */} -
-
-

총 자산 추이

-
- {[ - { label: '7일', value: 7 }, - { label: '30일', value: 30 }, - { label: '90일', value: 90 }, - { label: '전체', value: 0 }, - ].map(({ label, value }) => ( - - ))} - -
-
- - {asset.assetHistoryLoading ? ( -
- -
- ) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? ( - - - - - - - - - v?.slice(5)} - tickLine={false} - axisLine={false} - interval="preserveStartEnd" - /> - - [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']} - /> - - - - ) : ( -
- 저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요. -
- )} -
+
{/* 예수금 패널 */} -
-
-
-

예수금 관리

-

증권사별 예수금

-

- 증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다. -

-
-
- - {pf.cashList.length > 0 && ( -
- {pf.cashList.map((item) => { - const isEditing = pf.cashEditingBroker === item.broker; - return ( -
- {item.broker} - {isEditing ? ( - pf.setCashEditingValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') pf.handleCashInlineSave(item.broker); - if (e.key === 'Escape') pf.handleCashInlineCancel(); - }} - autoFocus - /> - ) : ( - - {formatNumber(item.cash)}원 - - )} - - {item.updated_at - ? new Date(item.updated_at).toLocaleDateString('ko-KR') - : ''} - - {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
- ); - })} -
- )} - {pf.cashList.length === 0 && ( -

- 등록된 예수금이 없습니다. -

- )} - -
- - - - {pf.cashError &&

{pf.cashError}

} -
-
+ {/* Broker cards stacked */} - {pf.brokerGroups.map(([broker, items]) => { - const bSummary = pf.getBrokerSummary(items); - const color = pf.brokerColors[broker]; - return ( -
-
-
-

- {broker} -

-

{broker} 보유 현황

-

- {items.length}종목 · 총 매입{' '} - {formatNumber(bSummary.totalBuy)} · 평가{' '} - {formatNumber(bSummary.totalEval)} · 손익{' '} - - {formatNumber(bSummary.totalProfit)} ( - {formatPercent(bSummary.totalProfitRate)}) - - {(() => { - const bc = pf.cashList.find((c) => c.broker === broker); - return bc ? ( - - 예수금 {formatNumber(bc.cash)}원 - - ) : null; - })()} -

-
-
-
- {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 ( -
- {isEditing ? ( -
-
- - - -
-
- - -
-
- ) : ( - <> -
-

- {item.name ?? item.ticker ?? 'N/A'} -

- - {item.ticker ?? ''} - -
-
- 수량 - {formatNumber(item.quantity)} -
-
- 평균단가 - {formatNumber(item.avg_price)} -
-
- 매입가 - {formatNumber(item.purchase_price ?? item.avg_price)} -
-
- 현재가 - - {item.current_price != null - ? formatNumber(item.current_price) - : '조회 실패'} - - -
-
- 평가금액 - - {item.current_price != null && item.quantity != null - ? formatNumber(item.current_price * item.quantity) - : '-'} - -
-
- 수익률 - - {profitRate != null ? formatPercent(profitRate) : '-'} - -
-
- 평가손익 - - {profitAmt != null ? formatNumber(profitAmt) : '-'} - -
-
- {!isSelling && !isDeleting && ( - - )} - {isSelling ? ( -
- - {item.current_price == null && ( - 현재가 미조회 — 매입가 기준 - )} - {saleAmount != null - ? `${formatNumber(saleAmount)}원 매도 후 예수금 반영` - : '매도 처리'} - - - -
- ) : isDeleting ? ( - <> - - - - ) : ( - <> - - - - )} -
- - )} -
- ); - })} -
-
- ); - })} + {pf.brokerGroups.map(([broker, items]) => ( + + ))} {pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
diff --git a/src/pages/stock/components/portfolio/AddHoldingForm.jsx b/src/pages/stock/components/portfolio/AddHoldingForm.jsx new file mode 100644 index 0000000..c8dc18b --- /dev/null +++ b/src/pages/stock/components/portfolio/AddHoldingForm.jsx @@ -0,0 +1,95 @@ +import React from 'react'; + +const AddHoldingForm = ({ pf }) => { + if (!pf.addFormOpen) return null; + return ( +
+ + + + + + + + {pf.addError &&

{pf.addError}

} +
+ ); +}; + +export default AddHoldingForm; diff --git a/src/pages/stock/components/portfolio/AssetHistoryChart.jsx b/src/pages/stock/components/portfolio/AssetHistoryChart.jsx new file mode 100644 index 0000000..e3d49c9 --- /dev/null +++ b/src/pages/stock/components/portfolio/AssetHistoryChart.jsx @@ -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 }) => ( +
+
+

총 자산 추이

+
+ {[ + { label: '7일', value: 7 }, + { label: '30일', value: 30 }, + { label: '90일', value: 90 }, + { label: '전체', value: 0 }, + ].map(({ label, value }) => ( + + ))} + +
+
+ + {asset.assetHistoryLoading ? ( +
+ +
+ ) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? ( + + + + + + + + + v?.slice(5)} + tickLine={false} + axisLine={false} + interval="preserveStartEnd" + /> + + [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']} + /> + + + + ) : ( +
+ 저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요. +
+ )} +
+); + +export default AssetHistoryChart; diff --git a/src/pages/stock/components/portfolio/BrokerSection.jsx b/src/pages/stock/components/portfolio/BrokerSection.jsx new file mode 100644 index 0000000..68fa62b --- /dev/null +++ b/src/pages/stock/components/portfolio/BrokerSection.jsx @@ -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 ( +
+
+
+

+ {broker} +

+

{broker} 보유 현황

+

+ {items.length}종목 · 총 매입{' '} + {formatNumber(bSummary.totalBuy)} · 평가{' '} + {formatNumber(bSummary.totalEval)} · 손익{' '} + + {formatNumber(bSummary.totalProfit)} ( + {formatPercent(bSummary.totalProfitRate)}) + + {(() => { + const bc = pf.cashList.find((c) => c.broker === broker); + return bc ? ( + + 예수금 {formatNumber(bc.cash)}원 + + ) : null; + })()} +

+
+
+
+ {items.map((item) => ( + + ))} +
+
+ ); +}; + +export default BrokerSection; diff --git a/src/pages/stock/components/portfolio/CashPanel.jsx b/src/pages/stock/components/portfolio/CashPanel.jsx new file mode 100644 index 0000000..ee99375 --- /dev/null +++ b/src/pages/stock/components/portfolio/CashPanel.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { formatNumber } from '../../stockUtils'; + +const CashPanel = ({ pf }) => ( +
+
+
+

예수금 관리

+

증권사별 예수금

+

+ 증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다. +

+
+
+ + {pf.cashList.length > 0 && ( +
+ {pf.cashList.map((item) => { + const isEditing = pf.cashEditingBroker === item.broker; + return ( +
+ {item.broker} + {isEditing ? ( + pf.setCashEditingValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') pf.handleCashInlineSave(item.broker); + if (e.key === 'Escape') pf.handleCashInlineCancel(); + }} + autoFocus + /> + ) : ( + + {formatNumber(item.cash)}원 + + )} + + {item.updated_at + ? new Date(item.updated_at).toLocaleDateString('ko-KR') + : ''} + + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ); + })} +
+ )} + {pf.cashList.length === 0 && ( +

+ 등록된 예수금이 없습니다. +

+ )} + +
+ + + + {pf.cashError &&

{pf.cashError}

} +
+
+); + +export default CashPanel; diff --git a/src/pages/stock/components/portfolio/HoldingRow.jsx b/src/pages/stock/components/portfolio/HoldingRow.jsx new file mode 100644 index 0000000..40e8432 --- /dev/null +++ b/src/pages/stock/components/portfolio/HoldingRow.jsx @@ -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 ( +
+ {isEditing ? ( +
+
+ + + +
+
+ + +
+
+ ) : ( + <> +
+

+ {item.name ?? item.ticker ?? 'N/A'} +

+ + {item.ticker ?? ''} + +
+
+ 수량 + {formatNumber(item.quantity)} +
+
+ 평균단가 + {formatNumber(item.avg_price)} +
+
+ 매입가 + {formatNumber(item.purchase_price ?? item.avg_price)} +
+
+ 현재가 + + {item.current_price != null + ? formatNumber(item.current_price) + : '조회 실패'} + + +
+
+ 평가금액 + + {item.current_price != null && item.quantity != null + ? formatNumber(item.current_price * item.quantity) + : '-'} + +
+
+ 수익률 + + {profitRate != null ? formatPercent(profitRate) : '-'} + +
+
+ 평가손익 + + {profitAmt != null ? formatNumber(profitAmt) : '-'} + +
+
+ {!isSelling && !isDeleting && ( + + )} + {isSelling ? ( +
+ + {item.current_price == null && ( + 현재가 미조회 — 매입가 기준 + )} + {saleAmount != null + ? `${formatNumber(saleAmount)}원 매도 후 예수금 반영` + : '매도 처리'} + + + +
+ ) : isDeleting ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + )} +
+ ); +}; + +export default HoldingRow; diff --git a/src/pages/stock/components/portfolio/PortfolioSummary.jsx b/src/pages/stock/components/portfolio/PortfolioSummary.jsx new file mode 100644 index 0000000..4d883a4 --- /dev/null +++ b/src/pages/stock/components/portfolio/PortfolioSummary.jsx @@ -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 ( +
+ {[ + { 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 ( +
+ {s.label} + + {display} + +
+ ); + })} + {pf.totalCash != null && (() => { + const display = `${formatNumber(pf.totalCash)}원`; + return ( +
+ 예수금 합계 + {display} +
+ ); + })()} + {pf.totalAssets != null && (() => { + const display = `${formatNumber(pf.totalAssets)}원`; + return ( +
+ 총 자산 + {display} +
+ ); + })()} +
+ ); +}; + +export default PortfolioSummary; diff --git a/src/pages/stock/components/portfolio/PriceSessionBadge.jsx b/src/pages/stock/components/portfolio/PriceSessionBadge.jsx new file mode 100644 index 0000000..390fbd3 --- /dev/null +++ b/src/pages/stock/components/portfolio/PriceSessionBadge.jsx @@ -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 ( + + {label} + + ); +}; + +export default PriceSessionBadge; diff --git a/src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx b/src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx new file mode 100644 index 0000000..b31ca89 --- /dev/null +++ b/src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx @@ -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(); + expect(screen.getByText('NXT')).toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText('NXT 프리')).toBeInTheDocument(); + }); + it('그 외 세션 → 렌더 없음', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/pages/stock/components/portfolio/portfolioUtils.js b/src/pages/stock/components/portfolio/portfolioUtils.js new file mode 100644 index 0000000..414d01a --- /dev/null +++ b/src/pages/stock/components/portfolio/portfolioUtils.js @@ -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')}`; +}; diff --git a/src/pages/stock/components/portfolio/portfolioUtils.test.js b/src/pages/stock/components/portfolio/portfolioUtils.test.js new file mode 100644 index 0000000..f9ae276 --- /dev/null +++ b/src/pages/stock/components/portfolio/portfolioUtils.test.js @@ -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(''); + }); +});