feat(listings): 판정 도구 탭(안전마진 체커+예산 계산기, disclaimer) + 스모크
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:
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;
|
||||
17
src/pages/listings/components/ToolsTab.test.jsx
Normal file
17
src/pages/listings/components/ToolsTab.test.jsx
Normal file
@@ -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(<ToolsTab />);
|
||||
expect(screen.getByText('안전마진 체커')).toBeInTheDocument();
|
||||
expect(screen.getByText('예산 계산기')).toBeInTheDocument();
|
||||
// 제출 버튼 존재
|
||||
expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user