import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import PullToRefresh from '../../components/PullToRefresh'; import FAB from '../../components/FAB'; import './Subscription.css'; // ── 상수 ─────────────────────────────────────────────────────────────────────── const STATUS_CONFIG = { '청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' }, '청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' }, '결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }, '완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' }, }; const HOUSE_TYPE_LABELS = { '01': '국민주택', '02': '민영주택', '03': '도시형생활주택', }; const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필']; const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료']; const DEFAULT_PROFILE = { name: '', age: '', is_homeless: false, is_householder: false, subscription_months: '', subscription_amount: '', family_members: '', has_dependents: false, children_count: '', is_newlywed: false, marriage_months: '', has_newborn: false, is_first_home: false, income_level: '', preferred_regions: '', preferred_types: '', min_area: '', max_area: '', max_price: '', }; // ── 유틸 ────────────────────────────────────────────────────────────────────── const fmt = (d) => { if (!d) return '-'; return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }); }; const fmtFull = (d) => { if (!d) return '-'; return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' }); }; const _diffDays = (d) => { if (!d) return null; // 로컬 타임존으로 통일하여 D-day 계산 (UTC 파싱 방지) const [y, m, day] = d.split('-').map(Number); const target = new Date(y, m - 1, day); const today = new Date(); today.setHours(0, 0, 0, 0); return Math.round((target - today) / 86400000); }; const getDDays = (d) => { const diff = _diffDays(d); if (diff === null) return null; if (diff === 0) return 'D-Day'; return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`; }; const getDDayColor = (d) => { const diff = _diffDays(d); if (diff === null) return 'var(--text-dim)'; if (diff <= 0) return '#f87171'; if (diff <= 3) return '#f59e0b'; if (diff <= 7) return '#00d4ff'; return 'var(--text-dim)'; }; const fmtDateTime = (d) => { if (!d) return '-'; return new Date(d).toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); }; async function apiPatch(path, body) { const opts = { method: 'PATCH', headers: { 'Accept': 'application/json' }, }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); } return res.json(); } const fmtPrice = (v) => { if (v == null) return null; if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`; return `${v.toLocaleString()}만`; }; // ── StatusBadge ────────────────────────────────────────────────────────────── function StatusBadge({ status, size }) { const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' }; const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge'; return ( {status || '알 수 없음'} ); } // ── DashboardTab ───────────────────────────────────────────────────────────── function DashboardTab() { const [dashboard, setDashboard] = useState(null); const [collectStatus, setCollectStatus] = useState(null); const [totalCount, setTotalCount] = useState(0); const [collecting, setCollecting] = useState(false); const [loading, setLoading] = useState(true); const load = async () => { setLoading(true); try { const [dash, status, ann] = await Promise.all([ apiGet('/api/realestate/dashboard'), apiGet('/api/realestate/collect/status').catch(() => null), apiGet('/api/realestate/announcements?page=1&size=1'), ]); setDashboard(dash); setCollectStatus(status); setTotalCount(ann?.total || 0); } catch (e) { console.error('Dashboard load error:', e); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const handleCollect = async () => { setCollecting(true); try { await apiPost('/api/realestate/collect'); // Wait a moment then refresh status setTimeout(async () => { try { const status = await apiGet('/api/realestate/collect/status'); setCollectStatus(status); } catch (_) {} setCollecting(false); load(); }, 3000); } catch (e) { console.error('Collect error:', e); setCollecting(false); } }; if (loading) return
불러오는 중...
; return (
{/* Stats Cards */}

{dashboard?.active_count ?? 0}

진행중 공고

0 ? '#f43f5e' : undefined }}> {dashboard?.new_match_count ?? 0}

신규 매칭

0 ? '#f59e0b' : undefined }}> {dashboard?.bookmarked_count ?? 0}

즐겨찾기

{totalCount}

전체 공고

{/* Collection Status */}

데이터 수집

공공데이터 수집 현황

