From a087c3d1e502ad5f8583e5e9adc541125e5472c3 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 14:31:41 +0900 Subject: [PATCH] =?UTF-8?q?docs(listings):=20=EC=8B=A4=EB=A7=A4=EB=AC=BC?= =?UTF-8?q?=20=EB=A7=A4=EB=AC=BC=C2=B7=EC=95=88=EC=A0=84=EB=A7=88=EC=A7=84?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=C2=B7=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- .../plans/2026-07-09-realestate-listings.md | 1071 +++++++++++++++++ .../2026-07-09-realestate-listings-design.md | 157 +++ 2 files changed, 1228 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-realestate-listings.md create mode 100644 docs/superpowers/specs/2026-07-09-realestate-listings-design.md diff --git a/docs/superpowers/plans/2026-07-09-realestate-listings.md b/docs/superpowers/plans/2026-07-09-realestate-listings.md new file mode 100644 index 0000000..c029d81 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-realestate-listings.md @@ -0,0 +1,1071 @@ +# 실매물 매물·안전마진 UI 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/listings` 신규 페이지에 실매물(매매/전세) 매물 목록·판정 tier·안전마진 체커·예산 계산기·조건 편집을 구현한다. + +**Architecture:** 전용 모듈 `src/pages/listings/`. 순수헬퍼(tier/금액) → api.js 헬퍼 8종 → 자립형 탭 3개(매물/판정도구/조건, 각자 api 호출·Subscription 탭 패턴) → Listings shell + routes 배선. 각 탭은 `vi.mock('../../../api')`로 스모크 테스트. + +**Tech Stack:** React 18 + Vite, Vitest + @testing-library/react, 기존 `apiGet/apiPost/apiPut` 헬퍼. + +## Global Constraints + +- **API는 항상 상대경로** `/api/realestate/*`, 전부 `src/api.js`의 `apiGet/apiPost/apiPut` 경유. +- **`regulation_flags`/`reasons`는 이미 배열 — `JSON.parse` 금지, 그대로 `.map` 렌더.** +- **어떤 응답 필드가 객체여도 JSX 자식으로 직접 렌더 금지** (tier/금액/문자열/배열-칩만 렌더). +- **disclaimer 응답에 있으면 반드시 노출** (safety-check·budget 결과). +- 금액은 **만원 단위**. 판정 색: 🟢`#34d399`/🟡`#f59e0b`/🔴`#f87171`/⚪`#94a3b8`. +- tier: 임차 safety_tier=안전/주의/위험/보류, 매매 valuation_tier=저평가/시세/고가/보류. 표본<3→"보류". +- 응답 방어적 파싱: `data.listings ?? []`, `data.matches ?? []`, `data.criteria ?? data`. +- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, 실행 `npm run test:run`, `*.test.js(x)` 동일 디렉토리. jest-dom은 `src/test-setup.js`에서 전역 등록됨(테스트 파일에 import 불필요). +- 스타일: Subscription CSS 변수 재사용(`--text-bright/--text-dim/--text-muted/--surface/--surface-raised/--line/--radius-sm`), 클래스 `lst-*`. +- 커밋은 web-ui 경로, 변경 파일만 명시적 `git add`. 커밋 trailer: + ``` + Co-Authored-By: Claude Opus 4.8 (1M context) + Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS + ``` + +--- + +### Task 1: 순수 헬퍼 `listingsUtils.js` (tier/금액 매핑) + +**Files:** +- Create: `src/pages/listings/listingsUtils.js` +- Test: `src/pages/listings/listingsUtils.test.js` + +**Interfaces:** +- Produces: `tierMeta(kind:'safety'|'valuation', tier:string) => {emoji, color, label}`; `formatMoney(manwon) => string`; `formatRatio(ratio) => string`; `SAFETY_TIER_META`, `VALUATION_TIER_META`. + +- [ ] **Step 1: Write the failing test** — Create `src/pages/listings/listingsUtils.test.js`: + +```js +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('-'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/listings/listingsUtils.test.js` +Expected: FAIL — `Failed to resolve import "./listingsUtils.js"`. + +- [ ] **Step 3: Write the implementation** — Create `src/pages/listings/listingsUtils.js`: + +```js +/* 실매물 판정 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)}%`; +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:run -- src/pages/listings/listingsUtils.test.js` +Expected: PASS (3 describe 전부). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/listings/listingsUtils.js src/pages/listings/listingsUtils.test.js +git commit -m "feat(listings): 실매물 tier/금액 순수 헬퍼 + 테스트" +``` + +--- + +### Task 2: API 헬퍼 8종 (`src/api.js`) + +**Files:** +- Modify: `src/api.js` (파일 끝에 추가) + +**Interfaces:** +- Produces: `getListings(params)`, `collectListings()`, `getListingsCollectStatus()`, `getListingsCriteria()`, `putListingsCriteria(body)`, `getListingsMatches()`, `safetyCheck(body)`, `budgetCalc(body)`. + +- [ ] **Step 1: Add API helpers** — `src/api.js` 맨 끝(마지막 export 뒤)에 추가: + +```js +// ── 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); +``` + +- [ ] **Step 2: Verify no syntax break** + +Run: `npm run test:run -- src/api` +Expected: (api.js has no dedicated test) — instead run `npm run build 2>&1 | tail -3` +Expected: `✓ built` (no syntax error). If build not desired here, run `node --check` is N/A for ESM+JSX; rely on Task 6 build. Minimum: `npx eslint src/api.js` → 0 new errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/api.js +git commit -m "feat(listings): realestate 실매물/안전마진/예산 API 헬퍼 8종" +``` + +--- + +### Task 3: `ListingsTab.jsx` (매물 목록 + 필터 + 수집) + +**Files:** +- Create: `src/pages/listings/components/ListingsTab.jsx` +- Test: `src/pages/listings/components/ListingsTab.test.jsx` + +**Interfaces:** +- Consumes (Task 1): `tierMeta`, `formatMoney`, `formatRatio`. (Task 2): `getListings`, `collectListings`, `getListingsCollectStatus`. +- Produces: `ListingsTab` 기본 export (자립형, props 없음). + +- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/listings/components/ListingsTab.test.jsx`: + +```jsx +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(); + 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(); + await waitFor(() => expect(screen.getByText('상도더샵')).toBeInTheDocument()); + expect(screen.getByText(/고가/)).toBeInTheDocument(); + expect(screen.getByText('토지거래허가')).toBeInTheDocument(); + expect(screen.getByText(/호가가 최근 실거래/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/listings/components/ListingsTab.test.jsx` +Expected: FAIL — `Failed to resolve import "./ListingsTab.jsx"`. + +- [ ] **Step 3: Write the component** — Create `src/pages/listings/components/ListingsTab.jsx`: + +```jsx +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 ( +
+
+ {tv && {meta.emoji} {meta.label}} + {l.complex_name || l.name || l.dong || '(매물)'} + {l.deal_type && {l.deal_type}} + {l.dong && {l.dong}} +
+
+ {price != null && {formatMoney(price)}} + {l.area != null && {l.area}m²} + {l.ratio != null && 비율 {formatRatio(l.ratio)}} +
+ {flags.length > 0 && ( +
{flags.map((f, i) => {f})}
+ )} + {reasons.length > 0 &&
{reasons.join(' · ')}
} +
+ ); +}; + +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); + try { + await collectListings(); + setTimeout(() => { loadStatus(); load(); setCollecting(false); }, 3000); + } catch (e) { + setError(e?.message ?? String(e)); + setCollecting(false); + } + }; + + return ( +
+
+
+ {DEAL_TYPES.map((d) => ( + + ))} +
+
+ + +
+
+ + {collectStatus && ( +

+ 수집 상태: {collectStatus.status === 'never_run' ? '미실행' : collectStatus.status} + {collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`} + {collectStatus.error && · 오류: {collectStatus.error}} +

+ )} + + {error &&

{error}

} + + {loading ? ( +

불러오는 중…

+ ) : listings.length === 0 ? ( +

조건에 맞는 매물이 없습니다. ‘수집 실행’으로 매물을 모아보세요.

+ ) : ( +
+ {listings.map((l) => )} +
+ )} +
+ ); +}; + +export default ListingsTab; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:run -- src/pages/listings/components/ListingsTab.test.jsx` +Expected: PASS (2 케이스). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/listings/components/ListingsTab.jsx src/pages/listings/components/ListingsTab.test.jsx +git commit -m "feat(listings): 매물 목록 탭(필터·판정 tier·수집) + 스모크 테스트" +``` + +--- + +### Task 4: `ToolsTab.jsx` (안전마진 체커 + 예산 계산기) + +**Files:** +- Create: `src/pages/listings/components/ToolsTab.jsx` +- Test: `src/pages/listings/components/ToolsTab.test.jsx` + +**Interfaces:** +- Consumes (Task 1): `tierMeta`, `formatMoney`, `formatRatio`. (Task 2): `safetyCheck`, `budgetCalc`. +- Produces: `ToolsTab` 기본 export (자립형). + +- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/listings/components/ToolsTab.test.jsx`: + +```jsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() })); +import ToolsTab from './ToolsTab.jsx'; + +beforeEach(() => vi.clearAllMocks()); + +describe('ToolsTab', () => { + it('안전마진·예산 카드 헤딩과 disclaimer 안내를 렌더', () => { + render(); + expect(screen.getByText('안전마진 체커')).toBeInTheDocument(); + expect(screen.getByText('예산 계산기')).toBeInTheDocument(); + // 제출 버튼 존재 + expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/listings/components/ToolsTab.test.jsx` +Expected: FAIL — `Failed to resolve import "./ToolsTab.jsx"`. + +- [ ] **Step 3: Write the component** — Create `src/pages/listings/components/ToolsTab.jsx`: + +```jsx +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 ( +
+

안전마진 체커

+

면적·거래유형·금액으로 전세가율/호가율 적정성을 즉시 판정합니다.

+
+ set('area', e.target.value)} /> + + set('amount', e.target.value)} /> + set('dong', e.target.value)} /> + +
+ {error &&

{error}

} + {res && ( +
+
+ {meta.emoji} {meta.label} + {res.ratio != null && 비율 {formatRatio(res.ratio)}} + {res.median != null && 중앙값 {formatMoney(res.median)}} + {res.sample != null && 표본 {res.sample}건} + {res.is_toheo && 토지거래허가} +
+ {res.disclaimer &&

※ {res.disclaimer}

} +
+ )} +
+ ); +}; + +/* ── 예산 계산기 ── */ +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 ( +
+

예산 계산기

+

자기자본·소득 기준 전세/매매 한도와 지역 규제를 계산합니다.

+
+ set('equity', e.target.value)} /> + set('annual_income', e.target.value)} /> + set('target_dong', e.target.value)} /> + + + + +
+ {error &&

{error}

} + {res && ( +
+ {res.jeonse && ( +
+ 전세 + 대출한도 {formatMoney(res.jeonse.loan_limit)} + 최대 보증금 {formatMoney(res.jeonse.max_deposit)} + {res.jeonse.notes && {res.jeonse.notes}} +
+ )} + {res.purchase && ( +
+ 매매 + {res.purchase.ltv_pct != null && LTV {res.purchase.ltv_pct}%} + 대출한도 {formatMoney(res.purchase.loan_cap)} + 최대 매수가 {formatMoney(res.purchase.max_price)} + {res.purchase.dsr_note && {res.purchase.dsr_note}} + {Array.isArray(res.purchase.regulation_flags) && res.purchase.regulation_flags.length > 0 && ( +
{res.purchase.regulation_flags.map((f, i) => {f})}
+ )} +
+ )} + {res.region && ( +
+ 지역 + {res.region.is_toheo && 토지거래허가} + {res.region.is_regulated && 규제지역} + {res.region.notes && {res.region.notes}} +
+ )} + {res.disclaimer &&

※ {res.disclaimer}

} +
+ )} +
+ ); +}; + +const ToolsTab = () => ( +
+ + +
+); + +export default ToolsTab; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:run -- src/pages/listings/components/ToolsTab.test.jsx` +Expected: PASS (1 케이스). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/listings/components/ToolsTab.jsx src/pages/listings/components/ToolsTab.test.jsx +git commit -m "feat(listings): 판정 도구 탭(안전마진 체커+예산 계산기, disclaimer) + 스모크" +``` + +--- + +### Task 5: `CriteriaTab.jsx` (조건 편집) + +**Files:** +- Create: `src/pages/listings/components/CriteriaTab.jsx` +- Test: `src/pages/listings/components/CriteriaTab.test.jsx` + +**Interfaces:** +- Consumes (Task 2): `getListingsCriteria`, `putListingsCriteria`. +- Produces: `CriteriaTab` 기본 export (자립형). + +- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/listings/components/CriteriaTab.test.jsx`: + +```jsx +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(); + await waitFor(() => expect(screen.getByLabelText(/자기자본/)).toBeInTheDocument()); + expect(screen.getByLabelText(/연소득/)).toBeInTheDocument(); + expect(screen.getByDisplayValue('신대방동, 상도동')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/listings/components/CriteriaTab.test.jsx` +Expected: FAIL — `Failed to resolve import "./CriteriaTab.jsx"`. + +- [ ] **Step 3: Write the component** — Create `src/pages/listings/components/CriteriaTab.jsx`: + +```jsx +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 || !c) return

불러오는 중…

; + + return ( +
+
+
+

매물 매칭 조건

+

자기자본·연소득을 넣어야 매매 예산상한·전세 max_deposit이 매칭에 반영됩니다.

+
+
+ {msg && {msg}} + +
+
+ +
+ + +
+ 거래 유형 +
+ {DEAL_TYPES.map((d) => ( + + ))} +
+
+ +
+ 주택 유형 +
+ {HOUSE_TYPES.map((h) => ( + + ))} +
+
+ + + + + + + + +
+ + + + +
+
+
+ ); +}; + +export default CriteriaTab; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:run -- src/pages/listings/components/CriteriaTab.test.jsx` +Expected: PASS (1 케이스). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/listings/components/CriteriaTab.jsx src/pages/listings/components/CriteriaTab.test.jsx +git commit -m "feat(listings): 조건 편집 탭(equity·소득 포함 부분 PUT) + 스모크" +``` + +--- + +### Task 6: `Listings` shell + 스타일 + 라우팅 배선 + 문서 + +**Files:** +- Create: `src/pages/listings/Listings.jsx` +- Create: `src/pages/listings/Listings.css` +- Modify: `src/routes.jsx` (lazy import + appRoutes 항목) +- Modify: `src/pages/subscription/Subscription.jsx` (헤더에 "실매물 →" 링크 — 안전한 최소 삽입) +- Modify: `CLAUDE.md` (web-ui 루트, 페이지 구조 표 + API 표) +- Test: `src/pages/listings/Listings.test.jsx` + +**Interfaces:** +- Consumes (Task 3/4/5): `ListingsTab`, `ToolsTab`, `CriteriaTab`. +- Produces: `Listings` 기본 export (라우트 엘리먼트). + +- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/listings/Listings.test.jsx`: + +```jsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('./components/ListingsTab.jsx', () => ({ default: () =>
LISTINGS_TAB
})); +vi.mock('./components/ToolsTab.jsx', () => ({ default: () =>
TOOLS_TAB
})); +vi.mock('./components/CriteriaTab.jsx', () => ({ default: () =>
CRITERIA_TAB
})); +vi.mock('react-router-dom', () => ({ Link: ({ children }) => {children} })); +import Listings from './Listings.jsx'; + +describe('Listings', () => { + it('기본 매물 탭 렌더 + 탭 전환', () => { + render(); + 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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/listings/Listings.test.jsx` +Expected: FAIL — `Failed to resolve import "./Listings.jsx"`. + +- [ ] **Step 3: Write the shell** — Create `src/pages/listings/Listings.jsx`: + +```jsx +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 ( +
+
+
+

실매물 · 안전마진

+

매물 · 안전마진

+

매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.

+
+ 청약으로 → +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'listings' && } + {tab === 'tools' && } + {tab === 'criteria' && } + +

+ ※ 모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요. +

+
+ ); +}; + +export default Listings; +``` + +- [ ] **Step 4: Write the stylesheet** — Create `src/pages/listings/Listings.css`: + +```css +/* 실매물 매물·안전마진 (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; } +} +``` + +- [ ] **Step 5: Wire the route** — `src/routes.jsx`: + +(a) 상단 lazy import 블록에 (다른 `const X = lazy(...)` 근처, 예: `Subscription` import 뒤) 추가: +```js +const Listings = lazy(() => import('./pages/listings/Listings')); +``` +(b) `appRoutes` 배열의 `{ path: 'realestate', element: },` 바로 뒤에 추가: +```js + { + path: 'realestate/listings', + element: , + }, +``` + +- [ ] **Step 6: Add cross-link in Subscription** — `src/pages/subscription/Subscription.jsx`: + +`import { apiGet, apiPost, apiPut, apiDelete } from '../../api';` 뒤(파일 상단 import 블록)에 추가: +```js +import { Link } from 'react-router-dom'; +``` +그리고 `const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];` 를 사용하는 최상위 탭바 렌더 지점에 "실매물" 링크를 넣기 어려우면(파일이 큼), **최소 침습**으로: 메인 반환 JSX 최상단(페이지 컨테이너 여는 태그 직후)에 아래 배너 한 줄을 추가. 정확한 앵커는 `return (` 이후 첫 번째 최상위 ` + + 실매물 · 안전마진 → + + +``` +> 앵커를 확실히 못 찾으면 이 링크 삽입은 생략하고 report에 남길 것(라우트는 Task 6 Step 5로 이미 접근 가능). Listings→청약 링크는 shell에 이미 있음. + +- [ ] **Step 7: Run tests + lint + build** + +Run: `npm run test:run` +Expected: 전체 통과 (신규 5개 테스트 파일 포함), 기존 회귀 없음. 통과 수 기록. + +Run: `npm run lint` +Expected: 신규 파일 관련 에러 0 (기존 사전 경고 무시). + +Run: `npm run build` +Expected: `✓ built` 성공. + +- [ ] **Step 8: Update `web-ui/CLAUDE.md`** — 페이지 구조 표에 행 추가(`/realestate/property` 행 근처): +```markdown +| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) | +``` +API 엔드포인트 표에 realestate listings 8행 추가(부동산 섹션 근처): +```markdown +| 부동산-매물 | 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 } | +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/pages/listings/Listings.jsx src/pages/listings/Listings.css src/pages/listings/Listings.test.jsx src/routes.jsx src/pages/subscription/Subscription.jsx CLAUDE.md +git commit -m "feat(listings): Listings shell+스타일+라우트 배선 + 청약 교차링크 + 문서" +``` + +- [ ] **Step 10: Manual verification (dev server)** + +`npm run dev` → `http://localhost:3007/realestate/listings` 접속. 3탭(매물/판정도구/조건) 전환, 조건 탭 로드(criteria 200), 안전마진·예산 폼 제출 → disclaimer 노출 확인. 매물은 수집 전이라 빈 상태 안내가 정상. (수집 실행 후 실데이터로 매물 카드 필드 확인 — 필요 시 후속 조정.) + +--- + +## Self-Review 결과 + +**Spec coverage** (설계 §1–§10): +- §2 계약 8종 → Task 2 ✅ +- §3 라우팅·교차링크 → Task 6 ✅ +- §4 컴포넌트 구조(utils+3탭+shell) → Task 1/3/4/5/6 ✅ +- §5 API 레이어 → Task 2 ✅ +- §6 안전장치(disclaimer·보류·배열 그대로·방어적 파싱) → Task 3(ListingCard)·Task 4(disclaimer)·Task 5 ✅ +- §7 스타일 lst-* → Task 6 ✅ +- §8 테스트 → Task 1(utils)·Task 3/4/5/6(스모크) ✅ +- §9 완료 기준 → Task 6 Step 7·10 ✅ +- §10 리스크(매물 아이템 방어적 렌더) → Task 3 ListingCard 옵셔널 필드 ✅ + +**Placeholder scan:** 모든 코드/명령/기대출력 구체값. Task 6 Step 6의 Subscription 링크 앵커는 "못 찾으면 생략+report"로 명시(플레이스홀더 아님, 안전 폴백). ✅ + +**Type consistency:** `tierMeta(kind,tier)`/`formatMoney`/`formatRatio`(Task1) ↔ Task3/4 사용 일치. api 헬퍼 8종(Task2) ↔ Task3/4/5 import 일치. `ListingsTab`/`ToolsTab`/`CriteriaTab` 기본 export ↔ Task6 shell import 일치. ✅ + +**참고 — routes.jsx/Subscription 라인:** 근사치. 실제 편집은 앵커 문자열로 확인 후 삽입. diff --git a/docs/superpowers/specs/2026-07-09-realestate-listings-design.md b/docs/superpowers/specs/2026-07-09-realestate-listings-design.md new file mode 100644 index 0000000..274bc05 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-realestate-listings-design.md @@ -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`는 보조.