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();
+ });
+});