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}
즐겨찾기
{/* 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 */}
{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;