merge: 실매물 매물·안전마진 UI (/realestate/listings) FE
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||
@@ -138,6 +139,12 @@ proxy: {
|
||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||
| 부동산 | 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` |
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
|
||||
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
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`는 보조.
|
||||
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 removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
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);
|
||||
|
||||
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,5 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||
@@ -1598,6 +1599,11 @@ function Subscription() {
|
||||
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>
|
||||
{/* Header */}
|
||||
<div className="sub-header">
|
||||
<div>
|
||||
|
||||
@@ -24,6 +24,7 @@ const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||
const Listings = lazy(() => import('./pages/listings/Listings'));
|
||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||
@@ -214,6 +215,10 @@ export const appRoutes = [
|
||||
path: 'realestate',
|
||||
element: <Subscription />,
|
||||
},
|
||||
{
|
||||
path: 'realestate/listings',
|
||||
element: <Listings />,
|
||||
},
|
||||
{
|
||||
path: 'travel',
|
||||
element: <Travel />,
|
||||
|
||||
Reference in New Issue
Block a user