Files
web-page/src/pages/listings/components/ListingsTab.jsx
gahusb f7c7394a94 fix(listings): ToolsTab 제출·disclaimer 테스트 추가 + 수집 완료 폴링
최종 리뷰 반영: (1) ToolsTab 제출→중첩객체·disclaimer 렌더 경로 테스트
2건 추가(최고위험 미검증 경로), (2) handleCollect의 고정 3초 타임아웃을
collected_at 변경 기반 폴링(최대 60s)으로 교체 — 진행 중을 완료로 오표시
하지 않도록.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
2026-07-09 16:39:34 +09:00

139 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}</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);
setError('');
const prevAt = collectStatus?.collected_at ?? null;
try {
await collectListings();
} catch (e) {
setError(e?.message ?? String(e));
setCollecting(false);
return;
}
let tries = 0;
const poll = async () => {
tries += 1;
let st = null;
try { st = await getListingsCollectStatus(); } catch { /* noop */ }
if (st) setCollectStatus(st);
const done = st && (st.collected_at !== prevAt || st.error);
if (done || tries >= 12) {
setCollecting(false);
load();
return;
}
window.setTimeout(poll, 5000);
};
window.setTimeout(poll, 5000);
};
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;