{collectStatus && (

마지막 수집: {fmtDateTime(collectStatus.collected_at)} {collectStatus.new_count != null && ` · 신규 ${collectStatus.new_count}건`} {collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`} {collectStatus.error && · 오류: {collectStatus.error}}

)} {!collectStatus &&

수집 이력이 없습니다.

}
{/* Upcoming Schedules */}

일정

다가오는 일정

{dashboard?.upcoming_schedules?.length > 0 ? (
{dashboard.upcoming_schedules.map((s, i) => { const dday = getDDays(s.date); return (
{s.house_nm || s.label || '공고'} {fmtFull(s.date)} · {s.event || s.type || '일정'}
{dday && ( {dday} )}
); })}
) : (

다가오는 일정이 없습니다.

)}
{/* Bookmarked */} {dashboard?.bookmarked?.length > 0 && (

즐겨찾기

관심 공고

{dashboard.bookmarked.map((item) => { const dday = getDDays(item.receipt_start); const priceText = item.min_price != null ? (item.min_price === item.max_price_display ? fmtPrice(item.min_price) : `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`) : null; return (
{item.house_nm}
{item.region_name || '-'} {priceText && <> · {priceText}}
{dday && ( {dday} )}
); })}
)}
); } // ── AnnouncementCard ───────────────────────────────────────────────────────── function AnnouncementCard({ item, isSelected, onClick, onBookmark }) { const dday = getDDays(item.receipt_start); const priceText = item.min_price != null ? (item.min_price === item.max_price_display ? fmtPrice(item.min_price) : `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`) : null; return (
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && ( {HOUSE_TYPE_LABELS[item.house_secd]} )} {item.match_score > 0 && ( = 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8', background: item.match_score >= 70 ? 'rgba(52,211,153,0.1)' : item.match_score >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(148,163,184,0.1)', fontWeight: 700, }}> {item.match_score}점 )}

{item.house_nm || '(이름 없음)'}

{item.address || item.region_name || '-'}

{item.total_units ? `${item.total_units}세대` : '-'} · {item.region_name || '-'} {priceText && ( <> · {priceText} )}
{item.receipt_start && ( {fmt(item.receipt_start)} ~ {fmt(item.receipt_end)} )} {dday && ( {dday} )}
); } // ── AnnouncementDetail ─────────────────────────────────────────────────────── function AnnouncementDetail({ item, onBookmark }) { const [detailTab, setDetailTab] = useState('info'); if (!item) { return (
🏠 공고를 선택하면 상세 정보가 표시됩니다.
); } return (
{/* Header */}
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && ( {HOUSE_TYPE_LABELS[item.house_secd]} )}

{item.house_nm}

{item.address || item.region_name}

{item.match_score > 0 && (
= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8', }}> 매칭 {item.match_score}점 {item.eligible_types?.length > 0 && (
{(Array.isArray(item.eligible_types) ? item.eligible_types : []).map((t, i) => ( {t} ))}
)}
)}
{item.homepage_url && ( 홈페이지 )} {item.pblanc_url && ( 공고문 )}
{/* Section Tabs */}
{item.models?.length > 0 && ( )}
{/* Section Content */}
{detailTab === 'info' && (
단지명 {item.house_nm}
지역 {item.region_name || '-'}
주소 {item.address || '-'}
총 세대수 {item.total_units ? `${item.total_units}세대` : '-'}
시공사 {item.constructor || '-'}
시행사 {item.developer || '-'}
입주 예정 {item.move_in_month || '-'}
)} {detailTab === 'schedule' && (
{[ { label: '특별공급 접수', start: item.spsply_start, end: item.spsply_end }, { label: '1순위 접수', start: item.gnrl_rank1_start, end: item.gnrl_rank1_end }, { label: '일반공급 접수', start: item.receipt_start, end: item.receipt_end }, { label: '당첨자 발표', start: item.winner_date }, { label: '계약', start: item.contract_start, end: item.contract_end }, ].filter(s => s.start).map((s, i) => { const dday = getDDays(s.start); return (
{s.label} {fmtFull(s.start)}{s.end ? ` ~ ${fmtFull(s.end)}` : ''}
{dday && ( {dday} )}
); })} {![item.spsply_start, item.gnrl_rank1_start, item.receipt_start, item.winner_date, item.contract_start].some(Boolean) && (

일정 정보가 없습니다.

)}
)} {detailTab === 'models' && item.models?.length > 0 && (
{item.models.map((m, i) => { const totalUnits = (m.general_units || 0) + (m.special_units || 0); return (
{m.house_ty || `주택형 ${i + 1}`} {totalUnits > 0 && ( {totalUnits}세대 )}
{m.supply_area && ( 공급면적 {m.supply_area}m² )} {m.top_amount != null && ( 분양가 {fmtPrice(m.top_amount)}원 )}
); })}
)}
); } // ── AnnouncementsTab ───────────────────────────────────────────────────────── function AnnouncementsTab() { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState('전체'); const [regionFilter, setRegionFilter] = useState(''); const [bookmarkFilter, setBookmarkFilter] = useState(false); const [selected, setSelected] = useState(null); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const size = 20; const load = async () => { setLoading(true); try { const params = new URLSearchParams({ page: String(page), size: String(size) }); if (statusFilter !== '전체') params.set('status', statusFilter); if (regionFilter.trim()) params.set('region', regionFilter.trim()); if (bookmarkFilter) params.set('bookmarked', 'true'); const data = await apiGet(`/api/realestate/announcements?${params}`); setItems(data.items || []); setTotal(data.total || 0); } catch (e) { console.error('Announcements load error:', e); setItems([]); } finally { setLoading(false); } }; useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter]); const handleSelect = async (item) => { setSelected(item.id); try { const d = await apiGet(`/api/realestate/announcements/${item.id}`); setDetail(d); } catch (e) { console.error('Detail load error:', e); setDetail(item); } }; const handleDeleteClosed = async () => { if (!confirm('종료된(완료) 청약 공고를 모두 삭제할까요?')) return; try { const res = await apiDelete('/api/realestate/announcements/closed'); alert(`${res.deleted || 0}건 삭제되었습니다.`); setPage(1); load(); } catch (e) { console.error('Delete closed error:', e); alert('삭제 실패'); } }; const handleBookmark = async (id) => { try { const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`); setItems(prev => prev.map(it => it.id === id ? { ...it, is_bookmarked: updated.is_bookmarked } : it )); if (detail?.id === id) setDetail(prev => ({ ...prev, is_bookmarked: updated.is_bookmarked })); } catch (e) { console.error('Bookmark error:', e); } }; const totalPages = Math.max(1, Math.ceil(total / size)); return (
{/* Filters */}
{STATUS_FILTERS.map((f) => ( ))}
{ setRegionFilter(e.target.value); setPage(1); }} style={{ width: 160, padding: '6px 12px', fontSize: 12 }} />
{loading ? (
불러오는 중...
) : items.length === 0 ? (
조건에 맞는 공고가 없습니다.
) : (
{/* Card Grid */}
{items.map((item) => ( handleSelect(item)} onBookmark={handleBookmark} /> ))}
{/* Pagination */} {totalPages > 1 && (
{page} / {totalPages}
)}
{/* Detail Panel */}
)}
); } // ── MatchesTab ──────────────────────────────────────────────────────────────── function MatchesTab() { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [myPoints, setMyPoints] = useState(null); const [page, setPage] = useState(1); const [refreshing, setRefreshing] = useState(false); const [loading, setLoading] = useState(true); const size = 20; const load = async () => { setLoading(true); try { const data = await apiGet(`/api/realestate/matches?page=${page}&size=${size}`); setItems(data.items || []); setTotal(data.total || 0); if (data.my_points) setMyPoints(data.my_points); } catch (e) { console.error('Matches load error:', e); setItems([]); } finally { setLoading(false); } }; useEffect(() => { load(); }, [page]); const handleRefresh = async () => { setRefreshing(true); try { await apiPost('/api/realestate/matches/refresh'); await load(); } catch (e) { console.error('Refresh error:', e); } finally { setRefreshing(false); } }; const handleMarkRead = async (id) => { try { await apiPatch(`/api/realestate/matches/${id}/read`); setItems(prev => prev.map(m => m.id === id ? { ...m, is_new: false } : m)); } catch (e) { console.error('Mark read error:', e); } }; const totalPages = Math.max(1, Math.ceil(total / size)); return (

