From 9fc023da9d1358864b090e21fc25b85dfcd7682c Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 16:11:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(listings):=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=ED=83=AD(=EC=95=88=EC=A0=84=EB=A7=88?= =?UTF-8?q?=EC=A7=84=20=EC=B2=B4=EC=BB=A4+=EC=98=88=EC=82=B0=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EA=B8=B0,=20disclaimer)=20+=20=EC=8A=A4=EB=AA=A8?= =?UTF-8?q?=ED=81=AC?= 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 --- src/pages/listings/components/ToolsTab.jsx | 161 ++++++++++++++++++ .../listings/components/ToolsTab.test.jsx | 17 ++ 2 files changed, 178 insertions(+) create mode 100644 src/pages/listings/components/ToolsTab.jsx create mode 100644 src/pages/listings/components/ToolsTab.test.jsx diff --git a/src/pages/listings/components/ToolsTab.jsx b/src/pages/listings/components/ToolsTab.jsx new file mode 100644 index 0000000..6847fcb --- /dev/null +++ b/src/pages/listings/components/ToolsTab.jsx @@ -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 ( +
+

안전마진 체커

+

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

+
+ 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; diff --git a/src/pages/listings/components/ToolsTab.test.jsx b/src/pages/listings/components/ToolsTab.test.jsx new file mode 100644 index 0000000..285c7b7 --- /dev/null +++ b/src/pages/listings/components/ToolsTab.test.jsx @@ -0,0 +1,17 @@ +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); + }); +});