Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
158 lines
9.9 KiB
Markdown
158 lines
9.9 KiB
Markdown
# 실매물 매물·안전마진 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`는 보조.
|