From 4869e3c97a01b2172caed7db761d8d8078e6f9df Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 14:40:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(listings):=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=83=AD(=ED=95=84=ED=84=B0=C2=B7?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20tier=C2=B7=EC=88=98=EC=A7=91)=20+=20?= =?UTF-8?q?=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= 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/ListingsTab.jsx | 121 ++++++++++++++++++ .../listings/components/ListingsTab.test.jsx | 36 ++++++ 2 files changed, 157 insertions(+) create mode 100644 src/pages/listings/components/ListingsTab.jsx create mode 100644 src/pages/listings/components/ListingsTab.test.jsx diff --git a/src/pages/listings/components/ListingsTab.jsx b/src/pages/listings/components/ListingsTab.jsx new file mode 100644 index 0000000..79cef86 --- /dev/null +++ b/src/pages/listings/components/ListingsTab.jsx @@ -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 ( +
+
+ {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; diff --git a/src/pages/listings/components/ListingsTab.test.jsx b/src/pages/listings/components/ListingsTab.test.jsx new file mode 100644 index 0000000..f4c4587 --- /dev/null +++ b/src/pages/listings/components/ListingsTab.test.jsx @@ -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(); + 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(); + }); +});