feat(listings): 조건 편집 탭(equity·소득 포함 부분 PUT) + 스모크
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:
147
src/pages/listings/components/CriteriaTab.jsx
Normal file
147
src/pages/listings/components/CriteriaTab.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
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 <p className="lst-empty">불러오는 중…</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;
|
||||
24
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
24
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user