총 {total}건의 매칭 결과

{myPoints && ( = 60 ? '#34d399' : myPoints.total >= 40 ? '#f59e0b' : '#f87171', background: myPoints.total >= 60 ? 'rgba(52,211,153,0.1)' : myPoints.total >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(248,113,113,0.1)', fontWeight: 700, fontSize: 12, }}> 내 가점 {myPoints.total}/{myPoints.max_total} )}
{loading ? (
불러오는 중...
) : items.length === 0 ? (
매칭 결과가 없습니다. 프로필을 설정하고 재계산을 실행해 보세요.
) : ( <>
{items.map((match) => (
match.is_new && handleMarkRead(match.id)} >

{match.house_nm || `공고 #${match.announcement_id}`}

{match.is_new && ( NEW )} {match.ann_status && }

{match.region_name || '-'} {match.receipt_start && ( {fmt(match.receipt_start)} ~ {fmt(match.receipt_end)} {(() => { const dd = getDDays(match.receipt_start); return dd ? {dd} : null; })()} )}

{match.eligible_types && (
{(Array.isArray(match.eligible_types) ? match.eligible_types : (() => { try { return JSON.parse(match.eligible_types); } catch { return []; } })() ).map((t, i) => ( {t} ))}
)} {match.match_reasons?.length > 0 && (
{(Array.isArray(match.match_reasons) ? match.match_reasons : (() => { try { return JSON.parse(match.match_reasons); } catch { return []; } })() ).join(' · ')}
)}
= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171', lineHeight: 1, }}> {match.match_score ?? '-'}
매칭 점수
{myPoints && (
= 50 ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)', color: myPoints.total >= 50 ? '#34d399' : '#f87171', fontWeight: 600, whiteSpace: 'nowrap', }}> 가점 {myPoints.total}점
)}
))}
{totalPages > 1 && (
{page} / {totalPages}
)} )}
); } // ── ProfileTab ──────────────────────────────────────────────────────────────── function ProfileTab() { const [profile, setProfile] = useState({ ...DEFAULT_PROFILE }); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(''); useEffect(() => { (async () => { setLoading(true); try { const data = await apiGet('/api/realestate/profile'); if (data && Object.keys(data).length > 0) { const display = { ...DEFAULT_PROFILE, ...data }; if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', '); if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', '); setProfile(display); } } catch (e) { console.error('Profile load error:', e); } finally { setLoading(false); } })(); }, []); const handleChange = (key, value) => { setProfile(prev => ({ ...prev, [key]: value })); }; const handleCheckbox = (key) => { setProfile(prev => ({ ...prev, [key]: !prev[key] })); }; const handleSave = async () => { setSaving(true); setMessage(''); try { const payload = { ...profile }; // Convert numeric strings to numbers ['age', 'subscription_months', 'subscription_amount', 'family_members', 'children_count', 'marriage_months', 'min_area', 'max_area', 'max_price' ].forEach(k => { if (payload[k] !== '' && payload[k] != null) { payload[k] = Number(payload[k]); } else { payload[k] = null; } }); // Convert comma-separated strings to arrays payload.preferred_regions = typeof payload.preferred_regions === 'string' ? payload.preferred_regions.split(',').map(s => s.trim()).filter(Boolean) : (payload.preferred_regions || []); payload.preferred_types = typeof payload.preferred_types === 'string' ? payload.preferred_types.split(',').map(s => s.trim()).filter(Boolean) : (payload.preferred_types || []); // Send empty arrays as null if (payload.preferred_regions.length === 0) payload.preferred_regions = null; if (payload.preferred_types.length === 0) payload.preferred_types = null; const updated = await apiPut('/api/realestate/profile', payload); if (updated && Object.keys(updated).length > 0) { // Convert arrays back to comma-separated strings for display const display = { ...DEFAULT_PROFILE, ...updated }; if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', '); if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', '); setProfile(display); } setMessage('저장 완료'); setTimeout(() => setMessage(''), 2000); } catch (e) { console.error('Profile save error:', e); setMessage('저장 실패: ' + e.message); } finally { setSaving(false); } }; if (loading) return
불러오는 중...
; const pts = profile.subscription_points; return (
{/* 가점 카드 */} {pts && pts.total > 0 && (

청약 가점

내 가점 현황

= 60 ? '#34d399' : pts.total >= 40 ? '#f59e0b' : '#f87171', lineHeight: 1, }}> {pts.total} / {pts.max_total}
{[ { label: '무주택기간', data: pts.homeless_duration, color: '#00d4ff' }, { label: '부양가족 수', data: pts.dependents, color: '#8b5cf6' }, { label: '청약통장 가입기간', data: pts.subscription_period, color: '#f59e0b' }, ].map(({ label, data, color }) => (
{label} {data.score} / {data.max}
{data.detail}
))}
)}

프로필

내 청약 프로필

자격 조건과 선호 조건을 설정하면 공고 매칭에 활용됩니다. * 필수 입력

{message && ( {message} )}
{/* 기본 정보 */}

기본 정보

{/* 자격 조건 */}

자격 조건

{/* 선호 조건 */}

선호 조건

); } // ── Subscription (Main) ────────────────────────────────────────────────────── function Subscription() { const [activeTab, setActiveTab] = useState(0); const [refreshKey, setRefreshKey] = useState(0); const handleRefresh = useCallback(async () => { setRefreshKey(k => k + 1); }, []); const handleFABClick = useCallback(() => { setActiveTab(1); // 공고 목록 탭으로 이동 }, []); return (
{/* Header */}

Real Estate

청약 관리

공공데이터 기반 청약 공고 자동 수집, 내 조건 매칭, 일정 관리를 한곳에서.

{/* Tabs */}
{TABS.map((tab, i) => ( ))}
{/* Body */}
{activeTab === 0 && } {activeTab === 1 && } {activeTab === 2 && } {activeTab === 3 && }
); } export default Subscription;