Compare commits
34 Commits
970c8164e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f688229c95 | |||
| 9de96f5851 | |||
| d018b9eedf | |||
| 5064dc2b93 | |||
| ca5adbf4a4 | |||
| 6f98c0a84d | |||
| 81c9a5d692 | |||
| afc28c64cf | |||
| c1da59574f | |||
| 5ecda580f7 | |||
| bf834dae7f | |||
| e578c41485 | |||
| 96de7e714d | |||
| 795f1d9bab | |||
| 7f29931f93 | |||
| 6bcf4c96e4 | |||
| 4f5d7796c1 | |||
| 5eaca69f78 | |||
| d2dae3d042 | |||
| d82550610a | |||
| 15cb30311e | |||
| 1b4fba3c19 | |||
| f7c7394a94 | |||
| 690964c994 | |||
| 17b1348f5d | |||
| 0431ae6887 | |||
| 9fc023da9d | |||
| ffd306859f | |||
| 4869e3c97a | |||
| ed81f73f13 | |||
| a5e33b70df | |||
| a087c3d1e5 | |||
| dec696b16e | |||
| d57f9b9b65 |
44
CLAUDE.md
44
CLAUDE.md
@@ -18,8 +18,9 @@
|
|||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
||||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)<br>• **(모듈 분해: shell(67줄) + components/10 + subscriptionUtils)** |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
|
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||||
@@ -138,6 +139,12 @@ proxy: {
|
|||||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||||
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||||
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
||||||
|
| 부동산-매물 | GET | `/api/realestate/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` — { listings: [...] } |
|
||||||
|
| 부동산-매물 | POST/GET | `/api/realestate/listings/collect`, `/collect/status` |
|
||||||
|
| 부동산-매물 | GET/PUT | `/api/realestate/listings/criteria` (부분 업데이트) |
|
||||||
|
| 부동산-매물 | GET | `/api/realestate/listings/matches` — { matches: [...] } |
|
||||||
|
| 부동산-안전마진 | POST | `/api/realestate/safety-check` — body { area, deal_type, amount(만원), dong? } → { median, ratio, tier, sample, is_toheo, disclaimer } |
|
||||||
|
| 부동산-예산 | POST | `/api/realestate/budget` — body { equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? } → { jeonse, purchase, region, disclaimer } |
|
||||||
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||||
@@ -236,6 +243,41 @@ npm run preview # 빌드 결과물 미리보기
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 기능 모듈 구조 (컨벤션)
|
||||||
|
|
||||||
|
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`, `src/pages/realestate/`).
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/<feature>/
|
||||||
|
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||||
|
├── <Feature>.css # 스타일 (shell에서 1회 import)
|
||||||
|
├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임
|
||||||
|
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||||
|
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙: 파일당 단일 책임·권장 ≤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`)
|
||||||
|
|
||||||
### 파일 구조
|
### 파일 구조
|
||||||
|
|||||||
@@ -222,3 +222,11 @@ apiPost('/api/travel/sync');
|
|||||||
| 공통 컴포넌트 | 10개 |
|
| 공통 컴포넌트 | 10개 |
|
||||||
| API 헬퍼 함수 | 65+ |
|
| API 헬퍼 함수 | 65+ |
|
||||||
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 기능 모듈 구조
|
||||||
|
|
||||||
|
대형 기능 페이지는 `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,368 @@
|
|||||||
|
# RealEstate.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:** `RealEstate.jsx`(909줄)를 기능 모듈 구조(shell + hooks/useComplexes + realEstateUtils + components/5)로 **동작 보존 분해**한다.
|
||||||
|
|
||||||
|
**Architecture:** 순수 추출. 상수·데이터·순수헬퍼→`realEstateUtils.js`, 표현 컴포넌트 5개→`components/*.jsx`(verbatim), 데이터 로직(complexes+CRUD)→`hooks/useComplexes.js`, `RealEstate.jsx`는 UI 상태+조합 shell(~150줄)로 축소. 각 Task는 코드 이동 후 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + Vite, react-leaflet/leaflet, recharts, Vitest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼 본문(JSX·로직)은 verbatim. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
|
||||||
|
- 경로: `RealEstate.jsx`(`src/pages/realestate/`) api `'../../api'`; `components/*`·`hooks/*`는 api `'../../../api'`, utils `'../realEstateUtils'`; shell → utils `'./realEstateUtils'`·hook `'./hooks/useComplexes'`·components `'./components/*'`·CSS `'./RealEstate.css'`. `realEstateUtils.js`는 `leaflet`의 `L`만 import(createMarkerIcon), api import 없음.
|
||||||
|
- 각 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` 동일 디렉토리.
|
||||||
|
- 각 컴포넌트/파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 커밋은 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: `realEstateUtils.js` — 상수·데이터·순수헬퍼 추출 + 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/realestate/realEstateUtils.js`
|
||||||
|
- Test: `src/pages/realestate/realEstateUtils.test.js`
|
||||||
|
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `SAMPLE_COMPLEXES`, `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS`, `formatDate(d)`, `formatPrice(v)`, `getDDays(dateStr)`, `createMarkerIcon(status, isSelected)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — Create `src/pages/realestate/realEstateUtils.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDate, formatPrice, getDDays } from './realEstateUtils.js';
|
||||||
|
|
||||||
|
describe('formatPrice', () => {
|
||||||
|
it('만원 표기 / falsy → -', () => {
|
||||||
|
expect(formatPrice(4500)).toBe('4,500만원');
|
||||||
|
expect(formatPrice(0)).toBe('-');
|
||||||
|
expect(formatPrice(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDDays (오늘 기준)', () => {
|
||||||
|
const iso = (off) => {
|
||||||
|
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + off);
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
it('오늘=D-Day, 미래=D-N, 과거=D+N, null→null', () => {
|
||||||
|
expect(getDDays(iso(0))).toBe('D-Day');
|
||||||
|
expect(getDDays(iso(4))).toBe('D-4');
|
||||||
|
expect(getDDays(iso(-2))).toBe('D+2');
|
||||||
|
expect(getDDays(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('유효 날짜 한국어 포맷 / falsy → -', () => {
|
||||||
|
expect(formatDate('2026-04-10')).toContain('2026');
|
||||||
|
expect(formatDate(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/realestate/realEstateUtils.test.js`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./realEstateUtils.js"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create realEstateUtils.js** — 상단 `import L from 'leaflet';` 후, 현 `RealEstate.jsx`의 아래 항목을 **verbatim** 이동하고 각각 `export`:
|
||||||
|
- `SAMPLE_COMPLEXES` (현 RealEstate.jsx의 4개 단지 배열 전체를 그대로 복사) — `export const SAMPLE_COMPLEXES = [...]`
|
||||||
|
- `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS` (verbatim) — 각 `export const`
|
||||||
|
- `formatDate`, `formatPrice`, `getDDays` (verbatim) — 각 `export const`
|
||||||
|
- `createMarkerIcon` (verbatim, `L.divIcon` 사용) — `export const createMarkerIcon = (status, isSelected = false) => {...}`
|
||||||
|
|
||||||
|
명시적 내용(작은 것들, verbatim):
|
||||||
|
```js
|
||||||
|
export const STATUS_CONFIG = {
|
||||||
|
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||||
|
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||||
|
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||||
|
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||||
|
};
|
||||||
|
export const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||||
|
export const EMPTY_FORM = {
|
||||||
|
name: '', address: '', lat: '', lng: '',
|
||||||
|
units: '', types: '', avgPricePerPyeong: '',
|
||||||
|
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||||
|
status: '청약예정', priority: 'normal',
|
||||||
|
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||||
|
};
|
||||||
|
export const TABS = ['목록', '일정', '분석'];
|
||||||
|
|
||||||
|
export const formatDate = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
export const formatPrice = (v) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
return `${v.toLocaleString()}만원`;
|
||||||
|
};
|
||||||
|
export const getDDays = (dateStr) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const target = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diff === 0) return 'D-Day';
|
||||||
|
if (diff > 0) return `D-${diff}`;
|
||||||
|
return `D+${Math.abs(diff)}`;
|
||||||
|
};
|
||||||
|
export const createMarkerIcon = (status, isSelected = false) => {
|
||||||
|
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||||
|
const size = isSelected ? 18 : 12;
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
background:${cfg.color};
|
||||||
|
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||||
|
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||||
|
transition:all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
`SAMPLE_COMPLEXES`는 현 RealEstate.jsx:14-91의 배열을 그대로 옮겨 `export const SAMPLE_COMPLEXES = [ ... ];`로.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire RealEstate.jsx** — 위 항목들의 기존 정의(SAMPLE_COMPLEXES/STATUS_CONFIG/PRIORITY_LABELS/EMPTY_FORM/TABS/formatDate/formatPrice/getDDays/createMarkerIcon)를 삭제하고, import 추가:
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS,
|
||||||
|
formatDate, formatPrice, getDDays, createMarkerIcon,
|
||||||
|
} from './realEstateUtils';
|
||||||
|
```
|
||||||
|
(이 시점엔 컴포넌트들이 아직 RealEstate.jsx 내부 → import한 심볼 사용. `import L from 'leaflet'`는 createMarkerIcon이 utils로 갔으니 RealEstate.jsx에서 더 이상 직접 안 쓰면 제거; 단 다른 곳(예: 없음)에서 안 쓰면 제거. `import 'leaflet/dist/leaflet.css'`는 지도 렌더 위해 shell 또는 RightPanel에 유지 필요 → 이번 단계는 RealEstate.jsx에 유지.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/realestate/realEstateUtils.test.js` → PASS.
|
||||||
|
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규/수정 파일 0 에러(미사용 import 없도록).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/realestate/realEstateUtils.js src/pages/realestate/realEstateUtils.test.js src/pages/realestate/RealEstate.jsx
|
||||||
|
git commit -m "refactor(realestate): 상수·데이터·순수헬퍼를 realEstateUtils로 추출 + 테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 표현 컴포넌트 5개 추출 (ComplexCard/RightPanel/ScheduleView/PriceAnalysis/ComplexModal)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/realestate/components/ComplexCard.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/RightPanel.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/ScheduleView.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/PriceAnalysis.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/ComplexModal.jsx`
|
||||||
|
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (Task 1): utils 심볼.
|
||||||
|
- Produces: 각 기본 export — `ComplexCard({complex,isSelected,onClick})`, `RightPanel({complexes,selectedComplex,onSelectComplex,onEdit,onDelete})`, `ScheduleView({complexes})`, `PriceAnalysis({complexes})`, `ComplexModal({complex,onClose,onSave})`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the five components (본문 verbatim + 상단 import)**
|
||||||
|
|
||||||
|
각 새 파일 = 상단 import 블록 + 현 RealEstate.jsx의 해당 함수 본문 verbatim + `export default <Name>;`. **`MapFlyTo`는 RightPanel.jsx 안에 내부 컴포넌트로 함께 이동**(현 RealEstate.jsx의 MapFlyTo 정의를 RightPanel.jsx 상단에 그대로 두고 RightPanel이 사용). `EventItem`은 ScheduleView 내부, `CustomTooltip`은 PriceAnalysis 내부에 이미 중첩되어 있으므로 그대로.
|
||||||
|
|
||||||
|
import 블록(각 컴포넌트가 실제 사용하는 심볼만 — 아래는 현 코드 사용 기준):
|
||||||
|
|
||||||
|
`ComplexCard.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`RightPanel.jsx` (MapFlyTo 내부 포함):
|
||||||
|
```jsx
|
||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
|
import { STATUS_CONFIG, PRIORITY_LABELS, formatDate, formatPrice, createMarkerIcon } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`ScheduleView.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`PriceAnalysis.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`ComplexModal.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
> 실제 본문이 참조하는 심볼만 포함할 것(빌드/lint가 누락·미사용 검출). RightPanel은 `leaflet/dist/leaflet.css`를 shell에서 로드하므로 별도 import 불필요.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire RealEstate.jsx** — 5개 컴포넌트 + MapFlyTo 정의 삭제, import 추가:
|
||||||
|
```js
|
||||||
|
import ComplexCard from './components/ComplexCard';
|
||||||
|
import RightPanel from './components/RightPanel';
|
||||||
|
import ScheduleView from './components/ScheduleView';
|
||||||
|
import PriceAnalysis from './components/PriceAnalysis';
|
||||||
|
import ComplexModal from './components/ComplexModal';
|
||||||
|
```
|
||||||
|
RealEstate.jsx 상단의 이제-미사용 import 정리: `MapContainer/TileLayer/Marker/Popup/useMap`(react-leaflet), `BarChart~Cell`(recharts)가 shell에서 더 이상 안 쓰이면 제거(shell은 지도/차트를 직접 렌더하지 않음). `import 'leaflet/dist/leaflet.css'`는 지도 스타일 위해 **shell에 유지**(RightPanel이 렌더될 때 필요). `useMemo`는 shell의 filteredComplexes/stats에서 계속 사용 → 유지.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests + build + lint**
|
||||||
|
|
||||||
|
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 5파일 + RealEstate.jsx 0 에러.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/realestate/components/ComplexCard.jsx src/pages/realestate/components/RightPanel.jsx src/pages/realestate/components/ScheduleView.jsx src/pages/realestate/components/PriceAnalysis.jsx src/pages/realestate/components/ComplexModal.jsx src/pages/realestate/RealEstate.jsx
|
||||||
|
git commit -m "refactor(realestate): 카드/지도패널/일정/분석/모달 표현 컴포넌트 추출"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `useComplexes` 훅 추출 → RealEstate를 shell 컨테이너로 + 최종 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/realestate/hooks/useComplexes.js`
|
||||||
|
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `useComplexes()` 기본 export → `{ complexes, addComplex, updateComplex, deleteComplex }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create useComplexes.js** — 현 메인의 데이터 로직을 그대로 옮김(UI 관심사는 제외):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
|
||||||
|
import { SAMPLE_COMPLEXES } from '../realEstateUtils';
|
||||||
|
|
||||||
|
export default function useComplexes() {
|
||||||
|
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/realestate/complexes')
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addComplex = async (data) => {
|
||||||
|
const newComplex = { ...data, id: Date.now() };
|
||||||
|
setComplexes((prev) => [...prev, newComplex]);
|
||||||
|
try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateComplex = async (data) => {
|
||||||
|
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||||
|
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteComplex = async (id) => {
|
||||||
|
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return { complexes, addComplex, updateComplex, deleteComplex };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reduce RealEstate.jsx to the shell container** — 컴포넌트 본문 최상단 부분(상태 선언 ~ 핸들러 ~ memo)을 아래로 교체. **`return (...)` JSX 블록은 verbatim 유지**(이미 `complexes`/`filteredComplexes`/`stats`/`selectedComplex`/`activeTab`/`filterStatus`/`handleDelete`/`handleModalSave`/`setSelectedComplex`/`setEditingComplex`/`setShowModal`/`setFilterStatus`/`setActiveTab`를 참조 — 이들은 아래 재구성으로 동일하게 제공됨).
|
||||||
|
|
||||||
|
RealEstate.jsx 상단 import는 다음으로 정리(현 사용 기준): React 훅(`useState`, `useMemo`), `Link`, utils(SAMPLE_COMPLEXES는 이제 훅이 씀 → shell에서 제거 가능; STATUS_CONFIG/TABS는 shell 렌더에서 사용 → 유지, 그 외 유틸 중 shell이 직접 쓰는 것만), `useComplexes`, 5개 컴포넌트, `import 'leaflet/dist/leaflet.css'`, `import './RealEstate.css'`. (미사용 import 제거로 lint 0.)
|
||||||
|
|
||||||
|
컴포넌트 함수 본문(선언부) 교체:
|
||||||
|
```js
|
||||||
|
const RealEstate = () => {
|
||||||
|
const { complexes, addComplex, updateComplex, deleteComplex } = useComplexes();
|
||||||
|
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState('목록');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('전체');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingComplex, setEditingComplex] = useState(null);
|
||||||
|
|
||||||
|
const handleAdd = (data) => {
|
||||||
|
addComplex(data);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (data) => {
|
||||||
|
updateComplex(data);
|
||||||
|
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||||
|
setEditingComplex(null);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
|
deleteComplex(id);
|
||||||
|
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSave = (data) => {
|
||||||
|
if (editingComplex) {
|
||||||
|
handleUpdate({ ...editingComplex, ...data });
|
||||||
|
} else {
|
||||||
|
handleAdd(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredComplexes = useMemo(() => {
|
||||||
|
if (filterStatus === '전체') return complexes;
|
||||||
|
return complexes.filter((c) => c.status === filterStatus);
|
||||||
|
}, [complexes, filterStatus]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: complexes.length,
|
||||||
|
upcoming: complexes.filter((c) => c.status === '청약예정').length,
|
||||||
|
active: complexes.filter((c) => c.status === '청약중').length,
|
||||||
|
avgPrice: complexes.length
|
||||||
|
? Math.round(complexes.reduce((s, c) => s + c.avgPricePerPyeong, 0) / complexes.length)
|
||||||
|
: 0,
|
||||||
|
}), [complexes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// ── 현 RealEstate.jsx의 return JSX 전체를 verbatim 유지 ──
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
> `handleAdd`/`handleUpdate`/`handleDelete`는 비동기 await를 훅으로 옮겼으므로 shell에선 동기(비-async). 원래도 optimistic 갱신 후 UI(setShowModal/setSelected)를 처리하고 API는 fire-and-forget이었으므로 동작 동일(setComplexes→UI→API 순서 보존). `handleModalSave`/`filteredComplexes`/`stats`와 return JSX는 원본 그대로.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full suite + lint + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run` → 전체 green(회귀 0). `npm run lint` → 0 새 에러. `npm run build` → `✓ built`. `wc -l src/pages/realestate/RealEstate.jsx` 로 shell 축소(~150줄) 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/realestate/hooks/useComplexes.js src/pages/realestate/RealEstate.jsx
|
||||||
|
git commit -m "refactor(realestate): 데이터 로직 useComplexes 훅 추출 → RealEstate shell 축소"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual verification (dev server)**
|
||||||
|
|
||||||
|
`npm run dev` → `http://localhost:3007/realestate/property` 접속. 3탭(목록/일정/분석) 전환, 목록 카드 선택→우측 지도 flyTo+상세, 필터, 단지 추가/편집/삭제 모달, 지도 마커 클릭이 리팩토링 전과 **동일 동작**인지 확인. 청약 대시보드 링크(`/realestate`) 동작 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 결과
|
||||||
|
|
||||||
|
**Spec coverage** (설계 §1–§7):
|
||||||
|
- §3 file map(utils/hook/components 5/shell) → Task 1/2/3 ✅
|
||||||
|
- §4 useComplexes(데이터만, UI 경계는 shell) → Task 3 ✅ (confirm·selectedComplex 동기화 shell 유지)
|
||||||
|
- §5 테스트(realEstateUtils.test + build/lint/수동) → Task 1 + 각 Task 검증 + Task 3 수동 ✅
|
||||||
|
- §6 완료기준 → 각 Task 검증 ✅
|
||||||
|
|
||||||
|
**Placeholder scan:** 신규 코드(utils 작은 것들/훅/테스트/shell 선언부)는 전체 코드 명시. SAMPLE_COMPLEXES·표현 컴포넌트·return JSX는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 원본 존재, import 심볼 명시). ✅
|
||||||
|
|
||||||
|
**Type consistency:** utils export(Task1) ↔ Task2/3 import 일치. 컴포넌트 기본 export ↔ shell import 일치. `useComplexes` 반환 `{complexes,addComplex,updateComplex,deleteComplex}` ↔ shell 사용 일치. 경로(shell `../../api` 아님— shell은 api 직접 안 씀; components/hooks `../../../api`) 일치. ✅
|
||||||
|
|
||||||
|
**참고:** Task 순서 의존(utils→components→hook/shell). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0.
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
# FE 모듈 구조 컨벤션 + Subscription 분해 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:** `Subscription.jsx`(1647줄)를 기능 모듈 구조(shell + components/ + utils)로 **동작 보존 분해**하고, 컨벤션을 CLAUDE.md/README에 기록한다.
|
||||||
|
|
||||||
|
**Architecture:** 순수 추출(코드 이동)만. 상수·순수헬퍼→`subscriptionUtils.js`, 각 sub-component→`components/*.jsx`, `Subscription.jsx`는 shell로 축소. 각 Task는 코드를 옮기고 import를 배선한 뒤 build+전체 테스트 green으로 동작 보존을 확인. 로직/JSX/스타일 한 줄도 바꾸지 않음.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + Vite, Vitest + @testing-library/react.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼의 본문(JSX·로직)은 **verbatim(그대로)**. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
|
||||||
|
- 경로: `Subscription.jsx`(`src/pages/subscription/`)는 api를 `'../../api'`로 import. `components/*.jsx`는 `'../../../api'`, utils는 `'../subscriptionUtils'`. shell은 CSS `'./Subscription.css'` import 유지.
|
||||||
|
- 각 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)` 동일 디렉토리.
|
||||||
|
- `/realestate/listings` 교차링크(shell의 `<Link>`)와 `PullToRefresh`/`FAB`/탭 동작 유지.
|
||||||
|
- 커밋은 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: `subscriptionUtils.js` — 상수·순수헬퍼 추출 + 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/subscription/subscriptionUtils.js`
|
||||||
|
- Test: `src/pages/subscription/subscriptionUtils.test.js`
|
||||||
|
- Modify: `src/pages/subscription/Subscription.jsx` (해당 정의 제거 + import 추가)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`, `extractTier(reasons)`, `fmt(d)`, `fmtFull(d)`, `getDDays(d)`, `getDDayColor(d)`, `fmtDateTime(d)`, `apiPatch(path,body)`, `fmtPrice(v)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — Create `src/pages/subscription/subscriptionUtils.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractTier, getDDays, getDDayColor, fmtPrice } from './subscriptionUtils.js';
|
||||||
|
|
||||||
|
describe('extractTier', () => {
|
||||||
|
it('자치구 티어 문자열에서 등급 추출', () => {
|
||||||
|
expect(extractTier(['자치구 S티어: 강남구 (+25)'])).toBe('S');
|
||||||
|
expect(extractTier(['면적 +10', '자치구 B티어: 노원구 (+15)'])).toBe('B');
|
||||||
|
});
|
||||||
|
it('미매치/빈 입력 → null', () => {
|
||||||
|
expect(extractTier(['면적 적합'])).toBe(null);
|
||||||
|
expect(extractTier(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDDays / getDDayColor (오늘 기준 경계)', () => {
|
||||||
|
const iso = (offsetDays) => {
|
||||||
|
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + offsetDays);
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
it('오늘=D-Day, 미래=D-N, 과거=D+N', () => {
|
||||||
|
expect(getDDays(iso(0))).toBe('D-Day');
|
||||||
|
expect(getDDays(iso(5))).toBe('D-5');
|
||||||
|
expect(getDDays(iso(-3))).toBe('D+3');
|
||||||
|
expect(getDDays(null)).toBe(null);
|
||||||
|
});
|
||||||
|
it('색 임계: 지남=빨강, ≤3=주황, ≤7=파랑, 그 외=dim', () => {
|
||||||
|
expect(getDDayColor(iso(-1))).toBe('#f87171');
|
||||||
|
expect(getDDayColor(iso(2))).toBe('#f59e0b');
|
||||||
|
expect(getDDayColor(iso(6))).toBe('#00d4ff');
|
||||||
|
expect(getDDayColor(iso(30))).toBe('var(--text-dim)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fmtPrice (현 구현 동작 고정)', () => {
|
||||||
|
it('억/만 변환', () => {
|
||||||
|
expect(fmtPrice(10000)).toBe('1억');
|
||||||
|
expect(fmtPrice(15000)).toBe('1.5억');
|
||||||
|
expect(fmtPrice(9999)).toBe('9,999만');
|
||||||
|
expect(fmtPrice(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./subscriptionUtils.js"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create subscriptionUtils.js** (아래 내용 — `Subscription.jsx` 현재 상수/헬퍼를 그대로 옮김; `_diffDays`는 내부 유지·미export):
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* 청약(Subscription) 모듈 상수·순수 헬퍼 */
|
||||||
|
|
||||||
|
export const STATUS_CONFIG = {
|
||||||
|
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
||||||
|
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
|
||||||
|
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
|
||||||
|
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOUSE_TYPE_LABELS = {
|
||||||
|
'01': '국민주택',
|
||||||
|
'02': '민영주택',
|
||||||
|
'03': '도시형생활주택',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
|
||||||
|
|
||||||
|
export const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
|
||||||
|
|
||||||
|
export const DEFAULT_PROFILE = {
|
||||||
|
name: '', age: '', is_homeless: false, is_householder: false,
|
||||||
|
subscription_months: '', subscription_amount: '',
|
||||||
|
family_members: '', has_dependents: false, children_count: '',
|
||||||
|
is_newlywed: false, marriage_months: '',
|
||||||
|
has_newborn: false, is_first_home: false, income_level: '',
|
||||||
|
preferred_regions: '', preferred_types: '',
|
||||||
|
min_area: '', max_area: '', max_price: '',
|
||||||
|
preferred_districts: {},
|
||||||
|
min_match_score: 70,
|
||||||
|
notify_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractTier(reasons) {
|
||||||
|
for (const r of reasons || []) {
|
||||||
|
const m = r.match(/자치구 ([SABCD])티어/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmt = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fmtFull = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const _diffDays = (d) => {
|
||||||
|
if (!d) return null;
|
||||||
|
const [y, m, day] = d.split('-').map(Number);
|
||||||
|
const target = new Date(y, m - 1, day);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((target - today) / 86400000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDDays = (d) => {
|
||||||
|
const diff = _diffDays(d);
|
||||||
|
if (diff === null) return null;
|
||||||
|
if (diff === 0) return 'D-Day';
|
||||||
|
return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDDayColor = (d) => {
|
||||||
|
const diff = _diffDays(d);
|
||||||
|
if (diff === null) return 'var(--text-dim)';
|
||||||
|
if (diff <= 0) return '#f87171';
|
||||||
|
if (diff <= 3) return '#f59e0b';
|
||||||
|
if (diff <= 7) return '#00d4ff';
|
||||||
|
return 'var(--text-dim)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fmtDateTime = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function apiPatch(path, body) {
|
||||||
|
const opts = {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtPrice = (v) => {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
|
||||||
|
return `${v.toLocaleString()}만`;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire Subscription.jsx** — `src/pages/subscription/Subscription.jsx`에서: 위 상수/헬퍼의 **기존 정의(현 파일 상단 STATUS_CONFIG~fmtPrice, 로컬 apiPatch 포함)를 삭제**하고, 최상단 import 블록에 추가:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE,
|
||||||
|
extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice,
|
||||||
|
} from './subscriptionUtils';
|
||||||
|
```
|
||||||
|
(이 시점엔 컴포넌트들이 아직 Subscription.jsx 내부에 있으므로 import한 심볼을 그대로 사용. 나머지 컴포넌트 정의는 유지.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js` → PASS.
|
||||||
|
Run: `npm run test:run` → 전체 green.
|
||||||
|
Run: `npm run build` → `✓ built` (Subscription 동작 보존).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/subscription/subscriptionUtils.js src/pages/subscription/subscriptionUtils.test.js src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "refactor(subscription): 상수·순수헬퍼를 subscriptionUtils로 추출 + 테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `StatusBadge` 컴포넌트 추출 + 스모크
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/subscription/components/StatusBadge.jsx`
|
||||||
|
- Test: `src/pages/subscription/components/StatusBadge.test.jsx`
|
||||||
|
- Modify: `src/pages/subscription/Subscription.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (Task 1): `STATUS_CONFIG`.
|
||||||
|
- Produces: `StatusBadge` 기본 export — `({ status, size })`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/subscription/components/StatusBadge.test.jsx`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import StatusBadge from './StatusBadge.jsx';
|
||||||
|
|
||||||
|
describe('StatusBadge', () => {
|
||||||
|
it('status 라벨 렌더', () => {
|
||||||
|
render(<StatusBadge status="청약중" />);
|
||||||
|
expect(screen.getByText('청약중')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('미정의 status → "알 수 없음" 폴백', () => {
|
||||||
|
render(<StatusBadge status={null} />);
|
||||||
|
expect(screen.getByText('알 수 없음')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./StatusBadge.jsx"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create StatusBadge.jsx** (현 `Subscription.jsx`의 StatusBadge 정의를 그대로 옮기고 상단 import 추가):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||||
|
|
||||||
|
function StatusBadge({ status, size }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
|
||||||
|
const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
|
||||||
|
return (
|
||||||
|
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{status || '알 수 없음'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire Subscription.jsx** — StatusBadge 정의 삭제 + import 추가:
|
||||||
|
```js
|
||||||
|
import StatusBadge from './components/StatusBadge';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx` → PASS.
|
||||||
|
Run: `npm run test:run` → green. `npm run build` → `✓ built`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/subscription/components/StatusBadge.jsx src/pages/subscription/components/StatusBadge.test.jsx src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "refactor(subscription): StatusBadge 컴포넌트 추출 + 스모크"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 표현 컴포넌트 추출 (AnnouncementCard / AnnouncementDetail / CalendarView)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/subscription/components/AnnouncementCard.jsx`
|
||||||
|
- Create: `src/pages/subscription/components/AnnouncementDetail.jsx`
|
||||||
|
- Create: `src/pages/subscription/components/CalendarView.jsx`
|
||||||
|
- Modify: `src/pages/subscription/Subscription.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (Task 1/2): utils 헬퍼, `StatusBadge`.
|
||||||
|
- Produces: 각 기본 export — `AnnouncementCard({item,isSelected,onClick,onBookmark})`, `AnnouncementDetail({item,onBookmark})`, `CalendarView({items,onDaySelect})`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the three components (본문은 현 Subscription.jsx 정의 verbatim, 상단 import만 추가)**
|
||||||
|
|
||||||
|
`AnnouncementCard.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
```
|
||||||
|
이어서 현 `Subscription.jsx`의 `function AnnouncementCard(...) { ... }` 본문을 그대로 붙이고 파일 끝에 `export default AnnouncementCard;`.
|
||||||
|
|
||||||
|
`AnnouncementDetail.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { HOUSE_TYPE_LABELS, fmt, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
```
|
||||||
|
이어서 현 `AnnouncementDetail(...)` 본문 verbatim + `export default AnnouncementDetail;`. (내부 `useState`(detailTab) 사용하므로 useState import 포함.)
|
||||||
|
|
||||||
|
`CalendarView.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||||
|
```
|
||||||
|
이어서 현 `CalendarView(...)` 본문 verbatim + `export default CalendarView;`. (내부 `useState`/`useMemo` 사용.)
|
||||||
|
|
||||||
|
> 각 컴포넌트가 실제로 참조하는 심볼만 import에 포함되어야 함(빌드가 미사용/누락을 검출). 위 목록은 현 코드 사용 기준.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire Subscription.jsx** — 세 컴포넌트 정의 삭제 + import 추가:
|
||||||
|
```js
|
||||||
|
import AnnouncementCard from './components/AnnouncementCard';
|
||||||
|
import AnnouncementDetail from './components/AnnouncementDetail';
|
||||||
|
import CalendarView from './components/CalendarView';
|
||||||
|
```
|
||||||
|
(AnnouncementsTab이 아직 Subscription.jsx 내부에 있으므로 import한 세 컴포넌트를 그대로 사용.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run build` → `✓ built`. Run: `npm run lint` → 신규 3파일 0 에러.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/subscription/components/AnnouncementCard.jsx src/pages/subscription/components/AnnouncementDetail.jsx src/pages/subscription/components/CalendarView.jsx src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "refactor(subscription): 공고 카드/상세/캘린더 컴포넌트 추출"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/subscription/components/DashboardTab.jsx`
|
||||||
|
- Create: `src/pages/subscription/components/AnnouncementsTab.jsx`
|
||||||
|
- Create: `src/pages/subscription/components/MatchesTab.jsx`
|
||||||
|
- Create: `src/pages/subscription/components/ProfileTab.jsx`
|
||||||
|
- Modify: `src/pages/subscription/Subscription.jsx` (shell로 축소)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: utils 헬퍼, StatusBadge, AnnouncementCard/AnnouncementDetail/CalendarView, 기존 `DistrictTierEditor`/`NotificationSettings`.
|
||||||
|
- Produces: 각 기본 export (자립형, props 없음).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the four tab components (본문 verbatim + 상단 import)**
|
||||||
|
|
||||||
|
`DashboardTab.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost } from '../../../api';
|
||||||
|
import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
```
|
||||||
|
현 `DashboardTab()` 본문 verbatim + `export default DashboardTab;`.
|
||||||
|
|
||||||
|
`AnnouncementsTab.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiDelete } from '../../../api';
|
||||||
|
import { STATUS_FILTERS, apiPatch } from '../subscriptionUtils';
|
||||||
|
import AnnouncementCard from './AnnouncementCard';
|
||||||
|
import AnnouncementDetail from './AnnouncementDetail';
|
||||||
|
import CalendarView from './CalendarView';
|
||||||
|
```
|
||||||
|
현 `AnnouncementsTab()` 본문 verbatim + `export default AnnouncementsTab;`.
|
||||||
|
|
||||||
|
`MatchesTab.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost } from '../../../api';
|
||||||
|
import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
```
|
||||||
|
현 `MatchesTab()` 본문 verbatim + `export default MatchesTab;`.
|
||||||
|
|
||||||
|
`ProfileTab.jsx` — 상단:
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPut } from '../../../api';
|
||||||
|
import { DEFAULT_PROFILE } from '../subscriptionUtils';
|
||||||
|
import DistrictTierEditor from './DistrictTierEditor';
|
||||||
|
import NotificationSettings from './NotificationSettings';
|
||||||
|
```
|
||||||
|
현 `ProfileTab()` 본문 verbatim + `export default ProfileTab;`.
|
||||||
|
|
||||||
|
> import 심볼은 각 탭이 실제 사용하는 것 기준(위 목록). 빌드가 누락/미사용 검출.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reduce Subscription.jsx to the shell** — 4개 탭 정의 삭제. 파일을 아래로 정리(현 shell 본문 verbatim 유지, import만 재구성):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
|
import FAB from '../../components/FAB';
|
||||||
|
import { TABS } from './subscriptionUtils';
|
||||||
|
import DashboardTab from './components/DashboardTab';
|
||||||
|
import AnnouncementsTab from './components/AnnouncementsTab';
|
||||||
|
import MatchesTab from './components/MatchesTab';
|
||||||
|
import ProfileTab from './components/ProfileTab';
|
||||||
|
import './Subscription.css';
|
||||||
|
|
||||||
|
// ── Subscription (Main) ──
|
||||||
|
function Subscription() {
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFABClick = useCallback(() => {
|
||||||
|
setActiveTab(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={handleRefresh}>
|
||||||
|
<div className="sub">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||||
|
<Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
|
||||||
|
실매물 · 안전마진 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="sub-header">
|
||||||
|
<div>
|
||||||
|
<p className="sub-kicker">Real Estate</p>
|
||||||
|
<h1>청약 관리</h1>
|
||||||
|
<p className="sub-desc">
|
||||||
|
공공데이터 기반 청약 공고 자동 수집, 내 조건 매칭, 일정 관리를 한곳에서.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-tabs-bar">
|
||||||
|
<div className="sub-tabs">
|
||||||
|
{TABS.map((tab, i) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`sub-tab${activeTab === i ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(i)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-body">
|
||||||
|
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
|
||||||
|
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
|
||||||
|
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
|
||||||
|
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
|
||||||
|
</div>
|
||||||
|
<FAB onClick={handleFABClick} label="공고 목록" />
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Subscription;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full suite + lint + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run lint` → 신규 파일 0 에러. Run: `npm run build` → `✓ built`. `Subscription.jsx`가 ~70줄 shell로 축소됐는지 확인(`wc -l`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/subscription/components/DashboardTab.jsx src/pages/subscription/components/AnnouncementsTab.jsx src/pages/subscription/components/MatchesTab.jsx src/pages/subscription/components/ProfileTab.jsx src/pages/subscription/Subscription.jsx
|
||||||
|
git commit -m "refactor(subscription): 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual verification (dev server)**
|
||||||
|
|
||||||
|
`npm run dev` → `http://localhost:3007/realestate` 접속. 4탭(대시보드/공고 목록/매칭 결과/내 프로필) 전환, 공고 카드 선택→상세, 캘린더 뷰, 즐겨찾기 토글, 프로필 저장이 리팩토링 전과 **동일 동작**인지 확인. 실매물 교차링크 동작 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Part A — 기능 모듈 구조 컨벤션 문서화 (CLAUDE.md + README)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (web-ui 루트)
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-ui/CLAUDE.md — "기능 모듈 구조" 섹션 추가** (문서 상단 "주요 파일 위치" 또는 페이지 구조 근처):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 기능 모듈 구조 (컨벤션)
|
||||||
|
|
||||||
|
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`).
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/<feature>/
|
||||||
|
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||||
|
├── <Feature>.css # 스타일 (shell에서 1회 import)
|
||||||
|
├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임
|
||||||
|
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||||
|
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
|
||||||
|
```
|
||||||
|
그리고 페이지 구조 표의 `/realestate` 행 설명에 "(모듈 분해: shell + components/8 + subscriptionUtils)" 취지 반영.
|
||||||
|
|
||||||
|
- [ ] **Step 2: README.md — 컨벤션 문단 추가** (프로젝트 통계/구조 근처):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 기능 모듈 구조
|
||||||
|
|
||||||
|
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md README.md
|
||||||
|
git commit -m "docs: 기능 모듈 구조 컨벤션 하네스(CLAUDE.md)·README 기록"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 결과
|
||||||
|
|
||||||
|
**Spec coverage** (설계 §1–§7):
|
||||||
|
- Part A 컨벤션+문서 → Task 5 ✅
|
||||||
|
- Part B file map(utils/StatusBadge/카드3/탭4/shell) → Task 1/2/3/4 ✅
|
||||||
|
- §3 subscriptionUtils 이동 항목 → Task 1 ✅ (local apiPatch 이동 포함)
|
||||||
|
- §4 테스트(utils 유닛 + StatusBadge 스모크 + build/lint/수동) → Task 1/2 + 각 Task build/lint + Task 4 수동 ✅
|
||||||
|
- §6 완료기준 → 각 Task 검증 + Task 5 ✅
|
||||||
|
|
||||||
|
**Placeholder scan:** 신규 코드(utils/StatusBadge/테스트/shell/docs)는 전체 코드 명시. 대형 컴포넌트는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 옮길 원본이 파일에 존재, import 심볼 목록 명시). ✅
|
||||||
|
|
||||||
|
**Type consistency:** utils export 심볼(Task1) ↔ Task2/3/4 import 일치. 컴포넌트 기본 export ↔ shell import 일치. api 경로(shell `../../api`, components `../../../api`, utils `../subscriptionUtils`) 일치. ✅
|
||||||
|
|
||||||
|
**참고:** 각 Task는 이전 Task 산출물(utils→StatusBadge→카드→탭→shell)에 순차 의존. 순서 준수 필수. 리팩토링이라 RED는 "신규 파일 미존재(import 실패)"로 성립, GREEN은 추출 후 통과 + 전체 회귀 0.
|
||||||
1071
docs/superpowers/plans/2026-07-09-realestate-listings.md
Normal file
1071
docs/superpowers/plans/2026-07-09-realestate-listings.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,90 @@
|
|||||||
|
# RealEstate.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 2)
|
||||||
|
|
||||||
|
- **작성일**: 2026-07-09
|
||||||
|
- **역할/저장소**: FE (`web-ui`)
|
||||||
|
- **범위**: FE 대형 컴포넌트 분해 sweep의 **2번째 서브프로젝트**. 컨벤션은 sub-project 1(Subscription)에서 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 이번은 적용만.
|
||||||
|
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목표
|
||||||
|
|
||||||
|
`src/pages/realestate/RealEstate.jsx`(909줄, `/realestate/property` 관심 단지 페이지)를 기능 모듈 구조로 동작 보존 분해한다. Subscription과 달리 **메인이 데이터 상태(complexes)+CRUD API를 직접 보유**하고 하위는 순수 표현 컴포넌트(props)인 "스마트 컨테이너 + 표현" 구조 → 데이터 로직은 `hooks/useComplexes.js`로 추출(컨벤션: shell은 조합만).
|
||||||
|
|
||||||
|
비목표: 동작/스타일 변경, 신규 기능, MapFlyTo/EventItem/CustomTooltip 같은 소형 내부 컴포넌트의 과분해.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현 구조 맵 (RealEstate.jsx 909줄)
|
||||||
|
|
||||||
|
- 상수/데이터: `SAMPLE_COMPLEXES`(폴백 샘플 4개), `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS`(['목록','일정','분석'])
|
||||||
|
- 순수 헬퍼: `formatDate`, `formatPrice`, `getDDays`, `createMarkerIcon`(leaflet `L.divIcon`)
|
||||||
|
- 컴포넌트: `MapFlyTo`(react-leaflet 훅), `ComplexCard`, `RightPanel`(지도+상세, ~183줄, MapFlyTo 사용), `ScheduleView`(내부 `EventItem`), `PriceAnalysis`(차트+표, 내부 `CustomTooltip`), `ComplexModal`(폼 모달)
|
||||||
|
- 메인 `RealEstate`: 상태(complexes/selectedComplex/activeTab/filterStatus/showModal/editingComplex) + `useEffect` 로드 + `handleAdd/handleUpdate/handleDelete/handleModalSave` + `filteredComplexes`/`stats` memo + 헤더/탭/목록·일정·분석 렌더 + 모달.
|
||||||
|
|
||||||
|
API: `apiGet/apiPost/apiPut/apiDelete` → `/api/realestate/complexes`(+`/:id`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 목표 file map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/realestate/
|
||||||
|
├── RealEstate.jsx # shell/컨테이너 (~150줄): UI 상태(selected/activeTab/filter/modal) + useComplexes 소비 + 조합
|
||||||
|
├── RealEstate.css # 변경 없음 (shell에서 import 유지)
|
||||||
|
├── realEstateUtils.js # SAMPLE_COMPLEXES·STATUS_CONFIG·PRIORITY_LABELS·EMPTY_FORM·TABS + formatDate·formatPrice·getDDays·createMarkerIcon
|
||||||
|
├── realEstateUtils.test.js # 순수헬퍼 유닛 (신규)
|
||||||
|
├── hooks/
|
||||||
|
│ └── useComplexes.js # complexes 상태 + 로드 + add/update/delete (낙관적 UI + API), { complexes, addComplex, updateComplex, deleteComplex } 반환
|
||||||
|
└── components/
|
||||||
|
├── ComplexCard.jsx # ({ complex, isSelected, onClick })
|
||||||
|
├── RightPanel.jsx # ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) — MapFlyTo 내부 유지
|
||||||
|
├── ScheduleView.jsx # ({ complexes }) — EventItem 내부 중첩 유지
|
||||||
|
├── PriceAnalysis.jsx # ({ complexes }) — CustomTooltip 내부 중첩 유지
|
||||||
|
└── ComplexModal.jsx # ({ complex, onClose, onSave })
|
||||||
|
```
|
||||||
|
|
||||||
|
**import 경로 규칙:** components/hooks → api `'../../../api'`; components/hooks → utils `'../realEstateUtils'`; shell → utils `'./realEstateUtils'`, hook `'./hooks/useComplexes'`, components `'./components/*'`, CSS `'./RealEstate.css'`. `realEstateUtils.js`는 `leaflet`의 `L`만 import(createMarkerIcon용), api import 없음. `RightPanel.jsx`는 leaflet/react-leaflet import 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `useComplexes` 훅 (데이터 레이어 추출)
|
||||||
|
|
||||||
|
현 메인의 데이터 로직을 그대로 옮김(동작 보존):
|
||||||
|
- 상태 `complexes`(초기값 `SAMPLE_COMPLEXES`), `useEffect`로 `GET /api/realestate/complexes` 로드(배열 있으면 교체).
|
||||||
|
- `addComplex(data)` — 낙관적 append(`id: Date.now()`) + `POST`.
|
||||||
|
- `updateComplex(data)` — 낙관적 map 교체 + `PUT /:id`.
|
||||||
|
- `deleteComplex(id)` — 낙관적 filter + `DELETE /:id`. (confirm은 shell에 유지할지 훅에 둘지 — **동작 보존 위해 현 위치(핸들러) 그대로**: `handleDelete`의 `confirm`은 shell에 남기고 훅은 순수 삭제만 담당. 즉 훅은 `deleteComplex(id)`가 낙관적 삭제+API, shell이 confirm 후 호출.)
|
||||||
|
- 반환: `{ complexes, addComplex, updateComplex, deleteComplex }`. selectedComplex 동기화(삭제/수정 시)는 shell이 담당(UI 상태이므로).
|
||||||
|
|
||||||
|
> 주의: 현 코드의 `handleUpdate`가 `selectedComplex?.id === data.id`면 `setSelectedComplex(data)`도 함. 이는 UI 상태(selectedComplex) 갱신이므로 **shell에 유지**. 훅은 complexes만 관리. shell의 `handleUpdate` 래퍼가 `updateComplex(data)` 호출 + selectedComplex 동기화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 테스트 (안전망 — 현재 RealEstate 테스트 0)
|
||||||
|
|
||||||
|
1. **`realEstateUtils.test.js`(신규, 필수)** — 순수 헬퍼:
|
||||||
|
- `formatPrice`: 4500→"4,500만원", 0/falsy→"-".
|
||||||
|
- `getDDays`: 오늘=D-Day, 미래 D-N, 과거 D+N, null→null.
|
||||||
|
- `formatDate`: 유효 날짜 한국어 포맷, falsy→"-".
|
||||||
|
2. 컴포넌트는 표현 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동). ComplexCard 스모크(선택) 정도 가능.
|
||||||
|
3. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규 파일 0 에러. 최종 개발서버 `/realestate/property` 3탭(목록/일정/분석)·지도 마커·단지 추가/편집/삭제 모달이 리팩토링 전과 동일 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 완료 기준
|
||||||
|
|
||||||
|
- [ ] RealEstate.jsx가 shell(~150줄) + hooks/useComplexes + realEstateUtils(+test) + components/5로 분해, 동작/스타일 불변.
|
||||||
|
- [ ] `realEstateUtils.test.js` 통과, 전체 `npm run test:run` green.
|
||||||
|
- [ ] `npm run lint`·`npm run build` 통과.
|
||||||
|
- [ ] `/realestate/property` 수동 검증 동일 동작(지도·모달·탭·필터).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리스크 / 오픈 이슈
|
||||||
|
|
||||||
|
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/수동으로 위험 최소화.
|
||||||
|
- **훅 추출 시 상태 경계**: complexes(데이터)는 훅, selectedComplex/activeTab/filter/modal(UI)은 shell. 현 동작(특히 update 시 selectedComplex 동기화, delete 시 confirm)을 그대로 재현.
|
||||||
|
- **leaflet/recharts import**: RightPanel(leaflet)·PriceAnalysis(recharts)가 각자 필요한 것만 import. createMarkerIcon은 utils에서 `L` import.
|
||||||
|
- **후속 follow-up**: RightPanel(~200줄)은 지도/상세 2책임 — 필요 시 후속 분할 여지 기록.
|
||||||
|
- **다음 서브프로젝트**: PortfolioTab(671, stock/) → InstaCards(1013) → MusicStudio(1936).
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# FE 기능 모듈 구조 컨벤션 + Subscription 분해 리팩토링 — 설계
|
||||||
|
|
||||||
|
- **작성일**: 2026-07-09
|
||||||
|
- **역할/저장소**: FE (`web-ui`)
|
||||||
|
- **범위**: FE 대형 컴포넌트 책임 분해 리팩토링의 **1번째 서브프로젝트**. (전체 분해: Subscription→RealEstate→PortfolioTab→InstaCards→MusicStudio 순차, 각 별도 spec/plan.)
|
||||||
|
- **성격**: **behavior-preserving(동작 보존) 순수 추출**. 로직 재작성 금지, UI/동작 변경 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목표
|
||||||
|
|
||||||
|
기능이 늘며 일부 페이지 컴포넌트가 단일 파일에 과도한 책임을 담게 됨(예: `Subscription.jsx` 1647줄에 4탭+카드+상세+캘린더+헬퍼 전부). 하네스 엔지니어링 원칙(단일 책임·명확한 경계·CLAUDE.md 계층 문서화)에 맞춰 **기능 모듈 구조**로 분해하고, 그 컨벤션을 하네스(web-ui/CLAUDE.md)와 README에 기록한다.
|
||||||
|
|
||||||
|
이 스펙은 두 부분:
|
||||||
|
- **Part A (공통 컨벤션 + 문서화)**: "기능 모듈 구조" 표준 정의 → 이후 모든 서브프로젝트가 참조. web-ui/CLAUDE.md + README 기록.
|
||||||
|
- **Part B (Subscription 분해)**: 컨벤션을 `Subscription.jsx`에 첫 적용.
|
||||||
|
|
||||||
|
비목표: 동작/스타일 변경, 신규 기능, api.js 중복(local apiPatch) 정리 외 무관 리팩토링.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Part A — 기능 모듈 구조 컨벤션 (하네스 기록)
|
||||||
|
|
||||||
|
**표준 디렉토리 형태** (레퍼런스: `src/pages/listings/`):
|
||||||
|
```
|
||||||
|
src/pages/<feature>/
|
||||||
|
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||||
|
├── <Feature>.css # 스타일 (shell에서 1회 import → 전역 클래스 하위 공유)
|
||||||
|
├── components/ # 표현 단위 (카드/탭/상세/리스트…) 각 단일 책임
|
||||||
|
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||||
|
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**규칙:**
|
||||||
|
- 파일당 **단일 책임**, 권장 상한 **~300줄**(초과 시 분할 신호).
|
||||||
|
- 순수 로직(포맷/매핑/계산)은 `*Utils.js`로 뽑고 **유닛 테스트 필수**.
|
||||||
|
- 상태+API가 얽힌 로직은 `hooks/` 또는 자립형 탭 컴포넌트로.
|
||||||
|
- shell은 "조합"만: 데이터 페칭·비즈니스 로직을 직접 갖지 않음.
|
||||||
|
- 컴포넌트는 서로 잘 정의된 props/훅 인터페이스로만 통신.
|
||||||
|
|
||||||
|
**문서화 대상:**
|
||||||
|
- `web-ui/CLAUDE.md`: "기능 모듈 구조" 섹션 신설(위 형태·규칙), 페이지 구조 표에 리팩토링 반영.
|
||||||
|
- `README.md`: 프로젝트 통계/구조 설명에 모듈 구조 컨벤션 1문단 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Part B — Subscription.jsx 분해 (file map)
|
||||||
|
|
||||||
|
현 `src/pages/subscription/Subscription.jsx`(1647줄)를 아래로 분해. **코드 이동만**(내용 동일), import 배선만 추가.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/subscription/
|
||||||
|
├── Subscription.jsx # shell: PullToRefresh + 헤더 + 탭바 + FAB + activeTab/refreshKey. 4탭 조합. (~70줄)
|
||||||
|
├── Subscription.css # (변경 없음, shell에서 import 유지)
|
||||||
|
├── subscriptionUtils.js # 상수·순수헬퍼 이동
|
||||||
|
├── subscriptionUtils.test.js # 순수헬퍼 유닛 테스트 (신규)
|
||||||
|
└── components/
|
||||||
|
├── StatusBadge.jsx # (기존 컴포넌트 이동)
|
||||||
|
├── DashboardTab.jsx # 자립형(self-fetch)
|
||||||
|
├── AnnouncementCard.jsx
|
||||||
|
├── AnnouncementDetail.jsx # detailTab 상태 + 정보/일정/주택형/매칭분석
|
||||||
|
├── CalendarView.jsx
|
||||||
|
├── AnnouncementsTab.jsx # AnnouncementCard/AnnouncementDetail/CalendarView 조합
|
||||||
|
├── MatchesTab.jsx
|
||||||
|
├── ProfileTab.jsx # DistrictTierEditor/NotificationSettings(기존) 사용
|
||||||
|
├── DistrictTierEditor.jsx # (기존 유지)
|
||||||
|
└── NotificationSettings.jsx # (기존 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`subscriptionUtils.js`로 이동할 항목:**
|
||||||
|
- 상수: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`
|
||||||
|
- 순수 헬퍼: `extractTier`, `fmt`, `fmtFull`, `_diffDays`, `getDDays`, `getDDayColor`, `fmtDateTime`, `fmtPrice`
|
||||||
|
- `apiPatch`(현재 Subscription 로컬 정의) — 이동. (⚠️ `src/api.js`에 동일 기능 `apiPatch` 이미 존재 — 이번엔 **로컬 것을 utils로 그대로 이동**(동작 보존), api.js와의 dedupe는 후속 follow-up으로 기록.)
|
||||||
|
|
||||||
|
**의존 관계(이동 시 import 배선):**
|
||||||
|
- `DashboardTab` → apiGet/apiPost, STATUS_CONFIG, fmt류, getDDays/getDDayColor, fmtDateTime, fmtPrice, StatusBadge
|
||||||
|
- `AnnouncementCard`/`AnnouncementDetail` → STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt/fmtFull, getDDays/getDDayColor, fmtPrice, StatusBadge
|
||||||
|
- `AnnouncementsTab` → apiGet/apiDelete/apiPatch, STATUS_FILTERS, AnnouncementCard/AnnouncementDetail/CalendarView
|
||||||
|
- `MatchesTab` → apiGet/apiPost/apiPatch, extractTier, fmt/getDDays/getDDayColor, StatusBadge
|
||||||
|
- `ProfileTab` → apiGet/apiPut, DEFAULT_PROFILE, DistrictTierEditor/NotificationSettings
|
||||||
|
- `Subscription`(shell) → TABS, 4탭 컴포넌트, PullToRefresh/FAB/Link
|
||||||
|
|
||||||
|
**주의:** `/realestate/listings` 교차링크(현 shell의 Link)는 그대로 유지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 테스트 (안전망 — 현재 Subscription은 테스트 0)
|
||||||
|
|
||||||
|
behavior-preserving라 기존 동작 유지가 핵심. 안전망:
|
||||||
|
1. **`subscriptionUtils.test.js` (신규, 필수)** — 순수 헬퍼 유닛:
|
||||||
|
- `extractTier`: "자치구 S티어: 강남구 (+25)" → "S"; 미매치 → null.
|
||||||
|
- `getDDays`/`getDDayColor`: D-day 경계(오늘=D-Day, 미래 D-N, 과거 D+N; 색 임계 0/3/7).
|
||||||
|
- `fmtPrice`(현 구현 동작 고정): 10000→"1억", 15000→"1.5억", 9999→"9,999만", null→null.
|
||||||
|
- `_diffDays`: 로컬 타임존 파싱 경계.
|
||||||
|
2. **`StatusBadge.test.jsx` (신규, 스모크)** — status→색/라벨, 미정의 status 폴백.
|
||||||
|
3. 각 탭 컴포넌트는 자립형(api 페치) → 이번 범위는 **스모크 최소화**(추출 검증은 build+lint+수동). 탭 스모크(vi.mock api)는 권장이나 필수 아님(후속).
|
||||||
|
|
||||||
|
**검증 게이트(품질 게이트):** `npm run test:run`(신규 유닛 포함 전체 green) + `npm run lint`(신규 파일 0 에러) + `npm run build`(성공) + 개발서버 `/realestate` 4탭 수동 확인(공고 목록/상세/캘린더/매칭/프로필 저장 동작 동일).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 컴포넌트 구조 (분해 후 검증 관점)
|
||||||
|
|
||||||
|
각 파일이 "무엇을 하는가 / 어떻게 쓰는가 / 무엇에 의존하는가"로 독립 이해 가능해야 함:
|
||||||
|
- `AnnouncementDetail`(현 272줄) → 여전히 큰 편이나 단일 책임(공고 상세). 내부 detailTab 3섹션은 한 컴포넌트 응집. 300줄 근처 → 필요 시 후속 분할 여지 기록.
|
||||||
|
- `ProfileTab`(현 390줄) → 폼이 커서 300줄 초과. 이번엔 파일 추출까지만, 폼 섹션(기본/자격/선호) 하위 분할은 후속 follow-up으로 기록(과분해 회피, YAGNI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 완료 기준 (Acceptance)
|
||||||
|
|
||||||
|
- [ ] Part A: web-ui/CLAUDE.md "기능 모듈 구조" 섹션 + README 컨벤션 문단 기록.
|
||||||
|
- [ ] Part B: Subscription.jsx가 shell(~70줄) + components/8 + subscriptionUtils(+test)로 분해, 동작/스타일 불변.
|
||||||
|
- [ ] `subscriptionUtils.test.js`·`StatusBadge.test.jsx` 통과, 전체 `npm run test:run` green.
|
||||||
|
- [ ] `npm run lint`·`npm run build` 통과.
|
||||||
|
- [ ] `/realestate` 4탭 수동 검증 동일 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리스크 / 오픈 이슈
|
||||||
|
|
||||||
|
- **테스트 부재 컴포넌트의 리팩토링**: 순수 추출로 위험 최소화 + build/lint/유닛테스트/수동검증. 로직 한 줄도 바꾸지 않음.
|
||||||
|
- **거대 import 배선 실수 가능**: 추출 시 각 파일의 사용 심볼을 정확히 import해야 함(누락 시 build 실패로 즉시 검출).
|
||||||
|
- **후속 follow-up(문서화, 이번 미적용)**: ProfileTab 폼 섹션 분할; local apiPatch ↔ api.js apiPatch dedupe; AnnouncementDetail 추가 분할.
|
||||||
|
- **다음 서브프로젝트**: RealEstate(909)→PortfolioTab(671)→InstaCards(1013)→MusicStudio(1936), 각 동일 컨벤션 별도 spec/plan.
|
||||||
157
docs/superpowers/specs/2026-07-09-realestate-listings-design.md
Normal file
157
docs/superpowers/specs/2026-07-09-realestate-listings-design.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# 실매물 매물·안전마진 UI (`/realestate/listings`) — FE 설계
|
||||||
|
|
||||||
|
- **작성일**: 2026-07-09
|
||||||
|
- **역할/저장소**: FE (`web-ui`)
|
||||||
|
- **상위(BE)**: realestate-lab 매물 알림 + 안전마진 배포 완료(main a7be8f7), `web-backend` §9 realestate / `service_realestate.md`. co-gahusb BE→FE msg id 3.
|
||||||
|
- **범위**: FE(web-ui)만. BE 계약(8 엔드포인트)을 소비하는 신규 전용 페이지. 매물 수집은 NAS 전담(web-ai 워커 불필요).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목표
|
||||||
|
|
||||||
|
BE가 실매물(매매/전세) **매물 수집 + 안전마진(전세가율)·적정성(호가율) 판정 + 예산 계산** 기능을 배포했다. 이는 기존 `/realestate`(청약, Subscription)와 **다른 도메인**이므로 신규 전용 라우트 `/realestate/listings`에 구현한다.
|
||||||
|
|
||||||
|
목표(v1, 하위 4기능 전체):
|
||||||
|
1. 매물 목록 조회 + 필터 + 판정 tier 뱃지 + 수동 수집 트리거.
|
||||||
|
2. 조건(criteria) 편집 — 특히 `equity`/`annual_income`(만원) 입력 UI(예산상한·전세 max_deposit 매칭 반영).
|
||||||
|
3. 안전마진 단건 체커(safety-check) — 즉시 전세가율/호가율 판정.
|
||||||
|
4. 예산 계산기(budget) — 자기자본·소득 기반 전세/매매/지역 한도.
|
||||||
|
|
||||||
|
비목표(YAGNI): 매물 상세 모달·지도, 실시간 알림 UI(텔레그램은 BE, FE는 `notify_enabled` 토글만), `/announcements`·청약 관련.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 소비할 BE 계약 (8 엔드포인트, 전부 `/api/realestate/*` 상대경로)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 요청/응답 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| GET | `/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` | → `{ listings: [ <매물아이템> ] }` (+판정 join) |
|
||||||
|
| POST | `/listings/collect` | 수동 수집 트리거 |
|
||||||
|
| GET | `/listings/collect/status` | → `{ status, collected_at?, new_count?, total_count?, error? }` (예: `{status:"never_run"}`) |
|
||||||
|
| GET | `/listings/criteria` | → 아래 criteria shape |
|
||||||
|
| PUT | `/listings/criteria` | body=변경 필드만(부분 업데이트) → 갱신된 criteria |
|
||||||
|
| GET | `/listings/matches` | → `{ matches: [ <매칭+판정> ] }` |
|
||||||
|
| POST | `/safety-check` | body `{ area:float, deal_type, amount:만원, dong_code?, complex_name?, dong? }` → `{ median, ratio, tier, sample, is_toheo, disclaimer }` |
|
||||||
|
| POST | `/budget` | body `{ equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? }` → `{ jeonse:{loan_limit,max_deposit,notes}, purchase:{ltv_pct,loan_cap,max_price,dsr_note,regulation_flags}, region:{is_toheo,is_regulated,notes}, disclaimer }` |
|
||||||
|
|
||||||
|
**criteria shape (프로덕션 확인):**
|
||||||
|
```
|
||||||
|
{ id, dongs:string[], deal_types:string[]("전세"|"반전세"|"매매"), max_deposit:int(만원),
|
||||||
|
max_sale_price:int|null(만원), min_area:float, house_types:string[],
|
||||||
|
min_safety_tier:string|null, notify_enabled:0|1, equity:int|null(만원),
|
||||||
|
annual_income:int|null(만원), is_homeless:0|1, is_householder:0|1, is_first_home:0|1, updated_at }
|
||||||
|
```
|
||||||
|
|
||||||
|
**판정 tier (표시 규칙):**
|
||||||
|
- 임차(전세/반전세) `safety_tier`(전세가율): 안전🟢 / 주의🟡 / 위험🔴 / 보류⚪
|
||||||
|
- 매매 `valuation_tier`(호가율): 저평가🟢 / 시세🟡 / 고가🔴 / 보류⚪
|
||||||
|
- 표본(sample) < 3 → tier="보류".
|
||||||
|
|
||||||
|
**매물 아이템 shape (추론 — 수집 전이라 실샘플 없음, FE는 방어적 렌더):** BE "매물 목록(+판정 join)" 설명 기준 예상 필드 = `{ id, complex_name?, dong?, deal_type?, area?, price?/deposit?/monthly?(만원), safety_tier?, valuation_tier?, ratio?, median?, regulation_flags?:[], reasons?:[], matched?, url? }`. 존재하는 필드만 렌더하고 없는 필드는 생략. 수집 실행 후 실데이터로 필드명 확인·조정(구현 단계 verify).
|
||||||
|
|
||||||
|
> ⚠️ **`regulation_flags`/`reasons`는 이미 배열(list)로 파싱되어 옴 — `JSON.parse` 금지, 그대로 렌더.** (BE 명시. Subscription의 옛 `JSON.parse` 패턴 답습 금지.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 배치 & 라우팅
|
||||||
|
|
||||||
|
- 신규 라우트 `realestate/listings` → `Listings` (routes.jsx `appRoutes`에 추가, `lazy` import). `realestate/property`(RealEstate) 서브라우트 패턴과 동일.
|
||||||
|
- 교차 링크: Subscription(청약) 헤더에 "실매물 →"(`/realestate/listings`), Listings 헤더에 "청약으로"(`/realestate`). 홈 허브 `realestate` 카드 description은 유지(선택적으로 "매물" 언급).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 컴포넌트 구조 (전용 모듈 — 접근안 A)
|
||||||
|
|
||||||
|
기존 Subscription.jsx(1642줄)에 얹지 않고 독립 모듈. 각 파일 단일 책임.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/listings/
|
||||||
|
├── Listings.jsx # 페이지 shell + 내부 탭바(매물/판정도구/조건) + useIsMobile
|
||||||
|
├── Listings.css # lst-* (Subscription의 --text-*/--surface/--line/--radius-* 토큰 재사용)
|
||||||
|
├── components/
|
||||||
|
│ ├── ListingsTab.jsx # 매물 목록 + 필터(동/거래유형/tier/매칭만) + 페이지네이션 + tier 뱃지 + 수집
|
||||||
|
│ ├── ToolsTab.jsx # 안전마진 체커 카드 + 예산 계산기 카드 (입력→결과, disclaimer)
|
||||||
|
│ └── CriteriaTab.jsx # 조건 편집 폼 (equity·annual_income 포함, 부분 PUT)
|
||||||
|
├── listingsUtils.js # 순수헬퍼 (tier 매핑·금액 포맷)
|
||||||
|
└── listingsUtils.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
내부 탭 3개(기능 4종 수용): **매물 / 판정 도구(안전마진+예산) / 조건**.
|
||||||
|
|
||||||
|
### 4.1 `listingsUtils.js` (순수헬퍼 — 테스트 대상)
|
||||||
|
```
|
||||||
|
SAFETY_TIER_META = { 안전:{emoji:'🟢',color}, 주의:{emoji:'🟡',color}, 위험:{emoji:'🔴',color}, 보류:{emoji:'⚪',color} }
|
||||||
|
VALUATION_TIER_META = { 저평가:{'🟢'}, 시세:{'🟡'}, 고가:{'🔴'}, 보류:{'⚪'} }
|
||||||
|
tierMeta(kind, tier) // kind: 'safety'|'valuation'; 미정의 tier → 회색⚪ + 원문
|
||||||
|
formatMoney(만원) // int(만원) → "X억 Y만"/"X억"/"X만" 정밀 표기(9999→"9,999만", 10000→"1억", 15000→"1억 5,000만"), null→'-'
|
||||||
|
formatRatio(ratio) // 0.812 → "81.2%", null→'-'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 컴포넌트 데이터 규칙
|
||||||
|
- 응답 방어적 파싱: `data.listings ?? []`, `data.matches ?? []`, `data.criteria ?? data`.
|
||||||
|
- `regulation_flags`/`reasons`: 배열 그대로 `.map` (JSON.parse 금지).
|
||||||
|
- 어떤 필드든 객체면 직접 JSX 렌더 금지(watchlist detail 교훈) — tier/금액/문자열만 렌더, 배열은 칩/리스트.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 레이어 (`src/api.js` 추가)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── Realestate Listings / 안전마진 / 예산 ──
|
||||||
|
export const getListings = ({dong,deal_type,tier,matched_only,page=1,size=20}={}) => { /* querystring, 빈값 제외 */ };
|
||||||
|
export const collectListings = () => apiPost('/api/realestate/listings/collect');
|
||||||
|
export const getListingsCollectStatus = () => apiGet('/api/realestate/listings/collect/status');
|
||||||
|
export const getListingsCriteria = () => apiGet('/api/realestate/listings/criteria');
|
||||||
|
export const putListingsCriteria = (body) => apiPut('/api/realestate/listings/criteria', body); // 부분 업데이트
|
||||||
|
export const getListingsMatches = () => apiGet('/api/realestate/listings/matches');
|
||||||
|
export const safetyCheck = (body) => apiPost('/api/realestate/safety-check', body);
|
||||||
|
export const budgetCalc = (body) => apiPost('/api/realestate/budget', body);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UX / 안전장치 (BE 강조)
|
||||||
|
|
||||||
|
- **disclaimer 항상 노출**: safety-check·budget 결과 카드 하단, matches/판정 영역에 disclaimer 문자열 표시. 등기부 선순위·토허·정책수치 근사 경고 → 잘못된 금융/안전 조언 방지. disclaimer가 응답에 있으면 반드시 렌더.
|
||||||
|
- **보류⚪**: 표본<3 등 판정 불가 시 tier="보류" 그대로 표시(회색).
|
||||||
|
- **금액 만원 단위**: 입력·표시 모두 만원. 큰 값은 억 환산 표시.
|
||||||
|
- **is_toheo(토지거래허가)/regulation_flags**: budget/safety 결과에 플래그 칩으로 표시.
|
||||||
|
- **조건 저장**: 부분 PUT(변경 필드만). equity·annual_income 미입력 시 null 유지(매칭에 미반영됨을 힌트로 안내).
|
||||||
|
- 로딩/에러/빈 상태(수집 전 `never_run`이면 "수집을 실행해 매물을 모아보세요" 안내).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 스타일
|
||||||
|
|
||||||
|
`Listings.css`에 `lst-*` 프리픽스. Subscription의 CSS 변수(`--text-bright/--text-dim/--text-muted/--surface/--surface-raised/--line/--radius-sm`)·다크 테마 재사용. tier 뱃지 색은 판정 규칙 색(🟢#34d399/🟡#f59e0b/🔴#f87171/⚪#94a3b8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테스트 (TDD)
|
||||||
|
|
||||||
|
`listingsUtils.test.js` — 순수헬퍼:
|
||||||
|
1. `tierMeta('safety', ...)` 4종 + `tierMeta('valuation', ...)` 4종 이모지·색.
|
||||||
|
2. 미정의 tier → 회색⚪ + 원문 폴백.
|
||||||
|
3. `formatMoney`: 만/억 경계(9999→"9,999만", 10000→"1억", 15000→"1억 5,000만"), null→'-'.
|
||||||
|
4. `formatRatio`: 0.812→"81.2%", null→'-'.
|
||||||
|
|
||||||
|
컴포넌트는 스모크(빈 상태 렌더 + 객체 필드 크래시 없음) + 빌드/lint 통과. safety-check/budget 폼 제출→결과 렌더는 mock 훅으로 스모크 가능(선택).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 완료 기준 (Acceptance)
|
||||||
|
|
||||||
|
- [ ] `/realestate/listings` 라우트 노출, Subscription↔Listings 교차 링크.
|
||||||
|
- [ ] 매물 탭: 목록/필터/페이지네이션/tier 뱃지/수집 동작(빈 상태 안내 포함).
|
||||||
|
- [ ] 판정 도구 탭: 안전마진·예산 입력→결과 + **disclaimer 노출**.
|
||||||
|
- [ ] 조건 탭: criteria GET/부분 PUT, equity·annual_income 입력.
|
||||||
|
- [ ] `regulation_flags`/`reasons` 배열 그대로 렌더(JSON.parse 없음).
|
||||||
|
- [ ] `listingsUtils.test.js` 통과, `npm run lint`·`npm run build` 통과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 리스크 / 오픈 이슈
|
||||||
|
|
||||||
|
- **매물 아이템 실스키마 미확정**: 수집(`collect`)이 아직 안 돌아 `/listings`가 빈 배열. FE는 방어적 렌더(존재 필드만)로 대응하고, 수집 실행 후 실데이터로 필드명 확인·조정(구현 verify 단계). 필요 시 BE에 아이템 필드 목록 확인 요청.
|
||||||
|
- **safety-check dong_code**: 동 코드(예 "11590")/이름 매핑 필요 시 criteria.dongs 활용. 입력은 동 이름 select + 선택적 dong_code.
|
||||||
|
- **matches vs listings(matched_only)**: 매칭 결과는 `matched_only=true` 필터 또는 `/matches` 중 택1 — v1은 매물 탭의 "매칭만" 토글로 통일, `/matches`는 보조.
|
||||||
@@ -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).
|
||||||
20
src/api.js
20
src/api.js
@@ -862,3 +862,23 @@ export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
|||||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||||
|
|
||||||
|
// ── Realestate Listings / 안전마진 / 예산 (실매물) ──
|
||||||
|
// GET /api/realestate/listings → { listings: [...] } (+판정 join)
|
||||||
|
export const getListings = ({ dong, deal_type, tier, matched_only, page = 1, size = 20 } = {}) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (dong) q.set('dong', dong);
|
||||||
|
if (deal_type) q.set('deal_type', deal_type);
|
||||||
|
if (tier) q.set('tier', tier);
|
||||||
|
if (matched_only) q.set('matched_only', 'true');
|
||||||
|
q.set('page', String(page));
|
||||||
|
q.set('size', String(size));
|
||||||
|
return apiGet(`/api/realestate/listings?${q.toString()}`);
|
||||||
|
};
|
||||||
|
export const collectListings = () => apiPost('/api/realestate/listings/collect');
|
||||||
|
export const getListingsCollectStatus = () => apiGet('/api/realestate/listings/collect/status');
|
||||||
|
export const getListingsCriteria = () => apiGet('/api/realestate/listings/criteria');
|
||||||
|
export const putListingsCriteria = (body) => apiPut('/api/realestate/listings/criteria', body);
|
||||||
|
export const getListingsMatches = () => apiGet('/api/realestate/listings/matches');
|
||||||
|
export const safetyCheck = (body) => apiPost('/api/realestate/safety-check', body);
|
||||||
|
export const budgetCalc = (body) => apiPost('/api/realestate/budget', body);
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export function workerStateLabel(w) {
|
|||||||
return '장중';
|
return '장중';
|
||||||
case 'market_closed':
|
case 'market_closed':
|
||||||
return '휴장';
|
return '휴장';
|
||||||
|
case 'fetching':
|
||||||
|
return '수집 중';
|
||||||
default:
|
default:
|
||||||
return '온라인';
|
return '온라인';
|
||||||
}
|
}
|
||||||
@@ -54,6 +56,7 @@ export const WORKER_TITLES = {
|
|||||||
'insta-render': 'Insta Render',
|
'insta-render': 'Insta Render',
|
||||||
'task-watcher': 'Task Watcher',
|
'task-watcher': 'Task Watcher',
|
||||||
ai_trade: 'AI Trade',
|
ai_trade: 'AI Trade',
|
||||||
|
'naver-fetch': 'Naver Fetch',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function workerTitle(name) {
|
export function workerTitle(name) {
|
||||||
@@ -65,5 +68,6 @@ export function kindLabel(kind) {
|
|||||||
if (kind === 'render') return '렌더 워커';
|
if (kind === 'render') return '렌더 워커';
|
||||||
if (kind === 'watcher') return '작업 감시';
|
if (kind === 'watcher') return '작업 감시';
|
||||||
if (kind === 'trader') return '트레이딩';
|
if (kind === 'trader') return '트레이딩';
|
||||||
|
if (kind === 'fetcher') return '수집 워커';
|
||||||
return kind || '';
|
return kind || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle } from './statusVisual';
|
import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle, kindLabel } from './statusVisual';
|
||||||
|
|
||||||
describe('statusVisual', () => {
|
describe('statusVisual', () => {
|
||||||
it('maps link status to theme colors', () => {
|
it('maps link status to theme colors', () => {
|
||||||
@@ -22,6 +22,18 @@ describe('statusVisual', () => {
|
|||||||
expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중');
|
expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('labels a fetching worker as collecting', () => {
|
||||||
|
expect(workerStateLabel({ alive: true, state: 'fetching' })).toBe('수집 중');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps kind to a one-line role label', () => {
|
||||||
|
expect(kindLabel('render')).toBe('렌더 워커');
|
||||||
|
expect(kindLabel('watcher')).toBe('작업 감시');
|
||||||
|
expect(kindLabel('trader')).toBe('트레이딩');
|
||||||
|
expect(kindLabel('fetcher')).toBe('수집 워커');
|
||||||
|
expect(kindLabel('unknown')).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
it('derives worker status with dead-letter and paused precedence', () => {
|
it('derives worker status with dead-letter and paused precedence', () => {
|
||||||
expect(workerStatus({ alive: false })).toBe('down');
|
expect(workerStatus({ alive: false })).toBe('down');
|
||||||
expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused');
|
expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused');
|
||||||
@@ -37,6 +49,7 @@ describe('statusVisual', () => {
|
|||||||
it('humanizes worker names', () => {
|
it('humanizes worker names', () => {
|
||||||
expect(workerTitle('insta-render')).toBe('Insta Render');
|
expect(workerTitle('insta-render')).toBe('Insta Render');
|
||||||
expect(workerTitle('ai_trade')).toBe('AI Trade');
|
expect(workerTitle('ai_trade')).toBe('AI Trade');
|
||||||
|
expect(workerTitle('naver-fetch')).toBe('Naver Fetch');
|
||||||
expect(workerTitle('unknown-x')).toBe('unknown-x');
|
expect(workerTitle('unknown-x')).toBe('unknown-x');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
65
src/pages/listings/Listings.css
Normal file
65
src/pages/listings/Listings.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/* 실매물 매물·안전마진 (lst-*) — Subscription 다크 토큰 재사용 */
|
||||||
|
.lst { display: grid; gap: 18px; padding: 20px; max-width: 1100px; margin: 0 auto; }
|
||||||
|
.lst-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.lst-kicker { margin: 0 0 4px; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: #f43f5e; }
|
||||||
|
.lst-header h1 { margin: 0; font-size: 24px; color: var(--text-bright); }
|
||||||
|
.lst-sub { margin: 6px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.lst-tabbar { display: flex; gap: 6px; border-bottom: 1px solid var(--line); }
|
||||||
|
.lst-tabbtn { border: none; background: none; color: var(--text-dim); padding: 10px 16px; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
|
||||||
|
.lst-tabbtn.is-active { color: var(--text-bright); border-bottom-color: #f43f5e; }
|
||||||
|
|
||||||
|
.lst-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.lst-toolbar__right { display: flex; gap: 6px; }
|
||||||
|
.lst-filter { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.lst-filter-btn { border: 1px solid var(--line); background: var(--surface); color: var(--text-dim); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; text-decoration: none; }
|
||||||
|
.lst-filter-btn.is-active { color: var(--text-bright); border-color: #f43f5e; }
|
||||||
|
.lst-filter-btn.is-primary { color: #fff; background: #f43f5e; border-color: #f43f5e; }
|
||||||
|
.lst-filter-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.lst-status { font-size: 12px; color: var(--text-muted); margin: 0; }
|
||||||
|
.lst-error { color: #f9b6b1; border: 1px solid rgba(249,182,177,0.4); border-radius: 12px; padding: 10px; background: rgba(249,182,177,0.1); margin: 0; font-size: 13px; }
|
||||||
|
.lst-ok { color: #34d399; font-size: 12px; }
|
||||||
|
.lst-empty { color: var(--text-muted); text-align: center; padding: 32px 0; }
|
||||||
|
|
||||||
|
.lst-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||||
|
.lst-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px 14px; display: grid; gap: 6px; }
|
||||||
|
.lst-card__head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.lst-tier { font-size: 12px; font-weight: 700; }
|
||||||
|
.lst-card__name { font-size: 14px; color: var(--text-bright); }
|
||||||
|
.lst-card__deal { font-size: 11px; color: #60a5fa; }
|
||||||
|
.lst-card__dong { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.lst-card__body { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.lst-card__price { color: #f59e0b; font-weight: 600; }
|
||||||
|
.lst-card__reasons { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.lst-flags { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.lst-flag { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(244,63,94,0.12); color: #f87171; }
|
||||||
|
|
||||||
|
.lst-tools { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||||
|
.lst-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 16px; display: grid; gap: 10px; }
|
||||||
|
.lst-panel__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.lst-panel__actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.lst-panel__title { margin: 0; font-size: 15px; color: var(--text-bright); }
|
||||||
|
.lst-panel__sub { margin: 0; font-size: 12px; color: var(--text-muted); }
|
||||||
|
.lst-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.lst-input { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: inherit; font-size: 13px; min-width: 100px; flex: 1 1 100px; }
|
||||||
|
.lst-check { font-size: 12px; color: var(--text-dim); display: inline-flex; gap: 4px; align-items: center; }
|
||||||
|
.lst-chks { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.lst-result { display: grid; gap: 8px; padding-top: 6px; border-top: 1px solid var(--line); }
|
||||||
|
.lst-result__row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; align-items: center; }
|
||||||
|
.lst-result__block { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; font-size: 13px; }
|
||||||
|
.lst-result__block strong { color: var(--text-bright); }
|
||||||
|
.lst-note { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.lst-disclaimer { font-size: 11px; color: #cbd5e1; margin: 0; line-height: 1.45; }
|
||||||
|
|
||||||
|
.lst-criteria { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||||
|
.lst-field { display: grid; gap: 4px; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.lst-field > span { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.lst-field--chks { grid-column: 1 / -1; display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
.lst-foot-disclaimer { font-size: 11px; color: #64748b; text-align: center; margin: 8px 0 0; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lst { padding: 12px; }
|
||||||
|
.lst-tools { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
46
src/pages/listings/Listings.jsx
Normal file
46
src/pages/listings/Listings.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import ListingsTab from './components/ListingsTab';
|
||||||
|
import ToolsTab from './components/ToolsTab';
|
||||||
|
import CriteriaTab from './components/CriteriaTab';
|
||||||
|
import './Listings.css';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'listings', label: '매물' },
|
||||||
|
{ id: 'tools', label: '판정 도구' },
|
||||||
|
{ id: 'criteria', label: '조건' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Listings = () => {
|
||||||
|
const [tab, setTab] = useState('listings');
|
||||||
|
return (
|
||||||
|
<div className="lst">
|
||||||
|
<header className="lst-header">
|
||||||
|
<div>
|
||||||
|
<p className="lst-kicker">실매물 · 안전마진</p>
|
||||||
|
<h1>매물 · 안전마진</h1>
|
||||||
|
<p className="lst-sub">매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.</p>
|
||||||
|
</div>
|
||||||
|
<Link className="lst-filter-btn" to="/realestate">청약으로 →</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lst-tabbar">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button key={t.id} type="button"
|
||||||
|
className={`lst-tabbtn ${tab === t.id ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'listings' && <ListingsTab />}
|
||||||
|
{tab === 'tools' && <ToolsTab />}
|
||||||
|
{tab === 'criteria' && <CriteriaTab />}
|
||||||
|
|
||||||
|
<p className="lst-foot-disclaimer">
|
||||||
|
※ 모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Listings;
|
||||||
19
src/pages/listings/Listings.test.jsx
Normal file
19
src/pages/listings/Listings.test.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('./components/ListingsTab.jsx', () => ({ default: () => <div>LISTINGS_TAB</div> }));
|
||||||
|
vi.mock('./components/ToolsTab.jsx', () => ({ default: () => <div>TOOLS_TAB</div> }));
|
||||||
|
vi.mock('./components/CriteriaTab.jsx', () => ({ default: () => <div>CRITERIA_TAB</div> }));
|
||||||
|
vi.mock('react-router-dom', () => ({ Link: ({ children }) => <a>{children}</a> }));
|
||||||
|
import Listings from './Listings.jsx';
|
||||||
|
|
||||||
|
describe('Listings', () => {
|
||||||
|
it('기본 매물 탭 렌더 + 탭 전환', () => {
|
||||||
|
render(<Listings />);
|
||||||
|
expect(screen.getByText('LISTINGS_TAB')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '판정 도구' }));
|
||||||
|
expect(screen.getByText('TOOLS_TAB')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '조건' }));
|
||||||
|
expect(screen.getByText('CRITERIA_TAB')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
148
src/pages/listings/components/CriteriaTab.jsx
Normal file
148
src/pages/listings/components/CriteriaTab.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { getListingsCriteria, putListingsCriteria } from '../../../api';
|
||||||
|
|
||||||
|
const DEAL_TYPES = ['전세', '반전세', '매매'];
|
||||||
|
const HOUSE_TYPES = ['아파트', '오피스텔', '빌라'];
|
||||||
|
const SAFETY_TIERS = ['', '안전', '주의', '위험'];
|
||||||
|
|
||||||
|
const toList = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||||
|
const numOrNull = (v) => (v === '' || v == null ? null : Number(v));
|
||||||
|
|
||||||
|
const CriteriaTab = () => {
|
||||||
|
const [c, setC] = useState(null); // 편집 중 폼 상태 (dongs/house_types는 문자열/배열)
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getListingsCriteria();
|
||||||
|
setC({ ...data, dongs: (data.dongs ?? []).join(', ') });
|
||||||
|
} catch (e) {
|
||||||
|
setMsg('불러오기 실패: ' + (e?.message ?? e));
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (k, v) => setC((prev) => ({ ...prev, [k]: v }));
|
||||||
|
const toggleArr = (k, val) => setC((prev) => {
|
||||||
|
const arr = Array.isArray(prev[k]) ? prev[k] : [];
|
||||||
|
return { ...prev, [k]: arr.includes(val) ? arr.filter((x) => x !== val) : [...arr, val] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true); setMsg('');
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
dongs: toList(c.dongs),
|
||||||
|
deal_types: c.deal_types ?? [],
|
||||||
|
house_types: c.house_types ?? [],
|
||||||
|
max_deposit: numOrNull(c.max_deposit),
|
||||||
|
max_sale_price: numOrNull(c.max_sale_price),
|
||||||
|
min_area: numOrNull(c.min_area),
|
||||||
|
equity: numOrNull(c.equity),
|
||||||
|
annual_income: numOrNull(c.annual_income),
|
||||||
|
min_safety_tier: c.min_safety_tier || null,
|
||||||
|
notify_enabled: c.notify_enabled ? 1 : 0,
|
||||||
|
is_homeless: c.is_homeless ? 1 : 0,
|
||||||
|
is_householder: c.is_householder ? 1 : 0,
|
||||||
|
is_first_home: c.is_first_home ? 1 : 0,
|
||||||
|
};
|
||||||
|
const updated = await putListingsCriteria(payload);
|
||||||
|
setC({ ...updated, dongs: (updated.dongs ?? []).join(', ') });
|
||||||
|
setMsg('저장 완료');
|
||||||
|
setTimeout(() => setMsg(''), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg('저장 실패: ' + (e?.message ?? e));
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="lst-empty">불러오는 중…</p>;
|
||||||
|
if (!c) return <p className="lst-error">{msg || '조건을 불러오지 못했습니다.'}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lst-panel">
|
||||||
|
<div className="lst-panel__head">
|
||||||
|
<div>
|
||||||
|
<h3 className="lst-panel__title">매물 매칭 조건</h3>
|
||||||
|
<p className="lst-panel__sub">자기자본·연소득을 넣어야 매매 예산상한·전세 max_deposit이 매칭에 반영됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="lst-panel__actions">
|
||||||
|
{msg && <span className={msg.startsWith('저장 완료') ? 'lst-ok' : 'lst-error'}>{msg}</span>}
|
||||||
|
<button className="lst-filter-btn is-primary" onClick={save} disabled={saving}>
|
||||||
|
{saving ? '저장 중…' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lst-criteria">
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>관심 동 (쉼표 구분)</span>
|
||||||
|
<input className="lst-input" value={c.dongs} onChange={(e) => set('dongs', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="lst-field">
|
||||||
|
<span>거래 유형</span>
|
||||||
|
<div className="lst-chks">
|
||||||
|
{DEAL_TYPES.map((d) => (
|
||||||
|
<label key={d} className="lst-check"><input type="checkbox"
|
||||||
|
checked={(c.deal_types ?? []).includes(d)} onChange={() => toggleArr('deal_types', d)} /> {d}</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lst-field">
|
||||||
|
<span>주택 유형</span>
|
||||||
|
<div className="lst-chks">
|
||||||
|
{HOUSE_TYPES.map((h) => (
|
||||||
|
<label key={h} className="lst-check"><input type="checkbox"
|
||||||
|
checked={(c.house_types ?? []).includes(h)} onChange={() => toggleArr('house_types', h)} /> {h}</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>전세 최대 보증금 (만원)</span>
|
||||||
|
<input className="lst-input" value={c.max_deposit ?? ''} onChange={(e) => set('max_deposit', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>매매 예산 상한 (만원)</span>
|
||||||
|
<input className="lst-input" value={c.max_sale_price ?? ''} onChange={(e) => set('max_sale_price', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>최소 면적 (m²)</span>
|
||||||
|
<input className="lst-input" value={c.min_area ?? ''} onChange={(e) => set('min_area', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>자기자본 (만원)</span>
|
||||||
|
<input className="lst-input" value={c.equity ?? ''} onChange={(e) => set('equity', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>연소득 (만원)</span>
|
||||||
|
<input className="lst-input" value={c.annual_income ?? ''} onChange={(e) => set('annual_income', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="lst-field">
|
||||||
|
<span>최소 안전등급</span>
|
||||||
|
<select className="lst-input" value={c.min_safety_tier ?? ''} onChange={(e) => set('min_safety_tier', e.target.value)}>
|
||||||
|
{SAFETY_TIERS.map((t) => <option key={t} value={t}>{t || '제한 없음'}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="lst-field lst-field--chks">
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={!!c.is_homeless}
|
||||||
|
onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={!!c.is_householder}
|
||||||
|
onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={!!c.is_first_home}
|
||||||
|
onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={!!c.notify_enabled}
|
||||||
|
onChange={(e) => set('notify_enabled', e.target.checked)} /> 텔레그램 알림</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CriteriaTab;
|
||||||
31
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
31
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('../../../api', () => ({ getListingsCriteria: vi.fn(), putListingsCriteria: vi.fn() }));
|
||||||
|
import { getListingsCriteria } from '../../../api';
|
||||||
|
import CriteriaTab from './CriteriaTab.jsx';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getListingsCriteria.mockResolvedValue({
|
||||||
|
id: 1, dongs: ['신대방동', '상도동'], deal_types: ['전세', '매매'], max_deposit: 32000,
|
||||||
|
max_sale_price: null, min_area: 40, house_types: ['아파트'], min_safety_tier: null,
|
||||||
|
notify_enabled: 1, equity: null, annual_income: null, is_homeless: 1, is_householder: 0, is_first_home: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CriteriaTab', () => {
|
||||||
|
it('criteria를 로드해 자기자본·연소득 입력과 동 목록을 렌더', async () => {
|
||||||
|
render(<CriteriaTab />);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/자기자본/)).toBeInTheDocument());
|
||||||
|
expect(screen.getByLabelText(/연소득/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('신대방동, 상도동')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('로드 실패 시 무한 로딩 대신 에러를 표시', async () => {
|
||||||
|
getListingsCriteria.mockRejectedValue(new Error('HTTP 500'));
|
||||||
|
render(<CriteriaTab />);
|
||||||
|
await waitFor(() => expect(screen.getByText(/불러오지 못했|HTTP 500/)).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('불러오는 중…')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/pages/listings/components/ListingsTab.jsx
Normal file
138
src/pages/listings/components/ListingsTab.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { getListings, collectListings, getListingsCollectStatus } from '../../../api';
|
||||||
|
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';
|
||||||
|
|
||||||
|
const DEAL_TYPES = ['전체', '전세', '반전세', '매매'];
|
||||||
|
|
||||||
|
const ListingCard = ({ l }) => {
|
||||||
|
const isSale = l.deal_type === '매매';
|
||||||
|
const tv = isSale ? l.valuation_tier : l.safety_tier;
|
||||||
|
const meta = tierMeta(isSale ? 'valuation' : 'safety', tv);
|
||||||
|
const price = l.price ?? l.deposit ?? l.amount;
|
||||||
|
const flags = Array.isArray(l.regulation_flags) ? l.regulation_flags : [];
|
||||||
|
const reasons = Array.isArray(l.reasons) ? l.reasons : [];
|
||||||
|
return (
|
||||||
|
<div className="lst-card">
|
||||||
|
<div className="lst-card__head">
|
||||||
|
{tv && <span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>}
|
||||||
|
<strong className="lst-card__name">{l.complex_name || l.name || l.dong || '(매물)'}</strong>
|
||||||
|
{l.deal_type && <span className="lst-card__deal">{l.deal_type}</span>}
|
||||||
|
{l.dong && <span className="lst-card__dong">{l.dong}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="lst-card__body">
|
||||||
|
{price != null && <span className="lst-card__price">{formatMoney(price)}</span>}
|
||||||
|
{l.area != null && <span className="lst-card__area">{l.area}m²</span>}
|
||||||
|
{l.ratio != null && <span className="lst-card__ratio">비율 {formatRatio(l.ratio)}</span>}
|
||||||
|
</div>
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<div className="lst-flags">{flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||||
|
)}
|
||||||
|
{reasons.length > 0 && <div className="lst-card__reasons">{reasons.join(' · ')}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListingsTab = () => {
|
||||||
|
const [listings, setListings] = useState([]);
|
||||||
|
const [dealType, setDealType] = useState('전체');
|
||||||
|
const [matchedOnly, setMatchedOnly] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [collectStatus, setCollectStatus] = useState(null);
|
||||||
|
const [collecting, setCollecting] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await getListings({
|
||||||
|
deal_type: dealType === '전체' ? undefined : dealType,
|
||||||
|
matched_only: matchedOnly,
|
||||||
|
size: 50,
|
||||||
|
});
|
||||||
|
setListings(Array.isArray(data) ? data : (data.listings ?? []));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
setListings([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
try { setCollectStatus(await getListingsCollectStatus()); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [dealType, matchedOnly]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(() => { loadStatus(); }, []);
|
||||||
|
|
||||||
|
const handleCollect = async () => {
|
||||||
|
setCollecting(true);
|
||||||
|
setError('');
|
||||||
|
const prevAt = collectStatus?.collected_at ?? null;
|
||||||
|
try {
|
||||||
|
await collectListings();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
setCollecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tries = 0;
|
||||||
|
const poll = async () => {
|
||||||
|
tries += 1;
|
||||||
|
let st = null;
|
||||||
|
try { st = await getListingsCollectStatus(); } catch { /* noop */ }
|
||||||
|
if (st) setCollectStatus(st);
|
||||||
|
const done = st && (st.collected_at !== prevAt || st.error);
|
||||||
|
if (done || tries >= 12) {
|
||||||
|
setCollecting(false);
|
||||||
|
load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.setTimeout(poll, 5000);
|
||||||
|
};
|
||||||
|
window.setTimeout(poll, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lst-tab">
|
||||||
|
<div className="lst-toolbar">
|
||||||
|
<div className="lst-filter">
|
||||||
|
{DEAL_TYPES.map((d) => (
|
||||||
|
<button key={d} type="button" className={`lst-filter-btn ${dealType === d ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setDealType(d)}>{d}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="lst-toolbar__right">
|
||||||
|
<button type="button" className={`lst-filter-btn ${matchedOnly ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setMatchedOnly((v) => !v)}>매칭만</button>
|
||||||
|
<button type="button" className="lst-filter-btn is-primary" onClick={handleCollect} disabled={collecting}>
|
||||||
|
{collecting ? '수집 중…' : '수집 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collectStatus && (
|
||||||
|
<p className="lst-status">
|
||||||
|
수집 상태: {collectStatus.status === 'never_run' ? '미실행' : collectStatus.status}
|
||||||
|
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
|
||||||
|
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="lst-error">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="lst-empty">불러오는 중…</p>
|
||||||
|
) : listings.length === 0 ? (
|
||||||
|
<p className="lst-empty">조건에 맞는 매물이 없습니다. ‘수집 실행’으로 매물을 모아보세요.</p>
|
||||||
|
) : (
|
||||||
|
<div className="lst-grid">
|
||||||
|
{listings.map((l) => <ListingCard key={l.id ?? `${l.complex_name}-${l.dong}`} l={l} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingsTab;
|
||||||
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
getListings: vi.fn(),
|
||||||
|
collectListings: vi.fn(),
|
||||||
|
getListingsCollectStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
import { getListings, getListingsCollectStatus } from '../../../api';
|
||||||
|
import ListingsTab from './ListingsTab.jsx';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getListingsCollectStatus.mockResolvedValue({ status: 'never_run' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListingsTab', () => {
|
||||||
|
it('빈 목록이면 안내 문구', async () => {
|
||||||
|
getListings.mockResolvedValue({ listings: [] });
|
||||||
|
render(<ListingsTab />);
|
||||||
|
await waitFor(() => expect(screen.getByText(/매물이 없습니다/)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('매물+판정 tier·배열 필드를 크래시 없이 렌더 (매매=valuation, 배열 그대로)', async () => {
|
||||||
|
getListings.mockResolvedValue({ listings: [{
|
||||||
|
id: 1, complex_name: '상도더샵', dong: '상도동', deal_type: '매매',
|
||||||
|
price: 95000, valuation_tier: '고가', ratio: 1.08,
|
||||||
|
regulation_flags: ['토지거래허가'], reasons: ['호가가 최근 실거래 대비 8% 높음'],
|
||||||
|
}] });
|
||||||
|
render(<ListingsTab />);
|
||||||
|
await waitFor(() => expect(screen.getByText('상도더샵')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/고가/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('토지거래허가')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/호가가 최근 실거래/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
src/pages/listings/components/ToolsTab.jsx
Normal file
161
src/pages/listings/components/ToolsTab.jsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { safetyCheck, budgetCalc } from '../../../api';
|
||||||
|
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';
|
||||||
|
|
||||||
|
const DEAL_TYPES = ['전세', '반전세', '매매'];
|
||||||
|
|
||||||
|
/* ── 안전마진 체커 ── */
|
||||||
|
const SafetyCard = () => {
|
||||||
|
const [form, setForm] = useState({ area: '', deal_type: '전세', amount: '', dong: '' });
|
||||||
|
const [res, setRes] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.area || !form.amount) return;
|
||||||
|
setLoading(true); setError(''); setRes(null);
|
||||||
|
try {
|
||||||
|
const r = await safetyCheck({
|
||||||
|
area: Number(form.area), deal_type: form.deal_type,
|
||||||
|
amount: Number(form.amount), dong: form.dong || undefined,
|
||||||
|
});
|
||||||
|
setRes(r);
|
||||||
|
} catch (e2) { setError(e2?.message ?? String(e2)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSale = form.deal_type === '매매';
|
||||||
|
const meta = res ? tierMeta(isSale ? 'valuation' : 'safety', res.tier) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lst-panel">
|
||||||
|
<h3 className="lst-panel__title">안전마진 체커</h3>
|
||||||
|
<p className="lst-panel__sub">면적·거래유형·금액으로 전세가율/호가율 적정성을 즉시 판정합니다.</p>
|
||||||
|
<form className="lst-form" onSubmit={submit}>
|
||||||
|
<input className="lst-input" placeholder="면적(m²)" value={form.area}
|
||||||
|
onChange={(e) => set('area', e.target.value)} />
|
||||||
|
<select className="lst-input" value={form.deal_type} onChange={(e) => set('deal_type', e.target.value)}>
|
||||||
|
{DEAL_TYPES.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
<input className="lst-input" placeholder="금액(만원)" value={form.amount}
|
||||||
|
onChange={(e) => set('amount', e.target.value)} />
|
||||||
|
<input className="lst-input" placeholder="동(선택)" value={form.dong}
|
||||||
|
onChange={(e) => set('dong', e.target.value)} />
|
||||||
|
<button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.area || !form.amount}>
|
||||||
|
{loading ? '판정 중…' : '판정'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="lst-error">{error}</p>}
|
||||||
|
{res && (
|
||||||
|
<div className="lst-result">
|
||||||
|
<div className="lst-result__row">
|
||||||
|
<span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>
|
||||||
|
{res.ratio != null && <span>비율 {formatRatio(res.ratio)}</span>}
|
||||||
|
{res.median != null && <span>중앙값 {formatMoney(res.median)}</span>}
|
||||||
|
{res.sample != null && <span>표본 {res.sample}건</span>}
|
||||||
|
{res.is_toheo && <span className="lst-flag">토지거래허가</span>}
|
||||||
|
</div>
|
||||||
|
{res.disclaimer && <p className="lst-disclaimer">※ {res.disclaimer}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 예산 계산기 ── */
|
||||||
|
const BudgetCard = () => {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
equity: '', annual_income: '', is_homeless: true, is_householder: true, is_first_home: false, target_dong: '',
|
||||||
|
});
|
||||||
|
const [res, setRes] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.equity) return;
|
||||||
|
setLoading(true); setError(''); setRes(null);
|
||||||
|
try {
|
||||||
|
const r = await budgetCalc({
|
||||||
|
equity: Number(form.equity),
|
||||||
|
annual_income: form.annual_income ? Number(form.annual_income) : null,
|
||||||
|
is_homeless: form.is_homeless ? 1 : 0,
|
||||||
|
is_householder: form.is_householder ? 1 : 0,
|
||||||
|
is_first_home: form.is_first_home ? 1 : 0,
|
||||||
|
target_dong: form.target_dong || undefined,
|
||||||
|
});
|
||||||
|
setRes(r);
|
||||||
|
} catch (e2) { setError(e2?.message ?? String(e2)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lst-panel">
|
||||||
|
<h3 className="lst-panel__title">예산 계산기</h3>
|
||||||
|
<p className="lst-panel__sub">자기자본·소득 기준 전세/매매 한도와 지역 규제를 계산합니다.</p>
|
||||||
|
<form className="lst-form" onSubmit={submit}>
|
||||||
|
<input className="lst-input" placeholder="자기자본(만원)" value={form.equity}
|
||||||
|
onChange={(e) => set('equity', e.target.value)} />
|
||||||
|
<input className="lst-input" placeholder="연소득(만원, 선택)" value={form.annual_income}
|
||||||
|
onChange={(e) => set('annual_income', e.target.value)} />
|
||||||
|
<input className="lst-input" placeholder="대상 동(선택)" value={form.target_dong}
|
||||||
|
onChange={(e) => set('target_dong', e.target.value)} />
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={form.is_homeless}
|
||||||
|
onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={form.is_householder}
|
||||||
|
onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
|
||||||
|
<label className="lst-check"><input type="checkbox" checked={form.is_first_home}
|
||||||
|
onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
|
||||||
|
<button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.equity}>
|
||||||
|
{loading ? '계산 중…' : '계산'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <p className="lst-error">{error}</p>}
|
||||||
|
{res && (
|
||||||
|
<div className="lst-result">
|
||||||
|
{res.jeonse && (
|
||||||
|
<div className="lst-result__block">
|
||||||
|
<strong>전세</strong>
|
||||||
|
<span>대출한도 {formatMoney(res.jeonse.loan_limit)}</span>
|
||||||
|
<span>최대 보증금 {formatMoney(res.jeonse.max_deposit)}</span>
|
||||||
|
{res.jeonse.notes && <span className="lst-note">{res.jeonse.notes}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.purchase && (
|
||||||
|
<div className="lst-result__block">
|
||||||
|
<strong>매매</strong>
|
||||||
|
{res.purchase.ltv_pct != null && <span>LTV {res.purchase.ltv_pct}%</span>}
|
||||||
|
<span>대출한도 {formatMoney(res.purchase.loan_cap)}</span>
|
||||||
|
<span>최대 매수가 {formatMoney(res.purchase.max_price)}</span>
|
||||||
|
{res.purchase.dsr_note && <span className="lst-note">{res.purchase.dsr_note}</span>}
|
||||||
|
{Array.isArray(res.purchase.regulation_flags) && res.purchase.regulation_flags.length > 0 && (
|
||||||
|
<div className="lst-flags">{res.purchase.regulation_flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.region && (
|
||||||
|
<div className="lst-result__block">
|
||||||
|
<strong>지역</strong>
|
||||||
|
{res.region.is_toheo && <span className="lst-flag">토지거래허가</span>}
|
||||||
|
{res.region.is_regulated && <span className="lst-flag">규제지역</span>}
|
||||||
|
{res.region.notes && <span className="lst-note">{res.region.notes}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.disclaimer && <p className="lst-disclaimer">※ {res.disclaimer}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolsTab = () => (
|
||||||
|
<div className="lst-tools">
|
||||||
|
<SafetyCard />
|
||||||
|
<BudgetCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ToolsTab;
|
||||||
44
src/pages/listings/components/ToolsTab.test.jsx
Normal file
44
src/pages/listings/components/ToolsTab.test.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() }));
|
||||||
|
import { safetyCheck, budgetCalc } from '../../../api';
|
||||||
|
import ToolsTab from './ToolsTab.jsx';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('ToolsTab', () => {
|
||||||
|
it('안전마진·예산 카드 헤딩과 disclaimer 안내를 렌더', () => {
|
||||||
|
render(<ToolsTab />);
|
||||||
|
expect(screen.getByText('안전마진 체커')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('예산 계산기')).toBeInTheDocument();
|
||||||
|
// 제출 버튼 존재
|
||||||
|
expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('안전마진 제출 → tier·비율·disclaimer 렌더 (객체 아님)', async () => {
|
||||||
|
safetyCheck.mockResolvedValue({ median: 45000, ratio: 0.72, tier: '안전', sample: 7, is_toheo: false, disclaimer: '등기부 선순위·근사치 주의' });
|
||||||
|
render(<ToolsTab />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/면적/), { target: { value: '84' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/금액/), { target: { value: '45000' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '판정' }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/등기부 선순위/)).toBeInTheDocument());
|
||||||
|
// "안전마진 체커" 헤딩과 구분하기 위해 "마진"이 뒤따르지 않는 "안전"만 매칭
|
||||||
|
expect(screen.getByText(/안전(?!마진)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('예산 제출 → 전세/매매 금액·disclaimer 렌더 (중첩 객체 필드별 렌더)', async () => {
|
||||||
|
budgetCalc.mockResolvedValue({
|
||||||
|
jeonse: { loan_limit: 30000, max_deposit: 40000, notes: '' },
|
||||||
|
purchase: { ltv_pct: 70, loan_cap: 50000, max_price: 90000, dsr_note: 'DSR 40%', regulation_flags: ['규제지역'] },
|
||||||
|
region: { is_toheo: true, is_regulated: true, notes: '토허 구역' },
|
||||||
|
disclaimer: '정책 수치는 근사치입니다',
|
||||||
|
});
|
||||||
|
render(<ToolsTab />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/자기자본/), { target: { value: '20000' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '계산' }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/정책 수치는 근사치/)).toBeInTheDocument());
|
||||||
|
// purchase.regulation_flags 와 region.is_regulated 플래그 둘 다 "규제지역" 텍스트를 렌더함
|
||||||
|
expect(screen.getAllByText('규제지역').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/pages/listings/listingsUtils.js
Normal file
40
src/pages/listings/listingsUtils.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* 실매물 판정 tier / 금액 포맷 순수 헬퍼 */
|
||||||
|
const C = { green: '#34d399', yellow: '#f59e0b', red: '#f87171', gray: '#94a3b8' };
|
||||||
|
|
||||||
|
export const SAFETY_TIER_META = Object.freeze({
|
||||||
|
'안전': { emoji: '🟢', color: C.green },
|
||||||
|
'주의': { emoji: '🟡', color: C.yellow },
|
||||||
|
'위험': { emoji: '🔴', color: C.red },
|
||||||
|
'보류': { emoji: '⚪', color: C.gray },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VALUATION_TIER_META = Object.freeze({
|
||||||
|
'저평가': { emoji: '🟢', color: C.green },
|
||||||
|
'시세': { emoji: '🟡', color: C.yellow },
|
||||||
|
'고가': { emoji: '🔴', color: C.red },
|
||||||
|
'보류': { emoji: '⚪', color: C.gray },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tierMeta = (kind, tier) => {
|
||||||
|
const map = kind === 'valuation' ? VALUATION_TIER_META : SAFETY_TIER_META;
|
||||||
|
if (Object.hasOwn(map, tier)) return { ...map[tier], label: tier };
|
||||||
|
return { emoji: '⚪', color: C.gray, label: tier ?? '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatMoney = (manwon) => {
|
||||||
|
if (manwon == null || manwon === '') return '-';
|
||||||
|
const n = Number(manwon);
|
||||||
|
if (Number.isNaN(n)) return '-';
|
||||||
|
const eok = Math.floor(n / 10000);
|
||||||
|
const rest = n % 10000;
|
||||||
|
if (eok > 0 && rest > 0) return `${eok}억 ${rest.toLocaleString('ko-KR')}만`;
|
||||||
|
if (eok > 0) return `${eok}억`;
|
||||||
|
return `${n.toLocaleString('ko-KR')}만`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRatio = (ratio) => {
|
||||||
|
if (ratio == null) return '-';
|
||||||
|
const n = Number(ratio);
|
||||||
|
if (Number.isNaN(n)) return '-';
|
||||||
|
return `${(n * 100).toFixed(1)}%`;
|
||||||
|
};
|
||||||
44
src/pages/listings/listingsUtils.test.js
Normal file
44
src/pages/listings/listingsUtils.test.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { tierMeta, formatMoney, formatRatio } from './listingsUtils.js';
|
||||||
|
|
||||||
|
describe('tierMeta', () => {
|
||||||
|
it('safety 4종 이모지·색·label', () => {
|
||||||
|
expect(tierMeta('safety', '안전')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '안전' });
|
||||||
|
expect(tierMeta('safety', '주의').emoji).toBe('🟡');
|
||||||
|
expect(tierMeta('safety', '위험').color).toBe('#f87171');
|
||||||
|
expect(tierMeta('safety', '보류').emoji).toBe('⚪');
|
||||||
|
});
|
||||||
|
it('valuation 4종', () => {
|
||||||
|
expect(tierMeta('valuation', '저평가')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '저평가' });
|
||||||
|
expect(tierMeta('valuation', '시세').emoji).toBe('🟡');
|
||||||
|
expect(tierMeta('valuation', '고가').color).toBe('#f87171');
|
||||||
|
});
|
||||||
|
it('미정의 tier는 회색⚪ + 원문 label, undefined는 빈 label', () => {
|
||||||
|
expect(tierMeta('safety', '없는거')).toMatchObject({ emoji: '⚪', color: '#94a3b8', label: '없는거' });
|
||||||
|
expect(tierMeta('valuation', undefined).label).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatMoney (만원 정밀 표기)', () => {
|
||||||
|
it('만/억 경계', () => {
|
||||||
|
expect(formatMoney(9999)).toBe('9,999만');
|
||||||
|
expect(formatMoney(10000)).toBe('1억');
|
||||||
|
expect(formatMoney(15000)).toBe('1억 5,000만');
|
||||||
|
expect(formatMoney(32000)).toBe('3억 2,000만');
|
||||||
|
});
|
||||||
|
it('null/빈값/NaN → -', () => {
|
||||||
|
expect(formatMoney(null)).toBe('-');
|
||||||
|
expect(formatMoney('')).toBe('-');
|
||||||
|
expect(formatMoney('abc')).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatRatio', () => {
|
||||||
|
it('비율 → 퍼센트 1자리', () => {
|
||||||
|
expect(formatRatio(0.812)).toBe('81.2%');
|
||||||
|
expect(formatRatio(1.05)).toBe('105.0%');
|
||||||
|
});
|
||||||
|
it('null → -', () => {
|
||||||
|
expect(formatRatio(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,761 +1,42 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
STATUS_CONFIG, TABS,
|
||||||
ResponsiveContainer, Cell,
|
} from './realEstateUtils';
|
||||||
} from 'recharts';
|
import useComplexes from './hooks/useComplexes';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
import ComplexCard from './components/ComplexCard';
|
||||||
|
import RightPanel from './components/RightPanel';
|
||||||
|
import ScheduleView from './components/ScheduleView';
|
||||||
|
import PriceAnalysis from './components/PriceAnalysis';
|
||||||
|
import ComplexModal from './components/ComplexModal';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './RealEstate.css';
|
import './RealEstate.css';
|
||||||
|
|
||||||
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
|
||||||
const SAMPLE_COMPLEXES = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: '래미안 원베일리',
|
|
||||||
address: '서울 서초구 반포동',
|
|
||||||
lat: 37.5065,
|
|
||||||
lng: 126.9942,
|
|
||||||
units: 2990,
|
|
||||||
types: ['59㎡', '84㎡', '114㎡'],
|
|
||||||
avgPricePerPyeong: 9500,
|
|
||||||
subscriptionStart: '2024-01-08',
|
|
||||||
subscriptionEnd: '2024-01-10',
|
|
||||||
resultDate: '2024-01-15',
|
|
||||||
status: '완료',
|
|
||||||
priority: 'high',
|
|
||||||
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: '올림픽파크 포레온',
|
|
||||||
address: '서울 강동구 둔촌동',
|
|
||||||
lat: 37.5284,
|
|
||||||
lng: 127.1340,
|
|
||||||
units: 12032,
|
|
||||||
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
|
||||||
avgPricePerPyeong: 3800,
|
|
||||||
subscriptionStart: '2022-12-05',
|
|
||||||
subscriptionEnd: '2022-12-07',
|
|
||||||
resultDate: '2022-12-12',
|
|
||||||
status: '완료',
|
|
||||||
priority: 'high',
|
|
||||||
tags: ['대단지', '역세권', '재건축'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '역대 최대 규모 재건축 단지.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: '힐스테이트 동탄',
|
|
||||||
address: '경기 화성시 동탄2신도시',
|
|
||||||
lat: 37.2001,
|
|
||||||
lng: 127.0724,
|
|
||||||
units: 1534,
|
|
||||||
types: ['59㎡', '74㎡', '84㎡'],
|
|
||||||
avgPricePerPyeong: 1850,
|
|
||||||
subscriptionStart: '2026-04-10',
|
|
||||||
subscriptionEnd: '2026-04-12',
|
|
||||||
resultDate: '2026-04-17',
|
|
||||||
status: '청약예정',
|
|
||||||
priority: 'normal',
|
|
||||||
tags: ['동탄2', '신도시', 'SRT'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: '롯데캐슬 마곡',
|
|
||||||
address: '서울 강서구 마곡동',
|
|
||||||
lat: 37.5626,
|
|
||||||
lng: 126.8295,
|
|
||||||
units: 868,
|
|
||||||
types: ['59㎡', '84㎡'],
|
|
||||||
avgPricePerPyeong: 4200,
|
|
||||||
subscriptionStart: '2026-03-20',
|
|
||||||
subscriptionEnd: '2026-03-22',
|
|
||||||
resultDate: '2026-03-27',
|
|
||||||
status: '청약중',
|
|
||||||
priority: 'high',
|
|
||||||
tags: ['마곡', '9호선', '공항철도'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
|
||||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
|
||||||
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
|
||||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
|
||||||
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
|
||||||
name: '', address: '', lat: '', lng: '',
|
|
||||||
units: '', types: '', avgPricePerPyeong: '',
|
|
||||||
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
|
||||||
status: '청약예정', priority: 'normal',
|
|
||||||
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TABS = ['목록', '일정', '분석'];
|
|
||||||
|
|
||||||
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
|
||||||
const formatDate = (d) => {
|
|
||||||
if (!d) return '-';
|
|
||||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (v) => {
|
|
||||||
if (!v) return '-';
|
|
||||||
return `${v.toLocaleString()}만원`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDDays = (dateStr) => {
|
|
||||||
if (!dateStr) return null;
|
|
||||||
const target = new Date(dateStr);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
|
||||||
if (diff === 0) return 'D-Day';
|
|
||||||
if (diff > 0) return `D-${diff}`;
|
|
||||||
return `D+${Math.abs(diff)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarkerIcon = (status, isSelected = false) => {
|
|
||||||
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
|
||||||
const size = isSelected ? 18 : 12;
|
|
||||||
return L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: `<div style="
|
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
|
||||||
background:${cfg.color};
|
|
||||||
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
|
||||||
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
|
||||||
transition:all 0.2s;
|
|
||||||
"></div>`,
|
|
||||||
iconSize: [size, size],
|
|
||||||
iconAnchor: [size / 2, size / 2],
|
|
||||||
popupAnchor: [0, -(size / 2 + 4)],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
|
||||||
const MapFlyTo = ({ position, zoom }) => {
|
|
||||||
const map = useMap();
|
|
||||||
useEffect(() => {
|
|
||||||
if (position) {
|
|
||||||
map.flyTo(position, zoom ?? 14, { duration: 1.0 });
|
|
||||||
}
|
|
||||||
}, [position, zoom, map]);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 단지 카드 ──────────────────────────────────────────────────────────────────
|
|
||||||
const ComplexCard = ({ complex, isSelected, onClick }) => {
|
|
||||||
const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료'];
|
|
||||||
const dday = getDDays(complex.subscriptionStart);
|
|
||||||
const isUpcoming = complex.status === '청약예정' || complex.status === '청약중';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
|
|
||||||
<div className="re-card__top">
|
|
||||||
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
|
||||||
{complex.status}
|
|
||||||
</span>
|
|
||||||
{complex.priority === 'high' && <span className="re-priority-star">★</span>}
|
|
||||||
</div>
|
|
||||||
<h3 className="re-card__name">{complex.name}</h3>
|
|
||||||
<p className="re-card__address">{complex.address}</p>
|
|
||||||
<div className="re-card__stats">
|
|
||||||
<span>{complex.units.toLocaleString()}세대</span>
|
|
||||||
<span className="re-card__dot">·</span>
|
|
||||||
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/평</span>
|
|
||||||
</div>
|
|
||||||
<div className="re-chip-group">
|
|
||||||
{complex.types.map((t) => (
|
|
||||||
<span key={t} className="re-chip">{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{isUpcoming && dday && (
|
|
||||||
<div className="re-card__dday" style={{ color: cfg.color }}>
|
|
||||||
청약 {dday}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 인라인 지도 + 단지 상세 패널 ──────────────────────────────────────────────
|
|
||||||
const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => {
|
|
||||||
const cfg = selectedComplex
|
|
||||||
? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료']
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const mapCenter = useMemo(() => {
|
|
||||||
if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng];
|
|
||||||
if (complexes.length === 0) return [37.5665, 126.9780];
|
|
||||||
return [
|
|
||||||
complexes.reduce((s, c) => s + c.lat, 0) / complexes.length,
|
|
||||||
complexes.reduce((s, c) => s + c.lng, 0) / complexes.length,
|
|
||||||
];
|
|
||||||
}, []); // 초기 중심값만 계산 (flyTo로 이후 이동)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="re-right-panel">
|
|
||||||
{/* ── 지도 ── */}
|
|
||||||
<div className="re-panel re-panel--map">
|
|
||||||
<div className="re-mini-map-wrap">
|
|
||||||
<MapContainer
|
|
||||||
key="inline-map"
|
|
||||||
center={mapCenter}
|
|
||||||
zoom={10}
|
|
||||||
className="re-map"
|
|
||||||
scrollWheelZoom
|
|
||||||
zoomControl={false}
|
|
||||||
>
|
|
||||||
<MapFlyTo
|
|
||||||
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
|
|
||||||
zoom={14}
|
|
||||||
/>
|
|
||||||
<TileLayer
|
|
||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
|
||||||
attribution='© <a href="https://carto.com">CartoDB</a>'
|
|
||||||
/>
|
|
||||||
{complexes.map((c) => (
|
|
||||||
<Marker
|
|
||||||
key={c.id}
|
|
||||||
position={[c.lat, c.lng]}
|
|
||||||
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
|
|
||||||
eventHandlers={{ click: () => onSelectComplex(c) }}
|
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
<div className="re-popup">
|
|
||||||
<strong>{c.name}</strong>
|
|
||||||
<span>{c.address}</span>
|
|
||||||
<span>{c.status} · {c.units.toLocaleString()}세대</span>
|
|
||||||
<span>{formatPrice(c.avgPricePerPyeong)}/평</span>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
))}
|
|
||||||
</MapContainer>
|
|
||||||
{selectedComplex && (
|
|
||||||
<div className="re-map-label">
|
|
||||||
<span style={{ color: cfg.color }}>●</span> {selectedComplex.name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 상세 패널 ── */}
|
|
||||||
{selectedComplex ? (
|
|
||||||
<div className="re-detail" key={selectedComplex.id}>
|
|
||||||
<div className="re-detail__header">
|
|
||||||
<div>
|
|
||||||
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
|
|
||||||
{selectedComplex.status}
|
|
||||||
</span>
|
|
||||||
<h2 className="re-detail__name">{selectedComplex.name}</h2>
|
|
||||||
<p className="re-detail__address">{selectedComplex.address}</p>
|
|
||||||
</div>
|
|
||||||
<div className="re-detail__header-actions">
|
|
||||||
<button className="button ghost small" onClick={onEdit}>편집</button>
|
|
||||||
<button className="button danger small" onClick={onDelete}>삭제</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-detail__section">
|
|
||||||
<div className="re-detail__stats-grid">
|
|
||||||
<div className="re-stat">
|
|
||||||
<p className="re-stat__label">세대수</p>
|
|
||||||
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div className="re-stat">
|
|
||||||
<p className="re-stat__label">평당가</p>
|
|
||||||
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
|
|
||||||
{formatPrice(selectedComplex.avgPricePerPyeong)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="re-stat">
|
|
||||||
<p className="re-stat__label">우선순위</p>
|
|
||||||
<p className="re-stat__value" style={{ fontSize: 13 }}>
|
|
||||||
{PRIORITY_LABELS[selectedComplex.priority]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-detail__section">
|
|
||||||
<p className="re-detail__section-title">평형대</p>
|
|
||||||
<div className="re-chip-group">
|
|
||||||
{selectedComplex.types.map((t) => (
|
|
||||||
<span key={t} className="re-chip re-chip--lg">{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-detail__section">
|
|
||||||
<p className="re-detail__section-title">청약 일정</p>
|
|
||||||
<div className="re-timeline">
|
|
||||||
<div className="re-timeline__item">
|
|
||||||
<div className="re-timeline__dot re-timeline__dot--start" />
|
|
||||||
<div>
|
|
||||||
<p className="re-timeline__label">청약 시작</p>
|
|
||||||
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="re-timeline__item">
|
|
||||||
<div className="re-timeline__dot" />
|
|
||||||
<div>
|
|
||||||
<p className="re-timeline__label">청약 마감</p>
|
|
||||||
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="re-timeline__item">
|
|
||||||
<div className="re-timeline__dot re-timeline__dot--result" />
|
|
||||||
<div>
|
|
||||||
<p className="re-timeline__label">당첨 발표</p>
|
|
||||||
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedComplex.tags.length > 0 && (
|
|
||||||
<div className="re-detail__section">
|
|
||||||
<p className="re-detail__section-title">특징</p>
|
|
||||||
<div className="re-chip-group">
|
|
||||||
{selectedComplex.tags.map((tag) => (
|
|
||||||
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedComplex.memo && (
|
|
||||||
<div className="re-detail__section">
|
|
||||||
<p className="re-detail__section-title">메모</p>
|
|
||||||
<p className="re-detail__memo">{selectedComplex.memo}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="re-detail__actions">
|
|
||||||
{selectedComplex.naverUrl ? (
|
|
||||||
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
|
|
||||||
네이버 부동산 →
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="button ghost small"
|
|
||||||
>
|
|
||||||
네이버 검색 →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{selectedComplex.floorPlanUrl && (
|
|
||||||
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
|
|
||||||
평면도 보기
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="re-detail re-detail--empty">
|
|
||||||
<div className="re-detail__empty-icon">🏢</div>
|
|
||||||
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
|
|
||||||
const ScheduleView = ({ complexes }) => {
|
|
||||||
const events = complexes
|
|
||||||
.filter((c) => c.subscriptionStart)
|
|
||||||
.flatMap((c) => [
|
|
||||||
{ date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' },
|
|
||||||
{ date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' },
|
|
||||||
{ date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' },
|
|
||||||
])
|
|
||||||
.filter((e) => e.date)
|
|
||||||
.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const upcoming = events.filter((e) => new Date(e.date) >= today);
|
|
||||||
const past = events.filter((e) => new Date(e.date) < today).reverse();
|
|
||||||
|
|
||||||
const EventItem = ({ event }) => {
|
|
||||||
const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료'];
|
|
||||||
const dday = getDDays(event.date);
|
|
||||||
return (
|
|
||||||
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
|
|
||||||
<div className="re-schedule-item__date">
|
|
||||||
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
|
|
||||||
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
|
|
||||||
<div className="re-schedule-item__content">
|
|
||||||
<p className="re-schedule-item__complex">{event.complex.name}</p>
|
|
||||||
<p className="re-schedule-item__label">{event.label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="re-schedule">
|
|
||||||
{upcoming.length > 0 && (
|
|
||||||
<div className="re-schedule-section">
|
|
||||||
<h4 className="re-schedule-section__title">예정 일정</h4>
|
|
||||||
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{past.length > 0 && (
|
|
||||||
<div className="re-schedule-section">
|
|
||||||
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
|
|
||||||
{past.map((e, i) => <EventItem key={i} event={e} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 가격 분석 ──────────────────────────────────────────────────────────────────
|
|
||||||
const PriceAnalysis = ({ complexes }) => {
|
|
||||||
const chartData = [...complexes]
|
|
||||||
.filter((c) => c.avgPricePerPyeong > 0)
|
|
||||||
.sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong)
|
|
||||||
.map((c) => ({
|
|
||||||
name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name,
|
|
||||||
price: c.avgPricePerPyeong,
|
|
||||||
status: c.status,
|
|
||||||
fullName: c.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }) => {
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
return (
|
|
||||||
<div className="re-chart-tooltip">
|
|
||||||
<p>{payload[0].payload.fullName}</p>
|
|
||||||
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/평</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0);
|
|
||||||
const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0;
|
|
||||||
const max = prices.length ? Math.max(...prices) : 0;
|
|
||||||
const min = prices.length ? Math.min(...prices) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="re-analysis">
|
|
||||||
<div className="re-analysis__stats">
|
|
||||||
<div className="re-stat-card">
|
|
||||||
<p className="re-stat-card__label">평균 평당가</p>
|
|
||||||
<p className="re-stat-card__value">{formatPrice(avg)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="re-stat-card">
|
|
||||||
<p className="re-stat-card__label">최고 평당가</p>
|
|
||||||
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="re-stat-card">
|
|
||||||
<p className="re-stat-card__label">최저 평당가</p>
|
|
||||||
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-panel">
|
|
||||||
<div className="re-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="re-panel__eyebrow">가격 비교</p>
|
|
||||||
<h3>단지별 평당가</h3>
|
|
||||||
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="re-chart-wrapper">
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
stroke="var(--text-dim)"
|
|
||||||
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
|
||||||
angle={-20}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--text-dim)"
|
|
||||||
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
|
||||||
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
|
|
||||||
width={44}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
|
|
||||||
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
|
|
||||||
{chartData.map((entry, index) => {
|
|
||||||
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
|
|
||||||
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
|
|
||||||
})}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-panel">
|
|
||||||
<div className="re-panel__head">
|
|
||||||
<div>
|
|
||||||
<p className="re-panel__eyebrow">비교표</p>
|
|
||||||
<h3>단지 상세 비교</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="re-table-wrapper">
|
|
||||||
<table className="re-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>단지명</th>
|
|
||||||
<th>상태</th>
|
|
||||||
<th>세대수</th>
|
|
||||||
<th>평형대</th>
|
|
||||||
<th>평당가</th>
|
|
||||||
<th>청약 시작</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{complexes.map((c) => {
|
|
||||||
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
|
|
||||||
return (
|
|
||||||
<tr key={c.id}>
|
|
||||||
<td className="re-table__name">{c.name}</td>
|
|
||||||
<td>
|
|
||||||
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
|
||||||
{c.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{c.units.toLocaleString()}</td>
|
|
||||||
<td>{c.types.join(', ')}</td>
|
|
||||||
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
|
|
||||||
{formatPrice(c.avgPricePerPyeong)}
|
|
||||||
</td>
|
|
||||||
<td>{formatDate(c.subscriptionStart)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
|
|
||||||
const ComplexModal = ({ complex, onClose, onSave }) => {
|
|
||||||
const [form, setForm] = useState(
|
|
||||||
complex
|
|
||||||
? {
|
|
||||||
...complex,
|
|
||||||
types: complex.types.join(', '),
|
|
||||||
tags: complex.tags.join(', '),
|
|
||||||
lat: String(complex.lat),
|
|
||||||
lng: String(complex.lng),
|
|
||||||
units: String(complex.units),
|
|
||||||
avgPricePerPyeong: String(complex.avgPricePerPyeong),
|
|
||||||
}
|
|
||||||
: { ...EMPTY_FORM }
|
|
||||||
);
|
|
||||||
|
|
||||||
const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave({
|
|
||||||
...form,
|
|
||||||
lat: parseFloat(form.lat) || 37.5665,
|
|
||||||
lng: parseFloat(form.lng) || 126.9780,
|
|
||||||
units: parseInt(form.units) || 0,
|
|
||||||
avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0,
|
|
||||||
types: form.types.split(',').map((t) => t.trim()).filter(Boolean),
|
|
||||||
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="re-modal-overlay" onClick={onClose}>
|
|
||||||
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="re-modal__header">
|
|
||||||
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
|
|
||||||
<button className="re-modal__close" onClick={onClose}>✕</button>
|
|
||||||
</div>
|
|
||||||
<form className="re-modal__form" onSubmit={handleSubmit}>
|
|
||||||
<div className="re-form-section">
|
|
||||||
<p className="re-form-section__title">기본 정보</p>
|
|
||||||
<div className="re-form-row">
|
|
||||||
<label className="re-form-label">
|
|
||||||
단지명 *
|
|
||||||
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
상태
|
|
||||||
<select className="re-form-input" value={form.status} onChange={set('status')}>
|
|
||||||
{Object.keys(STATUS_CONFIG).map((s) => (
|
|
||||||
<option key={s} value={s}>{s}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="re-form-label">
|
|
||||||
주소
|
|
||||||
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
|
|
||||||
</label>
|
|
||||||
<div className="re-form-row">
|
|
||||||
<label className="re-form-label">
|
|
||||||
위도 (lat)
|
|
||||||
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
경도 (lng)
|
|
||||||
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-form-section">
|
|
||||||
<p className="re-form-section__title">단지 정보</p>
|
|
||||||
<div className="re-form-row">
|
|
||||||
<label className="re-form-label">
|
|
||||||
세대수
|
|
||||||
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
평당가 (만원)
|
|
||||||
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="re-form-row">
|
|
||||||
<label className="re-form-label">
|
|
||||||
평형대 (쉼표 구분)
|
|
||||||
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
우선순위
|
|
||||||
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
|
|
||||||
<option value="high">★ 최우선</option>
|
|
||||||
<option value="normal">보통</option>
|
|
||||||
<option value="low">낮음</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="re-form-label">
|
|
||||||
특징 태그 (쉼표 구분)
|
|
||||||
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-form-section">
|
|
||||||
<p className="re-form-section__title">청약 일정</p>
|
|
||||||
<div className="re-form-row re-form-row--three">
|
|
||||||
<label className="re-form-label">
|
|
||||||
청약 시작
|
|
||||||
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
청약 마감
|
|
||||||
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
당첨 발표
|
|
||||||
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-form-section">
|
|
||||||
<p className="re-form-section__title">링크 & 메모</p>
|
|
||||||
<label className="re-form-label">
|
|
||||||
네이버 부동산 URL
|
|
||||||
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
평면도 URL
|
|
||||||
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
|
|
||||||
</label>
|
|
||||||
<label className="re-form-label">
|
|
||||||
메모
|
|
||||||
<textarea
|
|
||||||
className="re-form-input re-form-textarea"
|
|
||||||
value={form.memo}
|
|
||||||
onChange={set('memo')}
|
|
||||||
placeholder="관심 포인트, 분석 내용 등"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="re-modal__footer">
|
|
||||||
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
|
||||||
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
|
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
|
||||||
const RealEstate = () => {
|
const RealEstate = () => {
|
||||||
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
const { complexes, addComplex, updateComplex, deleteComplex } = useComplexes();
|
||||||
const [selectedComplex, setSelectedComplex] = useState(null);
|
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('목록');
|
const [activeTab, setActiveTab] = useState('목록');
|
||||||
const [filterStatus, setFilterStatus] = useState('전체');
|
const [filterStatus, setFilterStatus] = useState('전체');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingComplex, setEditingComplex] = useState(null);
|
const [editingComplex, setEditingComplex] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleAdd = (data) => {
|
||||||
apiGet('/api/realestate/complexes')
|
addComplex(data);
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAdd = async (data) => {
|
|
||||||
const newComplex = { ...data, id: Date.now() };
|
|
||||||
setComplexes((prev) => [...prev, newComplex]);
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
try { await apiPost('/api/realestate/complexes', data); } catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (data) => {
|
const handleUpdate = (data) => {
|
||||||
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
updateComplex(data);
|
||||||
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||||
setEditingComplex(null);
|
setEditingComplex(null);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = (id) => {
|
||||||
if (!confirm('삭제하시겠습니까?')) return;
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
deleteComplex(id);
|
||||||
if (selectedComplex?.id === id) setSelectedComplex(null);
|
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||||
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalSave = (data) => {
|
const handleModalSave = (data) => {
|
||||||
|
|||||||
39
src/pages/realestate/components/ComplexCard.jsx
Normal file
39
src/pages/realestate/components/ComplexCard.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';
|
||||||
|
|
||||||
|
// ── 단지 카드 ──────────────────────────────────────────────────────────────────
|
||||||
|
const ComplexCard = ({ complex, isSelected, onClick }) => {
|
||||||
|
const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료'];
|
||||||
|
const dday = getDDays(complex.subscriptionStart);
|
||||||
|
const isUpcoming = complex.status === '청약예정' || complex.status === '청약중';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
|
||||||
|
<div className="re-card__top">
|
||||||
|
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{complex.status}
|
||||||
|
</span>
|
||||||
|
{complex.priority === 'high' && <span className="re-priority-star">★</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className="re-card__name">{complex.name}</h3>
|
||||||
|
<p className="re-card__address">{complex.address}</p>
|
||||||
|
<div className="re-card__stats">
|
||||||
|
<span>{complex.units.toLocaleString()}세대</span>
|
||||||
|
<span className="re-card__dot">·</span>
|
||||||
|
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/평</span>
|
||||||
|
</div>
|
||||||
|
<div className="re-chip-group">
|
||||||
|
{complex.types.map((t) => (
|
||||||
|
<span key={t} className="re-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isUpcoming && dday && (
|
||||||
|
<div className="re-card__dday" style={{ color: cfg.color }}>
|
||||||
|
청약 {dday}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComplexCard;
|
||||||
157
src/pages/realestate/components/ComplexModal.jsx
Normal file
157
src/pages/realestate/components/ComplexModal.jsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';
|
||||||
|
|
||||||
|
// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
|
||||||
|
const ComplexModal = ({ complex, onClose, onSave }) => {
|
||||||
|
const [form, setForm] = useState(
|
||||||
|
complex
|
||||||
|
? {
|
||||||
|
...complex,
|
||||||
|
types: complex.types.join(', '),
|
||||||
|
tags: complex.tags.join(', '),
|
||||||
|
lat: String(complex.lat),
|
||||||
|
lng: String(complex.lng),
|
||||||
|
units: String(complex.units),
|
||||||
|
avgPricePerPyeong: String(complex.avgPricePerPyeong),
|
||||||
|
}
|
||||||
|
: { ...EMPTY_FORM }
|
||||||
|
);
|
||||||
|
|
||||||
|
const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
...form,
|
||||||
|
lat: parseFloat(form.lat) || 37.5665,
|
||||||
|
lng: parseFloat(form.lng) || 126.9780,
|
||||||
|
units: parseInt(form.units) || 0,
|
||||||
|
avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0,
|
||||||
|
types: form.types.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="re-modal__header">
|
||||||
|
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
|
||||||
|
<button className="re-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<form className="re-modal__form" onSubmit={handleSubmit}>
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">기본 정보</p>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
단지명 *
|
||||||
|
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
상태
|
||||||
|
<select className="re-form-input" value={form.status} onChange={set('status')}>
|
||||||
|
{Object.keys(STATUS_CONFIG).map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="re-form-label">
|
||||||
|
주소
|
||||||
|
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
|
||||||
|
</label>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
위도 (lat)
|
||||||
|
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
경도 (lng)
|
||||||
|
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">단지 정보</p>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
세대수
|
||||||
|
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
평당가 (만원)
|
||||||
|
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="re-form-row">
|
||||||
|
<label className="re-form-label">
|
||||||
|
평형대 (쉼표 구분)
|
||||||
|
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
우선순위
|
||||||
|
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
|
||||||
|
<option value="high">★ 최우선</option>
|
||||||
|
<option value="normal">보통</option>
|
||||||
|
<option value="low">낮음</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="re-form-label">
|
||||||
|
특징 태그 (쉼표 구분)
|
||||||
|
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">청약 일정</p>
|
||||||
|
<div className="re-form-row re-form-row--three">
|
||||||
|
<label className="re-form-label">
|
||||||
|
청약 시작
|
||||||
|
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
청약 마감
|
||||||
|
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
당첨 발표
|
||||||
|
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-form-section">
|
||||||
|
<p className="re-form-section__title">링크 & 메모</p>
|
||||||
|
<label className="re-form-label">
|
||||||
|
네이버 부동산 URL
|
||||||
|
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
평면도 URL
|
||||||
|
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
<label className="re-form-label">
|
||||||
|
메모
|
||||||
|
<textarea
|
||||||
|
className="re-form-input re-form-textarea"
|
||||||
|
value={form.memo}
|
||||||
|
onChange={set('memo')}
|
||||||
|
placeholder="관심 포인트, 분석 내용 등"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-modal__footer">
|
||||||
|
<button type="button" className="button ghost" onClick={onClose}>취소</button>
|
||||||
|
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComplexModal;
|
||||||
134
src/pages/realestate/components/PriceAnalysis.jsx
Normal file
134
src/pages/realestate/components/PriceAnalysis.jsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';
|
||||||
|
|
||||||
|
// ── 가격 분석 ──────────────────────────────────────────────────────────────────
|
||||||
|
const PriceAnalysis = ({ complexes }) => {
|
||||||
|
const chartData = [...complexes]
|
||||||
|
.filter((c) => c.avgPricePerPyeong > 0)
|
||||||
|
.sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong)
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name,
|
||||||
|
price: c.avgPricePerPyeong,
|
||||||
|
status: c.status,
|
||||||
|
fullName: c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="re-chart-tooltip">
|
||||||
|
<p>{payload[0].payload.fullName}</p>
|
||||||
|
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/평</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0);
|
||||||
|
const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0;
|
||||||
|
const max = prices.length ? Math.max(...prices) : 0;
|
||||||
|
const min = prices.length ? Math.min(...prices) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-analysis">
|
||||||
|
<div className="re-analysis__stats">
|
||||||
|
<div className="re-stat-card">
|
||||||
|
<p className="re-stat-card__label">평균 평당가</p>
|
||||||
|
<p className="re-stat-card__value">{formatPrice(avg)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-card">
|
||||||
|
<p className="re-stat-card__label">최고 평당가</p>
|
||||||
|
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat-card">
|
||||||
|
<p className="re-stat-card__label">최저 평당가</p>
|
||||||
|
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-panel">
|
||||||
|
<div className="re-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="re-panel__eyebrow">가격 비교</p>
|
||||||
|
<h3>단지별 평당가</h3>
|
||||||
|
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="var(--text-dim)"
|
||||||
|
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||||
|
angle={-20}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--text-dim)"
|
||||||
|
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
|
||||||
|
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((entry, index) => {
|
||||||
|
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
|
||||||
|
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
|
||||||
|
})}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-panel">
|
||||||
|
<div className="re-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="re-panel__eyebrow">비교표</p>
|
||||||
|
<h3>단지 상세 비교</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-table-wrapper">
|
||||||
|
<table className="re-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>단지명</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>세대수</th>
|
||||||
|
<th>평형대</th>
|
||||||
|
<th>평당가</th>
|
||||||
|
<th>청약 시작</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{complexes.map((c) => {
|
||||||
|
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
|
||||||
|
return (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="re-table__name">{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{c.units.toLocaleString()}</td>
|
||||||
|
<td>{c.types.join(', ')}</td>
|
||||||
|
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
|
||||||
|
{formatPrice(c.avgPricePerPyeong)}
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(c.subscriptionStart)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriceAnalysis;
|
||||||
202
src/pages/realestate/components/RightPanel.jsx
Normal file
202
src/pages/realestate/components/RightPanel.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
|
import { STATUS_CONFIG, PRIORITY_LABELS, formatDate, formatPrice, createMarkerIcon } from '../realEstateUtils';
|
||||||
|
|
||||||
|
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
||||||
|
const MapFlyTo = ({ position, zoom }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (position) {
|
||||||
|
map.flyTo(position, zoom ?? 14, { duration: 1.0 });
|
||||||
|
}
|
||||||
|
}, [position, zoom, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 인라인 지도 + 단지 상세 패널 ──────────────────────────────────────────────
|
||||||
|
const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => {
|
||||||
|
const cfg = selectedComplex
|
||||||
|
? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mapCenter = useMemo(() => {
|
||||||
|
if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng];
|
||||||
|
if (complexes.length === 0) return [37.5665, 126.9780];
|
||||||
|
return [
|
||||||
|
complexes.reduce((s, c) => s + c.lat, 0) / complexes.length,
|
||||||
|
complexes.reduce((s, c) => s + c.lng, 0) / complexes.length,
|
||||||
|
];
|
||||||
|
}, []); // 초기 중심값만 계산 (flyTo로 이후 이동)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-right-panel">
|
||||||
|
{/* ── 지도 ── */}
|
||||||
|
<div className="re-panel re-panel--map">
|
||||||
|
<div className="re-mini-map-wrap">
|
||||||
|
<MapContainer
|
||||||
|
key="inline-map"
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={10}
|
||||||
|
className="re-map"
|
||||||
|
scrollWheelZoom
|
||||||
|
zoomControl={false}
|
||||||
|
>
|
||||||
|
<MapFlyTo
|
||||||
|
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
|
||||||
|
zoom={14}
|
||||||
|
/>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://carto.com">CartoDB</a>'
|
||||||
|
/>
|
||||||
|
{complexes.map((c) => (
|
||||||
|
<Marker
|
||||||
|
key={c.id}
|
||||||
|
position={[c.lat, c.lng]}
|
||||||
|
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
|
||||||
|
eventHandlers={{ click: () => onSelectComplex(c) }}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="re-popup">
|
||||||
|
<strong>{c.name}</strong>
|
||||||
|
<span>{c.address}</span>
|
||||||
|
<span>{c.status} · {c.units.toLocaleString()}세대</span>
|
||||||
|
<span>{formatPrice(c.avgPricePerPyeong)}/평</span>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
{selectedComplex && (
|
||||||
|
<div className="re-map-label">
|
||||||
|
<span style={{ color: cfg.color }}>●</span> {selectedComplex.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 상세 패널 ── */}
|
||||||
|
{selectedComplex ? (
|
||||||
|
<div className="re-detail" key={selectedComplex.id}>
|
||||||
|
<div className="re-detail__header">
|
||||||
|
<div>
|
||||||
|
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{selectedComplex.status}
|
||||||
|
</span>
|
||||||
|
<h2 className="re-detail__name">{selectedComplex.name}</h2>
|
||||||
|
<p className="re-detail__address">{selectedComplex.address}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-detail__header-actions">
|
||||||
|
<button className="button ghost small" onClick={onEdit}>편집</button>
|
||||||
|
<button className="button danger small" onClick={onDelete}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<div className="re-detail__stats-grid">
|
||||||
|
<div className="re-stat">
|
||||||
|
<p className="re-stat__label">세대수</p>
|
||||||
|
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat">
|
||||||
|
<p className="re-stat__label">평당가</p>
|
||||||
|
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
|
||||||
|
{formatPrice(selectedComplex.avgPricePerPyeong)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="re-stat">
|
||||||
|
<p className="re-stat__label">우선순위</p>
|
||||||
|
<p className="re-stat__value" style={{ fontSize: 13 }}>
|
||||||
|
{PRIORITY_LABELS[selectedComplex.priority]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">평형대</p>
|
||||||
|
<div className="re-chip-group">
|
||||||
|
{selectedComplex.types.map((t) => (
|
||||||
|
<span key={t} className="re-chip re-chip--lg">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">청약 일정</p>
|
||||||
|
<div className="re-timeline">
|
||||||
|
<div className="re-timeline__item">
|
||||||
|
<div className="re-timeline__dot re-timeline__dot--start" />
|
||||||
|
<div>
|
||||||
|
<p className="re-timeline__label">청약 시작</p>
|
||||||
|
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-timeline__item">
|
||||||
|
<div className="re-timeline__dot" />
|
||||||
|
<div>
|
||||||
|
<p className="re-timeline__label">청약 마감</p>
|
||||||
|
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="re-timeline__item">
|
||||||
|
<div className="re-timeline__dot re-timeline__dot--result" />
|
||||||
|
<div>
|
||||||
|
<p className="re-timeline__label">당첨 발표</p>
|
||||||
|
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedComplex.tags.length > 0 && (
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">특징</p>
|
||||||
|
<div className="re-chip-group">
|
||||||
|
{selectedComplex.tags.map((tag) => (
|
||||||
|
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedComplex.memo && (
|
||||||
|
<div className="re-detail__section">
|
||||||
|
<p className="re-detail__section-title">메모</p>
|
||||||
|
<p className="re-detail__memo">{selectedComplex.memo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="re-detail__actions">
|
||||||
|
{selectedComplex.naverUrl ? (
|
||||||
|
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
|
||||||
|
네이버 부동산 →
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="button ghost small"
|
||||||
|
>
|
||||||
|
네이버 검색 →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{selectedComplex.floorPlanUrl && (
|
||||||
|
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
|
||||||
|
평면도 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="re-detail re-detail--empty">
|
||||||
|
<div className="re-detail__empty-icon">🏢</div>
|
||||||
|
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RightPanel;
|
||||||
59
src/pages/realestate/components/ScheduleView.jsx
Normal file
59
src/pages/realestate/components/ScheduleView.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';
|
||||||
|
|
||||||
|
// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
|
||||||
|
const ScheduleView = ({ complexes }) => {
|
||||||
|
const events = complexes
|
||||||
|
.filter((c) => c.subscriptionStart)
|
||||||
|
.flatMap((c) => [
|
||||||
|
{ date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' },
|
||||||
|
{ date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' },
|
||||||
|
{ date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' },
|
||||||
|
])
|
||||||
|
.filter((e) => e.date)
|
||||||
|
.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const upcoming = events.filter((e) => new Date(e.date) >= today);
|
||||||
|
const past = events.filter((e) => new Date(e.date) < today).reverse();
|
||||||
|
|
||||||
|
const EventItem = ({ event }) => {
|
||||||
|
const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료'];
|
||||||
|
const dday = getDDays(event.date);
|
||||||
|
return (
|
||||||
|
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
|
||||||
|
<div className="re-schedule-item__date">
|
||||||
|
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
|
||||||
|
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
|
||||||
|
<div className="re-schedule-item__content">
|
||||||
|
<p className="re-schedule-item__complex">{event.complex.name}</p>
|
||||||
|
<p className="re-schedule-item__label">{event.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="re-schedule">
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<div className="re-schedule-section">
|
||||||
|
<h4 className="re-schedule-section__title">예정 일정</h4>
|
||||||
|
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{past.length > 0 && (
|
||||||
|
<div className="re-schedule-section">
|
||||||
|
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
|
||||||
|
{past.map((e, i) => <EventItem key={i} event={e} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduleView;
|
||||||
33
src/pages/realestate/hooks/useComplexes.js
Normal file
33
src/pages/realestate/hooks/useComplexes.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
|
||||||
|
import { SAMPLE_COMPLEXES } from '../realEstateUtils';
|
||||||
|
|
||||||
|
export default function useComplexes() {
|
||||||
|
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/realestate/complexes')
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addComplex = async (data) => {
|
||||||
|
const newComplex = { ...data, id: Date.now() };
|
||||||
|
setComplexes((prev) => [...prev, newComplex]);
|
||||||
|
try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateComplex = async (data) => {
|
||||||
|
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||||
|
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteComplex = async (id) => {
|
||||||
|
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return { complexes, addComplex, updateComplex, deleteComplex };
|
||||||
|
}
|
||||||
141
src/pages/realestate/realEstateUtils.js
Normal file
141
src/pages/realestate/realEstateUtils.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
export const SAMPLE_COMPLEXES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '래미안 원베일리',
|
||||||
|
address: '서울 서초구 반포동',
|
||||||
|
lat: 37.5065,
|
||||||
|
lng: 126.9942,
|
||||||
|
units: 2990,
|
||||||
|
types: ['59㎡', '84㎡', '114㎡'],
|
||||||
|
avgPricePerPyeong: 9500,
|
||||||
|
subscriptionStart: '2024-01-08',
|
||||||
|
subscriptionEnd: '2024-01-10',
|
||||||
|
resultDate: '2024-01-15',
|
||||||
|
status: '완료',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '올림픽파크 포레온',
|
||||||
|
address: '서울 강동구 둔촌동',
|
||||||
|
lat: 37.5284,
|
||||||
|
lng: 127.1340,
|
||||||
|
units: 12032,
|
||||||
|
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 3800,
|
||||||
|
subscriptionStart: '2022-12-05',
|
||||||
|
subscriptionEnd: '2022-12-07',
|
||||||
|
resultDate: '2022-12-12',
|
||||||
|
status: '완료',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['대단지', '역세권', '재건축'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '역대 최대 규모 재건축 단지.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '힐스테이트 동탄',
|
||||||
|
address: '경기 화성시 동탄2신도시',
|
||||||
|
lat: 37.2001,
|
||||||
|
lng: 127.0724,
|
||||||
|
units: 1534,
|
||||||
|
types: ['59㎡', '74㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 1850,
|
||||||
|
subscriptionStart: '2026-04-10',
|
||||||
|
subscriptionEnd: '2026-04-12',
|
||||||
|
resultDate: '2026-04-17',
|
||||||
|
status: '청약예정',
|
||||||
|
priority: 'normal',
|
||||||
|
tags: ['동탄2', '신도시', 'SRT'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '롯데캐슬 마곡',
|
||||||
|
address: '서울 강서구 마곡동',
|
||||||
|
lat: 37.5626,
|
||||||
|
lng: 126.8295,
|
||||||
|
units: 868,
|
||||||
|
types: ['59㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 4200,
|
||||||
|
subscriptionStart: '2026-03-20',
|
||||||
|
subscriptionEnd: '2026-03-22',
|
||||||
|
resultDate: '2026-03-27',
|
||||||
|
status: '청약중',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['마곡', '9호선', '공항철도'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_CONFIG = {
|
||||||
|
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||||
|
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||||
|
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||||
|
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||||
|
|
||||||
|
export const EMPTY_FORM = {
|
||||||
|
name: '', address: '', lat: '', lng: '',
|
||||||
|
units: '', types: '', avgPricePerPyeong: '',
|
||||||
|
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||||
|
status: '청약예정', priority: 'normal',
|
||||||
|
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TABS = ['목록', '일정', '분석'];
|
||||||
|
|
||||||
|
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
||||||
|
export const formatDate = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPrice = (v) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
return `${v.toLocaleString()}만원`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDDays = (dateStr) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const target = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diff === 0) return 'D-Day';
|
||||||
|
if (diff > 0) return `D-${diff}`;
|
||||||
|
return `D+${Math.abs(diff)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMarkerIcon = (status, isSelected = false) => {
|
||||||
|
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||||
|
const size = isSelected ? 18 : 12;
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
background:${cfg.color};
|
||||||
|
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||||
|
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||||
|
transition:all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
});
|
||||||
|
};
|
||||||
31
src/pages/realestate/realEstateUtils.test.js
Normal file
31
src/pages/realestate/realEstateUtils.test.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDate, formatPrice, getDDays } from './realEstateUtils.js';
|
||||||
|
|
||||||
|
describe('formatPrice', () => {
|
||||||
|
it('만원 표기 / falsy → -', () => {
|
||||||
|
expect(formatPrice(4500)).toBe('4,500만원');
|
||||||
|
expect(formatPrice(0)).toBe('-');
|
||||||
|
expect(formatPrice(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDDays (오늘 기준)', () => {
|
||||||
|
const iso = (off) => {
|
||||||
|
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + off);
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
it('오늘=D-Day, 미래=D-N, 과거=D+N, null→null', () => {
|
||||||
|
expect(getDDays(iso(0))).toBe('D-Day');
|
||||||
|
expect(getDDays(iso(4))).toBe('D-4');
|
||||||
|
expect(getDDays(iso(-2))).toBe('D+2');
|
||||||
|
expect(getDDays(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('유효 날짜 한국어 포맷 / falsy → -', () => {
|
||||||
|
expect(formatDate('2026-04-10')).toContain('2026');
|
||||||
|
expect(formatDate(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3349,4 +3349,5 @@
|
|||||||
}
|
}
|
||||||
.wl-cond { font-size: 13px; font-weight: 600; }
|
.wl-cond { font-size: 13px; font-weight: 600; }
|
||||||
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
||||||
|
.wl-alert__reason { font-size: 12.5px; line-height: 1.45; color: #cbd5e1; }
|
||||||
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Loading from '../../../components/Loading';
|
import Loading from '../../../components/Loading';
|
||||||
import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils';
|
import { kindMeta, conditionLabel, relativeTime, formatDetail } from '../watchlistUtils';
|
||||||
import { formatNumber } from '../stockUtils';
|
import { formatNumber } from '../stockUtils';
|
||||||
|
|
||||||
const DAYS_OPTIONS = [
|
const DAYS_OPTIONS = [
|
||||||
@@ -11,6 +11,7 @@ const DAYS_OPTIONS = [
|
|||||||
|
|
||||||
const AlertCard = ({ a }) => {
|
const AlertCard = ({ a }) => {
|
||||||
const meta = kindMeta(a.kind);
|
const meta = kindMeta(a.kind);
|
||||||
|
const detailText = formatDetail(a.detail);
|
||||||
return (
|
return (
|
||||||
<div className="wl-alert">
|
<div className="wl-alert">
|
||||||
<div className="wl-alert__head">
|
<div className="wl-alert__head">
|
||||||
@@ -23,7 +24,8 @@ const AlertCard = ({ a }) => {
|
|||||||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||||||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||||||
</div>
|
</div>
|
||||||
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
|
{a.reason && <div className="wl-alert__reason">{a.reason}</div>}
|
||||||
|
{detailText && <div className="wl-alert__detail">{detailText}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,4 +28,35 @@ describe('WatchlistTab', () => {
|
|||||||
expect(screen.getByText('매수')).toBeInTheDocument();
|
expect(screen.getByText('매수')).toBeInTheDocument();
|
||||||
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
|
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detail이 객체인 알림도 크래시 없이 렌더 (React #31 회귀 방지)', () => {
|
||||||
|
const wl = {
|
||||||
|
...baseWl,
|
||||||
|
alerts: [{
|
||||||
|
id: 20, ticker: '381170', name: null, kind: 'sell', condition: 'sell_ma_break',
|
||||||
|
price: 32715, detail: { ma50: 33309.8, ma200: null, severity: 'normal' },
|
||||||
|
fired_at: '2026-07-03T07:02:59Z',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
render(<WatchlistTab wl={wl} />);
|
||||||
|
expect(screen.getByText('매도')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
|
||||||
|
// 객체 detail이 문자열로 안전 렌더됨 (severity 값 노출)
|
||||||
|
expect(screen.getByText(/normal/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('alert.reason(근거 문자열)을 condition 라벨과 함께 렌더', () => {
|
||||||
|
const wl = {
|
||||||
|
...baseWl,
|
||||||
|
alerts: [{
|
||||||
|
id: 40, ticker: '000660', name: 'SK하이닉스', kind: 'sell', condition: 'sell_ma_break',
|
||||||
|
price: 2120000, detail: { ma50: 2123160, ma200: null, severity: 'normal' },
|
||||||
|
fired_at: '2026-07-09T02:55:00Z',
|
||||||
|
reason: '주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
render(<WatchlistTab wl={wl} />);
|
||||||
|
expect(screen.getByText('주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,39 @@ export const conditionLabel = (cond) =>
|
|||||||
|
|
||||||
export const normalizeTicker = (str) => String(str ?? '').trim();
|
export const normalizeTicker = (str) => String(str ?? '').trim();
|
||||||
|
|
||||||
|
/* 알림 detail 은 condition 마다 스키마가 다른 객체(또는 문자열).
|
||||||
|
객체를 JSX 자식으로 직접 렌더하면 React #31 크래시 → 항상 문자열로 변환한다.
|
||||||
|
특정 필드 존재를 가정하지 않고(스키마 가변) 알려진 키만 한글 라벨, 나머지는 원문 키. */
|
||||||
|
const DETAIL_KEY_LABEL = Object.freeze({
|
||||||
|
avg_price: '평단', pnl_pct: '손익', stop_pct: '손절', take_pct: '목표',
|
||||||
|
holding_high: '보유고점', trailing_pct: '트레일링', drawdown_pct: '낙폭',
|
||||||
|
ma50: 'MA50', ma200: 'MA200', severity: '강도', vol: '거래량', rsi: 'RSI',
|
||||||
|
breakout_high: '돌파고점', pullback_pct: '눌림',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDetailValue = (key, v) => {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
if (key.endsWith('_pct')) return `${(v * 100).toFixed(2)}%`;
|
||||||
|
return Number.isInteger(v) ? v.toLocaleString('ko-KR') : String(v);
|
||||||
|
}
|
||||||
|
if (typeof v === 'object') return JSON.stringify(v);
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDetail = (detail) => {
|
||||||
|
if (detail == null) return '';
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
if (typeof detail !== 'object') return String(detail);
|
||||||
|
return Object.entries(detail)
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const fv = formatDetailValue(k, v);
|
||||||
|
return fv == null ? null : `${DETAIL_KEY_LABEL[k] ?? k} ${fv}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
export const relativeTime = (iso, now = Date.now()) => {
|
export const relativeTime = (iso, now = Date.now()) => {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js';
|
import { kindMeta, conditionLabel, normalizeTicker, relativeTime, formatDetail } from './watchlistUtils.js';
|
||||||
|
|
||||||
describe('kindMeta', () => {
|
describe('kindMeta', () => {
|
||||||
it('buy/sell 라벨과 색을 반환', () => {
|
it('buy/sell 라벨과 색을 반환', () => {
|
||||||
@@ -53,6 +53,34 @@ describe('normalizeTicker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatDetail (알림 detail 객체 → 안전 문자열, React #31 방지)', () => {
|
||||||
|
it('객체 detail을 읽기 좋은 문자열로 변환하고 절대 객체를 반환하지 않는다', () => {
|
||||||
|
const s = formatDetail({ avg_price: 75325, pnl_pct: -0.1012, stop_pct: 0.08 });
|
||||||
|
expect(typeof s).toBe('string');
|
||||||
|
expect(s).toContain('75,325'); // avg_price 천단위 포맷
|
||||||
|
expect(s).toContain('-10.12%'); // *_pct 는 퍼센트로
|
||||||
|
expect(s).toContain('8.00%');
|
||||||
|
});
|
||||||
|
it('값이 null인 필드는 건너뛴다', () => {
|
||||||
|
const s = formatDetail({ ma50: 33309.8, ma200: null, severity: 'normal' });
|
||||||
|
expect(typeof s).toBe('string');
|
||||||
|
expect(s).toContain('normal');
|
||||||
|
expect(s).not.toContain('null');
|
||||||
|
});
|
||||||
|
it('문자열 detail은 그대로 반환', () => {
|
||||||
|
expect(formatDetail('박스권 돌파')).toBe('박스권 돌파');
|
||||||
|
});
|
||||||
|
it('null/undefined 는 빈 문자열', () => {
|
||||||
|
expect(formatDetail(null)).toBe('');
|
||||||
|
expect(formatDetail(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
it('미정의 키(스키마 가정 없음)도 크래시 없이 문자열로', () => {
|
||||||
|
const s = formatDetail({ some_new_field: 42 });
|
||||||
|
expect(typeof s).toBe('string');
|
||||||
|
expect(s).toContain('42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('relativeTime', () => {
|
describe('relativeTime', () => {
|
||||||
const now = new Date('2026-07-03T12:00:00Z').getTime();
|
const now = new Date('2026-07-03T12:00:00Z').getTime();
|
||||||
it('60초 미만은 방금', () => {
|
it('60초 미만은 방금', () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
90
src/pages/subscription/components/AnnouncementCard.jsx
Normal file
90
src/pages/subscription/components/AnnouncementCard.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
|
||||||
|
const dday = getDDays(item.receipt_start);
|
||||||
|
const priceText = item.min_price != null
|
||||||
|
? (item.min_price === item.max_price_display
|
||||||
|
? fmtPrice(item.min_price)
|
||||||
|
: `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sub-card${isSelected ? ' is-selected' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="sub-card__top">
|
||||||
|
<div className="sub-card__badges">
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
|
||||||
|
<span className="sub-type-badge" style={{ color: '#60a5fa' }}>
|
||||||
|
{HOUSE_TYPE_LABELS[item.house_secd]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.match_score > 0 && (
|
||||||
|
<span className="sub-badge" style={{
|
||||||
|
color: item.match_score >= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
|
||||||
|
background: item.match_score >= 70 ? 'rgba(52,211,153,0.1)' : item.match_score >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(148,163,184,0.1)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{item.match_score}점
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(item.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||||
|
fontSize: 16, color: item.is_bookmarked ? '#f59e0b' : 'var(--text-dim)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
title={item.is_bookmarked ? '즐겨찾기 해제' : '즐겨찾기'}
|
||||||
|
>
|
||||||
|
{item.is_bookmarked ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h4 className="sub-card__name">{item.house_nm || '(이름 없음)'}</h4>
|
||||||
|
<p className="sub-card__address">{item.address || item.region_name || '-'}</p>
|
||||||
|
<div className="sub-card__info">
|
||||||
|
<span>{item.total_units ? `${item.total_units}세대` : '-'}</span>
|
||||||
|
<span className="sub-card__dot">·</span>
|
||||||
|
<span>{item.region_name || '-'}</span>
|
||||||
|
{priceText && (
|
||||||
|
<>
|
||||||
|
<span className="sub-card__dot">·</span>
|
||||||
|
<span style={{ color: '#f59e0b' }}>{priceText}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sub-card__bottom">
|
||||||
|
{item.receipt_start && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{fmt(item.receipt_start)} ~ {fmt(item.receipt_end)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dday && (
|
||||||
|
<span
|
||||||
|
className="sub-card__dday"
|
||||||
|
style={{ color: getDDayColor(item.receipt_start) }}
|
||||||
|
>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementCard;
|
||||||
279
src/pages/subscription/components/AnnouncementDetail.jsx
Normal file
279
src/pages/subscription/components/AnnouncementDetail.jsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { HOUSE_TYPE_LABELS, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
function AnnouncementDetail({ item, onBookmark }) {
|
||||||
|
const [detailTab, setDetailTab] = useState('info');
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<div className="sub-detail sub-detail--empty">
|
||||||
|
<span className="sub-detail__empty-icon">🏠</span>
|
||||||
|
<span>공고를 선택하면 상세 정보가 표시됩니다.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-detail">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sub-detail__header">
|
||||||
|
<div>
|
||||||
|
<div className="sub-detail__badges">
|
||||||
|
<StatusBadge status={item.status} size="lg" />
|
||||||
|
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
|
||||||
|
<span className="sub-type-badge sub-type-badge--lg" style={{ color: '#60a5fa' }}>
|
||||||
|
{HOUSE_TYPE_LABELS[item.house_secd]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="sub-detail__name">{item.house_nm}</h3>
|
||||||
|
<p className="sub-detail__address">{item.address || item.region_name}</p>
|
||||||
|
{item.match_score > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 20, fontWeight: 700, fontFamily: 'var(--font-display)',
|
||||||
|
color: item.match_score >= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
|
||||||
|
}}>
|
||||||
|
매칭 {item.match_score}점
|
||||||
|
</span>
|
||||||
|
{item.eligible_types?.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{(Array.isArray(item.eligible_types) ? item.eligible_types : []).map((t, i) => (
|
||||||
|
<span key={i} className="sub-tag is-neutral" style={{ fontSize: 10 }}>{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sub-detail__actions">
|
||||||
|
<button
|
||||||
|
onClick={() => onBookmark?.(item.id)}
|
||||||
|
className="sub-filter-btn"
|
||||||
|
style={{
|
||||||
|
color: item.is_bookmarked ? '#f59e0b' : undefined,
|
||||||
|
borderColor: item.is_bookmarked ? '#f59e0b' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.is_bookmarked ? '★ 즐겨찾기 해제' : '☆ 즐겨찾기'}
|
||||||
|
</button>
|
||||||
|
{item.homepage_url && (
|
||||||
|
<a href={item.homepage_url} target="_blank" rel="noreferrer"
|
||||||
|
className="sub-filter-btn" style={{ textDecoration: 'none' }}>
|
||||||
|
홈페이지
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.pblanc_url && (
|
||||||
|
<a href={item.pblanc_url} target="_blank" rel="noreferrer"
|
||||||
|
className="sub-filter-btn" style={{ textDecoration: 'none' }}>
|
||||||
|
공고문
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Tabs */}
|
||||||
|
<div className="sub-section-tabs">
|
||||||
|
<button
|
||||||
|
className={`sub-section-tab${detailTab === 'info' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDetailTab('info')}
|
||||||
|
>
|
||||||
|
기본 정보
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`sub-section-tab${detailTab === 'schedule' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDetailTab('schedule')}
|
||||||
|
>
|
||||||
|
일정
|
||||||
|
</button>
|
||||||
|
{item.models?.length > 0 && (
|
||||||
|
<button
|
||||||
|
className={`sub-section-tab${detailTab === 'models' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDetailTab('models')}
|
||||||
|
>
|
||||||
|
주택형 ({item.models.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Content */}
|
||||||
|
<div className="sub-detail__section-content">
|
||||||
|
{detailTab === 'info' && (
|
||||||
|
<div className="sub-compare">
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">단지명</span>
|
||||||
|
<span className="sub-compare__mine" style={{ gridColumn: 'span 2' }}>{item.house_nm}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">지역</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.region_name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">주소</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.address || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">총 세대수</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.total_units ? `${item.total_units}세대` : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">시공사</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.constructor || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">시행사</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.developer || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">입주 예정</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.move_in_month || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'schedule' && (
|
||||||
|
<div className="sub-schedule-mini">
|
||||||
|
{[
|
||||||
|
{ label: '특별공급 접수', start: item.spsply_start, end: item.spsply_end },
|
||||||
|
{ label: '1순위 접수', start: item.gnrl_rank1_start, end: item.gnrl_rank1_end },
|
||||||
|
{ label: '일반공급 접수', start: item.receipt_start, end: item.receipt_end },
|
||||||
|
{ label: '당첨자 발표', start: item.winner_date },
|
||||||
|
{ label: '계약', start: item.contract_start, end: item.contract_end },
|
||||||
|
].filter(s => s.start).map((s, i) => {
|
||||||
|
const dday = getDDays(s.start);
|
||||||
|
return (
|
||||||
|
<div className="sub-schedule-mini__item" key={i}>
|
||||||
|
<div className="sub-schedule-mini__dot" style={{ background: getDDayColor(s.start) }} />
|
||||||
|
<div className="sub-schedule-mini__content">
|
||||||
|
<span className="sub-schedule-mini__label">{s.label}</span>
|
||||||
|
<span className="sub-schedule-mini__date">
|
||||||
|
{fmtFull(s.start)}{s.end ? ` ~ ${fmtFull(s.end)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dday && (
|
||||||
|
<span className="sub-schedule-mini__dday" style={{ color: getDDayColor(s.start) }}>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{![item.spsply_start, item.gnrl_rank1_start, item.receipt_start, item.winner_date, item.contract_start].some(Boolean) && (
|
||||||
|
<p className="sub-empty-sm">일정 정보가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'models' && item.models?.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{item.models.map((m, i) => {
|
||||||
|
const totalUnits = (m.general_units || 0) + (m.special_units || 0);
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-bright)' }}>
|
||||||
|
{m.house_ty || `주택형 ${i + 1}`}
|
||||||
|
</span>
|
||||||
|
{totalUnits > 0 && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
|
||||||
|
{totalUnits}세대
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.supply_area && (
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>공급면적 {m.supply_area}m²</span>
|
||||||
|
)}
|
||||||
|
{m.top_amount != null && (
|
||||||
|
<span style={{ color: '#f59e0b', fontWeight: 600 }}>
|
||||||
|
분양가 {fmtPrice(m.top_amount)}원
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.match_score !== undefined && item.match_score !== null && (
|
||||||
|
<div className="sub-match-analysis">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||||
|
<span className="sub-match-analysis__score">
|
||||||
|
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.score_breakdown && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
|
||||||
|
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
|
||||||
|
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
|
||||||
|
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
|
||||||
|
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
|
||||||
|
].map(({ key, label, max, color }) => {
|
||||||
|
const v = item.score_breakdown[key] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ display: 'grid', gap: 3 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 700, color }}>{v}</span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 3, background: color,
|
||||||
|
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
|
||||||
|
<ul className="sub-match-analysis__reasons">
|
||||||
|
{item.match_reasons.map((r, idx) => (
|
||||||
|
<li key={idx}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||||
|
<div className="sub-match-analysis__elig">
|
||||||
|
{item.eligible_types.map(t => (
|
||||||
|
<span key={t} className="sub-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementDetail;
|
||||||
221
src/pages/subscription/components/AnnouncementsTab.jsx
Normal file
221
src/pages/subscription/components/AnnouncementsTab.jsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiDelete } from '../../../api';
|
||||||
|
import { STATUS_CONFIG, STATUS_FILTERS, apiPatch } from '../subscriptionUtils';
|
||||||
|
import AnnouncementCard from './AnnouncementCard';
|
||||||
|
import AnnouncementDetail from './AnnouncementDetail';
|
||||||
|
import CalendarView from './CalendarView';
|
||||||
|
|
||||||
|
// ── AnnouncementsTab ─────────────────────────────────────────────────────────
|
||||||
|
function AnnouncementsTab() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('전체');
|
||||||
|
const [regionFilter, setRegionFilter] = useState('');
|
||||||
|
const [bookmarkFilter, setBookmarkFilter] = useState(false);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
|
||||||
|
const [calendarDay, setCalendarDay] = useState(null); // { label, items }
|
||||||
|
|
||||||
|
const size = viewMode === 'calendar' ? 200 : 20;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||||
|
if (statusFilter !== '전체') params.set('status', statusFilter);
|
||||||
|
if (regionFilter.trim()) params.set('region', regionFilter.trim());
|
||||||
|
if (bookmarkFilter) params.set('bookmarked', 'true');
|
||||||
|
const data = await apiGet(`/api/realestate/announcements?${params}`);
|
||||||
|
setItems(data.items || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Announcements load error:', e);
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter, viewMode]);
|
||||||
|
|
||||||
|
const handleSelect = async (item) => {
|
||||||
|
setSelected(item.id);
|
||||||
|
try {
|
||||||
|
const d = await apiGet(`/api/realestate/announcements/${item.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Detail load error:', e);
|
||||||
|
setDetail(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClosed = async () => {
|
||||||
|
if (!confirm('종료된(완료) 청약 공고를 모두 삭제할까요?')) return;
|
||||||
|
try {
|
||||||
|
const res = await apiDelete('/api/realestate/announcements/closed');
|
||||||
|
alert(`${res.deleted || 0}건 삭제되었습니다.`);
|
||||||
|
setPage(1);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Delete closed error:', e);
|
||||||
|
alert('삭제 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookmark = async (id) => {
|
||||||
|
try {
|
||||||
|
const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`);
|
||||||
|
setItems(prev => prev.map(it =>
|
||||||
|
it.id === id ? { ...it, is_bookmarked: updated.is_bookmarked } : it
|
||||||
|
));
|
||||||
|
if (detail?.id === id) setDetail(prev => ({ ...prev, is_bookmarked: updated.is_bookmarked }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Bookmark error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="sub-tabs-bar">
|
||||||
|
<div className="sub-filter">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={`sub-filter-btn${statusFilter === f ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setStatusFilter(f); setPage(1); }}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className={`sub-filter-btn${bookmarkFilter ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setBookmarkFilter(v => !v); setPage(1); }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
★ 즐겨찾기
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
placeholder="지역 검색..."
|
||||||
|
value={regionFilter}
|
||||||
|
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
|
||||||
|
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`sub-filter-btn${viewMode === 'calendar' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setViewMode(v => v === 'calendar' ? 'list' : 'calendar'); setPage(1); setCalendarDay(null); }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
title="캘린더 뷰 전환"
|
||||||
|
>
|
||||||
|
📅 캘린더
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn"
|
||||||
|
onClick={handleDeleteClosed}
|
||||||
|
style={{ fontSize: 12, color: '#f87171' }}
|
||||||
|
title="status='완료' 공고 일괄 삭제"
|
||||||
|
>
|
||||||
|
🗑 종료 청약 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="sub-empty">불러오는 중...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="sub-empty">조건에 맞는 공고가 없습니다.</div>
|
||||||
|
) : viewMode === 'calendar' ? (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
<CalendarView
|
||||||
|
items={items}
|
||||||
|
onDaySelect={(dayItems, label) => setCalendarDay({ items: dayItems, label })}
|
||||||
|
/>
|
||||||
|
{calendarDay && (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">{calendarDay.label}</p>
|
||||||
|
<h3>공고 {calendarDay.items.length}건</h3>
|
||||||
|
</div>
|
||||||
|
<button className="sub-filter-btn" onClick={() => setCalendarDay(null)} style={{ fontSize: 11 }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{calendarDay.items.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="sub-card"
|
||||||
|
style={{ cursor: 'pointer', padding: '10px 14px' }}
|
||||||
|
onClick={() => { setViewMode('list'); handleSelect(item); }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>{item.house_nm}</span>
|
||||||
|
<span className="sub-badge" style={{ background: STATUS_CONFIG[item.status]?.bg, color: STATUS_CONFIG[item.status]?.color, flexShrink: 0 }}>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>{item.region_name} · 접수 {item.receipt_start}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sub-list-layout">
|
||||||
|
{/* Card Grid */}
|
||||||
|
<div>
|
||||||
|
<div className="sub-card-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<AnnouncementCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={selected === item.id}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
onBookmark={handleBookmark}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
>
|
||||||
|
‹ 이전
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-dim)', padding: '4px 8px' }}>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
다음 ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Panel */}
|
||||||
|
<div className="sub-detail-panel">
|
||||||
|
<AnnouncementDetail item={detail} onBookmark={handleBookmark} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementsTab;
|
||||||
70
src/pages/subscription/components/CalendarView.jsx
Normal file
70
src/pages/subscription/components/CalendarView.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||||
|
|
||||||
|
function CalendarView({ items, onDaySelect }) {
|
||||||
|
const [cur, setCur] = useState(() => {
|
||||||
|
const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const year = cur.getFullYear(), month = cur.getMonth();
|
||||||
|
|
||||||
|
const dateMap = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start;
|
||||||
|
if (!raw || raw.length < 8) continue;
|
||||||
|
const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
|
||||||
|
(map[key] = map[key] || []).push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const firstDow = new Date(year, month, 1).getDay();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < firstDow; i++) cells.push(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
|
||||||
|
const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-calendar">
|
||||||
|
<div className="sub-calendar__header">
|
||||||
|
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month-1, 1))}>‹</button>
|
||||||
|
<span>{year}년 {month+1}월</span>
|
||||||
|
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month+1, 1))}>›</button>
|
||||||
|
</div>
|
||||||
|
<div className="sub-calendar__weekdays">
|
||||||
|
{['일','월','화','수','목','금','토'].map(w => (
|
||||||
|
<div key={w} className="sub-calendar__weekday">{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sub-calendar__grid">
|
||||||
|
{cells.map((d, i) => {
|
||||||
|
const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null;
|
||||||
|
const dayItems = key ? (dateMap[key] || []) : [];
|
||||||
|
const isToday = key === todayKey;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`sub-calendar__day${!d ? ' is-empty' : ''}${isToday ? ' is-today' : ''}${dayItems.length > 0 ? ' has-items' : ''}`}
|
||||||
|
onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)}
|
||||||
|
>
|
||||||
|
{d && <span className="sub-calendar__day-num">{d}</span>}
|
||||||
|
{dayItems.length > 0 && (
|
||||||
|
<div className="sub-calendar__dots">
|
||||||
|
{dayItems.slice(0, 3).map((it, j) => (
|
||||||
|
<span key={j} className="sub-calendar__dot" style={{ background: STATUS_CONFIG[it.status]?.color || '#888' }} />
|
||||||
|
))}
|
||||||
|
{dayItems.length > 3 && <span className="sub-calendar__more">+{dayItems.length - 3}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarView;
|
||||||
206
src/pages/subscription/components/DashboardTab.jsx
Normal file
206
src/pages/subscription/components/DashboardTab.jsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost } from '../../../api';
|
||||||
|
import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
// ── DashboardTab ─────────────────────────────────────────────────────────────
|
||||||
|
function DashboardTab() {
|
||||||
|
const [dashboard, setDashboard] = useState(null);
|
||||||
|
const [collectStatus, setCollectStatus] = useState(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [collecting, setCollecting] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [dash, status, ann] = await Promise.all([
|
||||||
|
apiGet('/api/realestate/dashboard'),
|
||||||
|
apiGet('/api/realestate/collect/status').catch(() => null),
|
||||||
|
apiGet('/api/realestate/announcements?page=1&size=1'),
|
||||||
|
]);
|
||||||
|
setDashboard(dash);
|
||||||
|
setCollectStatus(status);
|
||||||
|
setTotalCount(ann?.total || 0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Dashboard load error:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleCollect = async () => {
|
||||||
|
setCollecting(true);
|
||||||
|
try {
|
||||||
|
await apiPost('/api/realestate/collect');
|
||||||
|
// Wait a moment then refresh status
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const status = await apiGet('/api/realestate/collect/status');
|
||||||
|
setCollectStatus(status);
|
||||||
|
} catch (_) {}
|
||||||
|
setCollecting(false);
|
||||||
|
load();
|
||||||
|
}, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Collect error:', e);
|
||||||
|
setCollecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="sub-empty">불러오는 중...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 20 }}>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="sub-stats-bar">
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value">{dashboard?.active_count ?? 0}</p>
|
||||||
|
<p className="sub-stat-item__label">진행중 공고</p>
|
||||||
|
</div>
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value" style={{ color: dashboard?.new_match_count > 0 ? '#f43f5e' : undefined }}>
|
||||||
|
{dashboard?.new_match_count ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="sub-stat-item__label">신규 매칭</p>
|
||||||
|
</div>
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value" style={{ color: dashboard?.bookmarked_count > 0 ? '#f59e0b' : undefined }}>
|
||||||
|
{dashboard?.bookmarked_count ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="sub-stat-item__label">즐겨찾기</p>
|
||||||
|
</div>
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value">{totalCount}</p>
|
||||||
|
<p className="sub-stat-item__label">전체 공고</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collection Status */}
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">데이터 수집</p>
|
||||||
|
<h3>공공데이터 수집 현황</h3>
|
||||||
|
{collectStatus && (
|
||||||
|
<p className="sub-panel__sub">
|
||||||
|
마지막 수집: {fmtDateTime(collectStatus.collected_at)}
|
||||||
|
{collectStatus.new_count != null && ` · 신규 ${collectStatus.new_count}건`}
|
||||||
|
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
|
||||||
|
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!collectStatus && <p className="sub-panel__sub">수집 이력이 없습니다.</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn is-active"
|
||||||
|
onClick={handleCollect}
|
||||||
|
disabled={collecting}
|
||||||
|
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{collecting ? '수집 중...' : '수집 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Schedules */}
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">일정</p>
|
||||||
|
<h3>다가오는 일정</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body">
|
||||||
|
{dashboard?.upcoming_schedules?.length > 0 ? (
|
||||||
|
<div className="sub-schedule-mini">
|
||||||
|
{dashboard.upcoming_schedules.map((s, i) => {
|
||||||
|
const dday = getDDays(s.date);
|
||||||
|
return (
|
||||||
|
<div className="sub-schedule-mini__item" key={i}>
|
||||||
|
<div
|
||||||
|
className="sub-schedule-mini__dot"
|
||||||
|
style={{ background: getDDayColor(s.date) }}
|
||||||
|
/>
|
||||||
|
<div className="sub-schedule-mini__content">
|
||||||
|
<span className="sub-schedule-mini__label">
|
||||||
|
{s.house_nm || s.label || '공고'}
|
||||||
|
</span>
|
||||||
|
<span className="sub-schedule-mini__date">
|
||||||
|
{fmtFull(s.date)} · {s.event || s.type || '일정'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dday && (
|
||||||
|
<span
|
||||||
|
className="sub-schedule-mini__dday"
|
||||||
|
style={{ color: getDDayColor(s.date) }}
|
||||||
|
>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="sub-empty-sm">다가오는 일정이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookmarked */}
|
||||||
|
{dashboard?.bookmarked?.length > 0 && (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">즐겨찾기</p>
|
||||||
|
<h3>관심 공고</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body">
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{dashboard.bookmarked.map((item) => {
|
||||||
|
const dday = getDDays(item.receipt_start);
|
||||||
|
const priceText = item.min_price != null
|
||||||
|
? (item.min_price === item.max_price_display
|
||||||
|
? fmtPrice(item.min_price)
|
||||||
|
: `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={item.id} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '10px 14px', borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--line)', background: 'var(--surface)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gap: 2 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: '#f59e0b', fontSize: 14 }}>★</span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>
|
||||||
|
{item.house_nm}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{item.region_name || '-'}
|
||||||
|
{priceText && <> · <span style={{ color: '#f59e0b' }}>{priceText}</span></>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dday && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: getDDayColor(item.receipt_start) }}>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardTab;
|
||||||
228
src/pages/subscription/components/MatchesTab.jsx
Normal file
228
src/pages/subscription/components/MatchesTab.jsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost } from '../../../api';
|
||||||
|
import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
// ── MatchesTab ────────────────────────────────────────────────────────────────
|
||||||
|
function MatchesTab() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [myPoints, setMyPoints] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const size = 20;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await apiGet(`/api/realestate/matches?page=${page}&size=${size}`);
|
||||||
|
setItems(data.items || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
if (data.my_points) setMyPoints(data.my_points);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Matches load error:', e);
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [page]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await apiPost('/api/realestate/matches/refresh');
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Refresh error:', e);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkRead = async (id) => {
|
||||||
|
try {
|
||||||
|
await apiPatch(`/api/realestate/matches/${id}/read`);
|
||||||
|
setItems(prev => prev.map(m => m.id === id ? { ...m, is_new: false } : m));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Mark read error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
<div className="sub-tabs-bar">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-dim)' }}>
|
||||||
|
총 {total}건의 매칭 결과
|
||||||
|
</p>
|
||||||
|
{myPoints && (
|
||||||
|
<span className="sub-badge" style={{
|
||||||
|
color: myPoints.total >= 60 ? '#34d399' : myPoints.total >= 40 ? '#f59e0b' : '#f87171',
|
||||||
|
background: myPoints.total >= 60 ? 'rgba(52,211,153,0.1)' : myPoints.total >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(248,113,113,0.1)',
|
||||||
|
fontWeight: 700, fontSize: 12,
|
||||||
|
}}>
|
||||||
|
내 가점 {myPoints.total}/{myPoints.max_total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn is-active"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{refreshing ? '재계산 중...' : '재계산'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="sub-empty">불러오는 중...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="sub-empty">
|
||||||
|
매칭 결과가 없습니다. 프로필을 설정하고 재계산을 실행해 보세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{items.map((match) => (
|
||||||
|
<div
|
||||||
|
key={match.id}
|
||||||
|
className="sub-card"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '1fr auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderLeft: match.is_new ? '3px solid #f43f5e' : undefined,
|
||||||
|
cursor: match.is_new ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => match.is_new && handleMarkRead(match.id)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h4 className="sub-card__name" style={{ margin: 0 }}>
|
||||||
|
{match.house_nm || `공고 #${match.announcement_id}`}
|
||||||
|
</h4>
|
||||||
|
{match.is_new && (
|
||||||
|
<span className="sub-badge" style={{ color: '#f43f5e', background: 'rgba(244,63,94,0.1)' }}>
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
||||||
|
{match.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(match.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<p className="sub-card__address" style={{ margin: 0 }}>
|
||||||
|
{match.region_name || '-'}
|
||||||
|
{match.receipt_start && (
|
||||||
|
<span style={{ marginLeft: 8 }}>
|
||||||
|
{fmt(match.receipt_start)} ~ {fmt(match.receipt_end)}
|
||||||
|
{(() => {
|
||||||
|
const dd = getDDays(match.receipt_start);
|
||||||
|
return dd ? <span style={{ marginLeft: 6, fontWeight: 600, color: getDDayColor(match.receipt_start) }}>{dd}</span> : null;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{match.eligible_types && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{(Array.isArray(match.eligible_types)
|
||||||
|
? match.eligible_types
|
||||||
|
: (() => { try { return JSON.parse(match.eligible_types); } catch { return []; } })()
|
||||||
|
).map((t, i) => (
|
||||||
|
<span key={i} className="sub-tag is-neutral" style={{ fontSize: 10 }}>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match.match_reasons?.length > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{(Array.isArray(match.match_reasons)
|
||||||
|
? match.match_reasons
|
||||||
|
: (() => { try { return JSON.parse(match.match_reasons); } catch { return []; } })()
|
||||||
|
).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match.score_breakdown && (
|
||||||
|
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'region', max: 35, color: '#00d4ff' },
|
||||||
|
{ key: 'type', max: 10, color: '#8b5cf6' },
|
||||||
|
{ key: 'area', max: 15, color: '#f59e0b' },
|
||||||
|
{ key: 'price', max: 15, color: '#f43f5e' },
|
||||||
|
{ key: 'eligibility', max: 25, color: '#34d399' },
|
||||||
|
].map(({ key, max, color }) => {
|
||||||
|
const v = match.score_breakdown[key] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: (match.match_score ?? 0) >= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{match.match_score ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
매칭 점수
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{myPoints && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, padding: '3px 8px', borderRadius: 4,
|
||||||
|
background: myPoints.total >= 50 ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)',
|
||||||
|
color: myPoints.total >= 50 ? '#34d399' : '#f87171',
|
||||||
|
fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
가점 {myPoints.total}점
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 8 }}>
|
||||||
|
<button className="sub-filter-btn" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
||||||
|
‹ 이전
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-dim)', padding: '4px 8px' }}>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button className="sub-filter-btn" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||||
|
다음 ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchesTab;
|
||||||
399
src/pages/subscription/components/ProfileTab.jsx
Normal file
399
src/pages/subscription/components/ProfileTab.jsx
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPut } from '../../../api';
|
||||||
|
import { DEFAULT_PROFILE } from '../subscriptionUtils';
|
||||||
|
import DistrictTierEditor from './DistrictTierEditor';
|
||||||
|
import NotificationSettings from './NotificationSettings';
|
||||||
|
|
||||||
|
// ── ProfileTab ────────────────────────────────────────────────────────────────
|
||||||
|
function ProfileTab() {
|
||||||
|
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
|
||||||
|
const [passCount, setPassCount] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [data, dash] = await Promise.all([
|
||||||
|
apiGet('/api/realestate/profile'),
|
||||||
|
apiGet('/api/realestate/dashboard').catch(() => null),
|
||||||
|
]);
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const display = { ...DEFAULT_PROFILE, ...data };
|
||||||
|
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
|
||||||
|
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
|
||||||
|
setProfile(display);
|
||||||
|
}
|
||||||
|
if (dash?.pass_count != null) setPassCount(dash.pass_count);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Profile load error:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (key, value) => {
|
||||||
|
setProfile(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckbox = (key) => {
|
||||||
|
setProfile(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const payload = { ...profile };
|
||||||
|
// Convert numeric strings to numbers
|
||||||
|
['age', 'subscription_months', 'subscription_amount', 'family_members',
|
||||||
|
'children_count', 'marriage_months', 'min_area', 'max_area', 'max_price'
|
||||||
|
].forEach(k => {
|
||||||
|
if (payload[k] !== '' && payload[k] != null) {
|
||||||
|
payload[k] = Number(payload[k]);
|
||||||
|
} else {
|
||||||
|
payload[k] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Convert comma-separated strings to arrays
|
||||||
|
payload.preferred_regions = typeof payload.preferred_regions === 'string'
|
||||||
|
? payload.preferred_regions.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
: (payload.preferred_regions || []);
|
||||||
|
payload.preferred_types = typeof payload.preferred_types === 'string'
|
||||||
|
? payload.preferred_types.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
: (payload.preferred_types || []);
|
||||||
|
// Send empty arrays as null
|
||||||
|
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
|
||||||
|
if (payload.preferred_types.length === 0) payload.preferred_types = null;
|
||||||
|
|
||||||
|
// 신규: preferred_districts (객체), min_match_score, notify_enabled
|
||||||
|
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
|
||||||
|
? profile.preferred_districts
|
||||||
|
: {};
|
||||||
|
payload.min_match_score = profile.min_match_score ?? null;
|
||||||
|
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||||
|
|
||||||
|
const updated = await apiPut('/api/realestate/profile', payload);
|
||||||
|
if (updated && Object.keys(updated).length > 0) {
|
||||||
|
// Convert arrays back to comma-separated strings for display
|
||||||
|
const display = { ...DEFAULT_PROFILE, ...updated };
|
||||||
|
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
|
||||||
|
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
|
||||||
|
setProfile(display);
|
||||||
|
}
|
||||||
|
setMessage('저장 완료');
|
||||||
|
setTimeout(() => setMessage(''), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Profile save error:', e);
|
||||||
|
setMessage('저장 실패: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="sub-empty">불러오는 중...</div>;
|
||||||
|
|
||||||
|
const pts = profile.subscription_points;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
{/* 가점 카드 */}
|
||||||
|
{pts && pts.total > 0 && (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">청약 가점</p>
|
||||||
|
<h3>내 가점 현황</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-display)', fontSize: 36, fontWeight: 700,
|
||||||
|
color: pts.total >= 60 ? '#34d399' : pts.total >= 40 ? '#f59e0b' : '#f87171',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{pts.total}<span style={{ fontSize: 16, color: 'var(--text-muted)', fontWeight: 400 }}> / {pts.max_total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{ label: '무주택기간', data: pts.homeless_duration, color: '#00d4ff' },
|
||||||
|
{ label: '부양가족 수', data: pts.dependents, color: '#8b5cf6' },
|
||||||
|
{ label: '청약통장 가입기간', data: pts.subscription_period, color: '#f59e0b' },
|
||||||
|
].map(({ label, data, color }) => (
|
||||||
|
<div key={label} style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 700, color }}>{data.score}</span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}> / {data.max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 3, background: color,
|
||||||
|
width: `${(data.score / data.max) * 100}%`, transition: 'width 0.3s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{data.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">프로필</p>
|
||||||
|
<h3>내 청약 프로필</h3>
|
||||||
|
<p className="sub-panel__sub">자격 조건과 선호 조건을 설정하면 공고 매칭에 활용됩니다. <span style={{ color: '#f43f5e', fontSize: 11 }}>* 필수 입력</span></p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{message && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: message.startsWith('저장 완료') ? '#34d399' : '#f87171',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn is-active"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로필 완성도 힌트 */}
|
||||||
|
{(() => {
|
||||||
|
const missing = [];
|
||||||
|
if (!profile.income_level) missing.push('소득 수준');
|
||||||
|
if (!profile.min_area || !profile.max_area) missing.push('희망 면적');
|
||||||
|
if (!profile.max_price) missing.push('최대 예산');
|
||||||
|
const hasDistricts = profile.preferred_districts &&
|
||||||
|
Object.values(profile.preferred_districts).some(arr => arr?.length > 0);
|
||||||
|
if (!hasDistricts) missing.push('자치구 티어');
|
||||||
|
if (missing.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="sub-profile-hint">
|
||||||
|
<span className="sub-profile-hint__icon">💡</span>
|
||||||
|
<span>
|
||||||
|
<strong>매칭 정확도 개선 가능</strong> — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="sub-modal__form">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<p className="sub-form-section__title">기본 정보</p>
|
||||||
|
<div className="sub-form-row">
|
||||||
|
<label className="sub-form-label">
|
||||||
|
이름
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
value={profile.name || ''}
|
||||||
|
onChange={e => handleChange('name', e.target.value)}
|
||||||
|
placeholder="이름"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
나이
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.age || ''}
|
||||||
|
onChange={e => handleChange('age', e.target.value)}
|
||||||
|
placeholder="만 나이"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자격 조건 */}
|
||||||
|
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<p className="sub-form-section__title">자격 조건</p>
|
||||||
|
|
||||||
|
<div className="sub-form-checks">
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_homeless} onChange={() => handleCheckbox('is_homeless')} />
|
||||||
|
무주택자 *
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_householder} onChange={() => handleCheckbox('is_householder')} />
|
||||||
|
세대주 *
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.has_dependents} onChange={() => handleCheckbox('has_dependents')} />
|
||||||
|
부양가족 있음
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_newlywed} onChange={() => handleCheckbox('is_newlywed')} />
|
||||||
|
신혼부부
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.has_newborn} onChange={() => handleCheckbox('has_newborn')} />
|
||||||
|
출산/입양
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_first_home} onChange={() => handleCheckbox('is_first_home')} />
|
||||||
|
생애최초
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
청약 납입 기간 (개월) *
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.subscription_months || ''}
|
||||||
|
onChange={e => handleChange('subscription_months', e.target.value)}
|
||||||
|
placeholder="예: 84"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
청약 납입 금액 (만원)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.subscription_amount || ''}
|
||||||
|
onChange={e => handleChange('subscription_amount', e.target.value)}
|
||||||
|
placeholder="예: 1500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
가족 수 *
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.family_members || ''}
|
||||||
|
onChange={e => handleChange('family_members', e.target.value)}
|
||||||
|
placeholder="본인 포함"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
자녀 수
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.children_count || ''}
|
||||||
|
onChange={e => handleChange('children_count', e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
혼인 기간 (개월)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.marriage_months || ''}
|
||||||
|
onChange={e => handleChange('marriage_months', e.target.value)}
|
||||||
|
placeholder="미혼이면 비워두세요"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
소득 수준 (%)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="300"
|
||||||
|
value={profile.income_level || ''}
|
||||||
|
onChange={e => handleChange('income_level', e.target.value)}
|
||||||
|
placeholder="도시근로자 월평균 대비 %"
|
||||||
|
/>
|
||||||
|
<span className="sub-form-hint">청년 ≤140 / 신혼·생애최초 ≤160 / 신생아 ≤200 · 미입력 시 검증 생략</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선호 조건 */}
|
||||||
|
<div className="sub-form-section">
|
||||||
|
<p className="sub-form-section__title">선호 조건</p>
|
||||||
|
|
||||||
|
<div className="sub-form-row">
|
||||||
|
<label className="sub-form-label">
|
||||||
|
선호 지역 *
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
value={profile.preferred_regions || ''}
|
||||||
|
onChange={e => handleChange('preferred_regions', e.target.value)}
|
||||||
|
placeholder="예: 서울, 경기"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
선호 주택유형
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
value={profile.preferred_types || ''}
|
||||||
|
onChange={e => handleChange('preferred_types', e.target.value)}
|
||||||
|
placeholder="예: 국민주택, 민영주택"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
최소 면적 (m²)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.min_area || ''}
|
||||||
|
onChange={e => handleChange('min_area', e.target.value)}
|
||||||
|
placeholder="예: 59"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
최대 면적 (m²)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.max_area || ''}
|
||||||
|
onChange={e => handleChange('max_area', e.target.value)}
|
||||||
|
placeholder="예: 84"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
최대 분양가 (만원)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.max_price || ''}
|
||||||
|
onChange={e => handleChange('max_price', e.target.value)}
|
||||||
|
placeholder="예: 80000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자치구 5티어 */}
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={profile.preferred_districts}
|
||||||
|
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 알림 설정 */}
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score ?? 70}
|
||||||
|
notifyEnabled={profile.notify_enabled ?? true}
|
||||||
|
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||||
|
passCount={passCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileTab;
|
||||||
14
src/pages/subscription/components/StatusBadge.jsx
Normal file
14
src/pages/subscription/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||||
|
|
||||||
|
function StatusBadge({ status, size }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
|
||||||
|
const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
|
||||||
|
return (
|
||||||
|
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{status || '알 수 없음'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
14
src/pages/subscription/components/StatusBadge.test.jsx
Normal file
14
src/pages/subscription/components/StatusBadge.test.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import StatusBadge from './StatusBadge.jsx';
|
||||||
|
|
||||||
|
describe('StatusBadge', () => {
|
||||||
|
it('status 라벨 렌더', () => {
|
||||||
|
render(<StatusBadge status="청약중" />);
|
||||||
|
expect(screen.getByText('청약중')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('미정의 status → "알 수 없음" 폴백', () => {
|
||||||
|
render(<StatusBadge status={null} />);
|
||||||
|
expect(screen.getByText('알 수 없음')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/pages/subscription/subscriptionUtils.js
Normal file
105
src/pages/subscription/subscriptionUtils.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/* 청약(Subscription) 모듈 상수·순수 헬퍼 */
|
||||||
|
|
||||||
|
export const STATUS_CONFIG = {
|
||||||
|
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
||||||
|
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
|
||||||
|
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
|
||||||
|
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOUSE_TYPE_LABELS = {
|
||||||
|
'01': '국민주택',
|
||||||
|
'02': '민영주택',
|
||||||
|
'03': '도시형생활주택',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
|
||||||
|
|
||||||
|
export const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
|
||||||
|
|
||||||
|
export const DEFAULT_PROFILE = {
|
||||||
|
name: '', age: '', is_homeless: false, is_householder: false,
|
||||||
|
subscription_months: '', subscription_amount: '',
|
||||||
|
family_members: '', has_dependents: false, children_count: '',
|
||||||
|
is_newlywed: false, marriage_months: '',
|
||||||
|
has_newborn: false, is_first_home: false, income_level: '',
|
||||||
|
preferred_regions: '', preferred_types: '',
|
||||||
|
min_area: '', max_area: '', max_price: '',
|
||||||
|
preferred_districts: {},
|
||||||
|
min_match_score: 70,
|
||||||
|
notify_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractTier(reasons) {
|
||||||
|
for (const r of reasons || []) {
|
||||||
|
const m = r.match(/자치구 ([SABCD])티어/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmt = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fmtFull = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const _diffDays = (d) => {
|
||||||
|
if (!d) return null;
|
||||||
|
const [y, m, day] = d.split('-').map(Number);
|
||||||
|
const target = new Date(y, m - 1, day);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((target - today) / 86400000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDDays = (d) => {
|
||||||
|
const diff = _diffDays(d);
|
||||||
|
if (diff === null) return null;
|
||||||
|
if (diff === 0) return 'D-Day';
|
||||||
|
return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDDayColor = (d) => {
|
||||||
|
const diff = _diffDays(d);
|
||||||
|
if (diff === null) return 'var(--text-dim)';
|
||||||
|
if (diff <= 0) return '#f87171';
|
||||||
|
if (diff <= 3) return '#f59e0b';
|
||||||
|
if (diff <= 7) return '#00d4ff';
|
||||||
|
return 'var(--text-dim)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fmtDateTime = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function apiPatch(path, body) {
|
||||||
|
const opts = {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtPrice = (v) => {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
|
||||||
|
return `${v.toLocaleString()}만`;
|
||||||
|
};
|
||||||
42
src/pages/subscription/subscriptionUtils.test.js
Normal file
42
src/pages/subscription/subscriptionUtils.test.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractTier, getDDays, getDDayColor, fmtPrice } from './subscriptionUtils.js';
|
||||||
|
|
||||||
|
describe('extractTier', () => {
|
||||||
|
it('자치구 티어 문자열에서 등급 추출', () => {
|
||||||
|
expect(extractTier(['자치구 S티어: 강남구 (+25)'])).toBe('S');
|
||||||
|
expect(extractTier(['면적 +10', '자치구 B티어: 노원구 (+15)'])).toBe('B');
|
||||||
|
});
|
||||||
|
it('미매치/빈 입력 → null', () => {
|
||||||
|
expect(extractTier(['면적 적합'])).toBe(null);
|
||||||
|
expect(extractTier(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDDays / getDDayColor (오늘 기준 경계)', () => {
|
||||||
|
const iso = (offsetDays) => {
|
||||||
|
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + offsetDays);
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
it('오늘=D-Day, 미래=D-N, 과거=D+N', () => {
|
||||||
|
expect(getDDays(iso(0))).toBe('D-Day');
|
||||||
|
expect(getDDays(iso(5))).toBe('D-5');
|
||||||
|
expect(getDDays(iso(-3))).toBe('D+3');
|
||||||
|
expect(getDDays(null)).toBe(null);
|
||||||
|
});
|
||||||
|
it('색 임계: 지남=빨강, ≤3=주황, ≤7=파랑, 그 외=dim', () => {
|
||||||
|
expect(getDDayColor(iso(-1))).toBe('#f87171');
|
||||||
|
expect(getDDayColor(iso(2))).toBe('#f59e0b');
|
||||||
|
expect(getDDayColor(iso(6))).toBe('#00d4ff');
|
||||||
|
expect(getDDayColor(iso(30))).toBe('var(--text-dim)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fmtPrice (현 구현 동작 고정)', () => {
|
||||||
|
it('억/만 변환', () => {
|
||||||
|
expect(fmtPrice(10000)).toBe('1억');
|
||||||
|
expect(fmtPrice(15000)).toBe('1.5억');
|
||||||
|
expect(fmtPrice(9999)).toBe('9,999만');
|
||||||
|
expect(fmtPrice(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ const Stock = lazy(() => import('./pages/stock/Stock'));
|
|||||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
||||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||||
|
const Listings = lazy(() => import('./pages/listings/Listings'));
|
||||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||||
@@ -214,6 +215,10 @@ export const appRoutes = [
|
|||||||
path: 'realestate',
|
path: 'realestate',
|
||||||
element: <Subscription />,
|
element: <Subscription />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'realestate/listings',
|
||||||
|
element: <Listings />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
|
|||||||
Reference in New Issue
Block a user