feat(listings): 매물 목록 탭(필터·판정 tier·수집) + 스모크 테스트
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:
121
src/pages/listings/components/ListingsTab.jsx
Normal file
121
src/pages/listings/components/ListingsTab.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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 (
|
||||||
|
<div className="lst-card">
|
||||||
|
<div className="lst-card__head">
|
||||||
|
{tv && <span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>}
|
||||||
|
<strong className="lst-card__name">{l.complex_name || l.name || l.dong || '(매물)'}</strong>
|
||||||
|
{l.deal_type && <span className="lst-card__deal">{l.deal_type}</span>}
|
||||||
|
{l.dong && <span className="lst-card__dong">{l.dong}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="lst-card__body">
|
||||||
|
{price != null && <span className="lst-card__price">{formatMoney(price)}</span>}
|
||||||
|
{l.area != null && <span className="lst-card__area">{l.area}m²</span>}
|
||||||
|
{l.ratio != null && <span className="lst-card__ratio">비율 {formatRatio(l.ratio)}</span>}
|
||||||
|
</div>
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<div className="lst-flags">{flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||||
|
)}
|
||||||
|
{reasons.length > 0 && <div className="lst-card__reasons">{reasons.join(' · ')}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="lst-tab">
|
||||||
|
<div className="lst-toolbar">
|
||||||
|
<div className="lst-filter">
|
||||||
|
{DEAL_TYPES.map((d) => (
|
||||||
|
<button key={d} type="button" className={`lst-filter-btn ${dealType === d ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setDealType(d)}>{d}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="lst-toolbar__right">
|
||||||
|
<button type="button" className={`lst-filter-btn ${matchedOnly ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setMatchedOnly((v) => !v)}>매칭만</button>
|
||||||
|
<button type="button" className="lst-filter-btn is-primary" onClick={handleCollect} disabled={collecting}>
|
||||||
|
{collecting ? '수집 중…' : '수집 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collectStatus && (
|
||||||
|
<p className="lst-status">
|
||||||
|
수집 상태: {collectStatus.status === 'never_run' ? '미실행' : collectStatus.status}
|
||||||
|
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
|
||||||
|
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="lst-error">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="lst-empty">불러오는 중…</p>
|
||||||
|
) : listings.length === 0 ? (
|
||||||
|
<p className="lst-empty">조건에 맞는 매물이 없습니다. ‘수집 실행’으로 매물을 모아보세요.</p>
|
||||||
|
) : (
|
||||||
|
<div className="lst-grid">
|
||||||
|
{listings.map((l) => <ListingCard key={l.id ?? `${l.complex_name}-${l.dong}`} l={l} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingsTab;
|
||||||
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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(<ListingsTab />);
|
||||||
|
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(<ListingsTab />);
|
||||||
|
await waitFor(() => expect(screen.getByText('상도더샵')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/고가/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('토지거래허가')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/호가가 최근 실거래/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user