From 4f5d7796c12c41ce271fc1b9832cadc556f1ebd8 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 18:09:57 +0900 Subject: [PATCH] =?UTF-8?q?refactor(subscription):=204=EA=B0=9C=20?= =?UTF-8?q?=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=E2=86=92=20Subscription=EC=9D=84=20shell=EB=A1=9C?= =?UTF-8?q?=20=EC=B6=95=EC=86=8C?= 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/subscription/Subscription.jsx | 1055 +---------------- .../components/AnnouncementsTab.jsx | 221 ++++ .../subscription/components/DashboardTab.jsx | 206 ++++ .../subscription/components/MatchesTab.jsx | 228 ++++ .../subscription/components/ProfileTab.jsx | 399 +++++++ 5 files changed, 1062 insertions(+), 1047 deletions(-) create mode 100644 src/pages/subscription/components/AnnouncementsTab.jsx create mode 100644 src/pages/subscription/components/DashboardTab.jsx create mode 100644 src/pages/subscription/components/MatchesTab.jsx create mode 100644 src/pages/subscription/components/ProfileTab.jsx diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index 3e3af70..75f0756 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -1,1048 +1,15 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; +import React, { useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; import PullToRefresh from '../../components/PullToRefresh'; import FAB from '../../components/FAB'; -import DistrictTierEditor from './components/DistrictTierEditor'; -import NotificationSettings from './components/NotificationSettings'; -import StatusBadge from './components/StatusBadge'; -import AnnouncementCard from './components/AnnouncementCard'; -import AnnouncementDetail from './components/AnnouncementDetail'; -import CalendarView from './components/CalendarView'; +import { TABS } from './subscriptionUtils'; +import DashboardTab from './components/DashboardTab'; +import AnnouncementsTab from './components/AnnouncementsTab'; +import MatchesTab from './components/MatchesTab'; +import ProfileTab from './components/ProfileTab'; import './Subscription.css'; -import { - STATUS_CONFIG, TABS, STATUS_FILTERS, DEFAULT_PROFILE, - extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, -} from './subscriptionUtils'; -// ── 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} - - )} -
- ); - })} -
-
-
- )} -
- ); -} - -// ── 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 [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar' - const [calendarDay, setCalendarDay] = useState(null); // { label, items } - - const size = viewMode === 'calendar' ? 200 : 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, viewMode]); - - 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 ? ( -
조건에 맞는 공고가 없습니다.
- ) : viewMode === 'calendar' ? ( -
- setCalendarDay({ items: dayItems, label })} - /> - {calendarDay && ( -
-
-
-

{calendarDay.label}

-

공고 {calendarDay.items.length}건

-
- -
-
- {calendarDay.items.map(item => ( -
{ setViewMode('list'); handleSelect(item); }} - > -
- {item.house_nm} - - {item.status} - -
- {item.region_name} · 접수 {item.receipt_start} -
- ))} -
-
- )} -
- ) : ( -
- {/* 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.district && ( - {match.district} - )} - {(() => { - const tier = extractTier(match.match_reasons); - return tier ? ( - - {tier}티어 - - ) : null; - })()} -
-

- {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(' · ')} -
- )} - {match.score_breakdown && ( -
- {[ - { key: 'region', max: 35, color: '#00d4ff' }, - { key: 'type', max: 10, color: '#8b5cf6' }, - { key: 'area', max: 15, color: '#f59e0b' }, - { key: 'price', max: 15, color: '#f43f5e' }, - { key: 'eligibility', max: 25, color: '#34d399' }, - ].map(({ key, max, color }) => { - const v = match.score_breakdown[key] ?? 0; - return ( -
-
-
- ); - })} -
- )} -
-
-
-
= 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 [passCount, setPassCount] = useState(null); - const [saving, setSaving] = useState(false); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(''); - - useEffect(() => { - (async () => { - setLoading(true); - try { - const [data, dash] = await Promise.all([ - apiGet('/api/realestate/profile'), - apiGet('/api/realestate/dashboard').catch(() => null), - ]); - 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); - } - if (dash?.pass_count != null) setPassCount(dash.pass_count); - } 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; - - // 신규: preferred_districts (객체), min_match_score, notify_enabled - payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object" - ? profile.preferred_districts - : {}; - payload.min_match_score = profile.min_match_score ?? null; - payload.notify_enabled = profile.notify_enabled ?? 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} - - )} - -
-
- - {/* 프로필 완성도 힌트 */} - {(() => { - const missing = []; - if (!profile.income_level) missing.push('소득 수준'); - if (!profile.min_area || !profile.max_area) missing.push('희망 면적'); - if (!profile.max_price) missing.push('최대 예산'); - const hasDistricts = profile.preferred_districts && - Object.values(profile.preferred_districts).some(arr => arr?.length > 0); - if (!hasDistricts) missing.push('자치구 티어'); - if (missing.length === 0) return null; - return ( -
- 💡 - - 매칭 정확도 개선 가능 — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다. - -
- ); - })()} - -
- {/* 기본 정보 */} -
-

기본 정보

-
- - -
-
- - {/* 자격 조건 */} -
-

자격 조건

- -
- - - - - - -
- -
- - - -
- -
- - - -
-
- - {/* 선호 조건 */} -
-

선호 조건

- -
- - -
- -
- - - -
-
- - {/* 자치구 5티어 */} - setProfile(prev => ({ ...prev, preferred_districts: next }))} - /> - - {/* 알림 설정 */} - setProfile(prev => ({ ...prev, ...patch }))} - passCount={passCount} - /> -
-
-
- ); -} - -// ── Subscription (Main) ────────────────────────────────────────────────────── +// ── Subscription (Main) ── function Subscription() { const [activeTab, setActiveTab] = useState(0); const [refreshKey, setRefreshKey] = useState(0); @@ -1052,7 +19,7 @@ function Subscription() { }, []); const handleFABClick = useCallback(() => { - setActiveTab(1); // 공고 목록 탭으로 이동 + setActiveTab(1); }, []); return ( @@ -1063,7 +30,6 @@ function Subscription() { 실매물 · 안전마진 →
- {/* Header */}

Real Estate

@@ -1073,8 +39,6 @@ function Subscription() {

- - {/* Tabs */}
{TABS.map((tab, i) => ( @@ -1088,15 +52,12 @@ function Subscription() { ))}
- - {/* Body */}
{activeTab === 0 && } {activeTab === 1 && } {activeTab === 2 && } {activeTab === 3 && }
-
diff --git a/src/pages/subscription/components/AnnouncementsTab.jsx b/src/pages/subscription/components/AnnouncementsTab.jsx new file mode 100644 index 0000000..46ae9fd --- /dev/null +++ b/src/pages/subscription/components/AnnouncementsTab.jsx @@ -0,0 +1,221 @@ +import React, { useState, useEffect } from 'react'; +import { apiGet, apiDelete } from '../../../api'; +import { STATUS_CONFIG, STATUS_FILTERS, apiPatch } from '../subscriptionUtils'; +import AnnouncementCard from './AnnouncementCard'; +import AnnouncementDetail from './AnnouncementDetail'; +import CalendarView from './CalendarView'; + +// ── 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 [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar' + const [calendarDay, setCalendarDay] = useState(null); // { label, items } + + const size = viewMode === 'calendar' ? 200 : 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, viewMode]); + + 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 ? ( +
조건에 맞는 공고가 없습니다.
+ ) : viewMode === 'calendar' ? ( +
+ setCalendarDay({ items: dayItems, label })} + /> + {calendarDay && ( +
+
+
+

{calendarDay.label}

+

공고 {calendarDay.items.length}건

+
+ +
+
+ {calendarDay.items.map(item => ( +
{ setViewMode('list'); handleSelect(item); }} + > +
+ {item.house_nm} + + {item.status} + +
+ {item.region_name} · 접수 {item.receipt_start} +
+ ))} +
+
+ )} +
+ ) : ( +
+ {/* Card Grid */} +
+
+ {items.map((item) => ( + handleSelect(item)} + onBookmark={handleBookmark} + /> + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+ + {/* Detail Panel */} +
+ +
+
+ )} +
+ ); +} + +export default AnnouncementsTab; diff --git a/src/pages/subscription/components/DashboardTab.jsx b/src/pages/subscription/components/DashboardTab.jsx new file mode 100644 index 0000000..2c78b12 --- /dev/null +++ b/src/pages/subscription/components/DashboardTab.jsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect } from 'react'; +import { apiGet, apiPost } from '../../../api'; +import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; + +// ── 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} + + )} +
+ ); + })} +
+
+
+ )} +
+ ); +} + +export default DashboardTab; diff --git a/src/pages/subscription/components/MatchesTab.jsx b/src/pages/subscription/components/MatchesTab.jsx new file mode 100644 index 0000000..afdde18 --- /dev/null +++ b/src/pages/subscription/components/MatchesTab.jsx @@ -0,0 +1,228 @@ +import React, { useState, useEffect } from 'react'; +import { apiGet, apiPost } from '../../../api'; +import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; + +// ── 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.district && ( + {match.district} + )} + {(() => { + const tier = extractTier(match.match_reasons); + return tier ? ( + + {tier}티어 + + ) : null; + })()} +
+

+ {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(' · ')} +
+ )} + {match.score_breakdown && ( +
+ {[ + { key: 'region', max: 35, color: '#00d4ff' }, + { key: 'type', max: 10, color: '#8b5cf6' }, + { key: 'area', max: 15, color: '#f59e0b' }, + { key: 'price', max: 15, color: '#f43f5e' }, + { key: 'eligibility', max: 25, color: '#34d399' }, + ].map(({ key, max, color }) => { + const v = match.score_breakdown[key] ?? 0; + return ( +
+
+
+ ); + })} +
+ )} +
+
+
+
= 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} + + +
+ )} + + )} +
+ ); +} + +export default MatchesTab; diff --git a/src/pages/subscription/components/ProfileTab.jsx b/src/pages/subscription/components/ProfileTab.jsx new file mode 100644 index 0000000..201501f --- /dev/null +++ b/src/pages/subscription/components/ProfileTab.jsx @@ -0,0 +1,399 @@ +import React, { useState, useEffect } from 'react'; +import { apiGet, apiPut } from '../../../api'; +import { DEFAULT_PROFILE } from '../subscriptionUtils'; +import DistrictTierEditor from './DistrictTierEditor'; +import NotificationSettings from './NotificationSettings'; + +// ── ProfileTab ──────────────────────────────────────────────────────────────── +function ProfileTab() { + const [profile, setProfile] = useState({ ...DEFAULT_PROFILE }); + const [passCount, setPassCount] = useState(null); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(''); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const [data, dash] = await Promise.all([ + apiGet('/api/realestate/profile'), + apiGet('/api/realestate/dashboard').catch(() => null), + ]); + 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); + } + if (dash?.pass_count != null) setPassCount(dash.pass_count); + } 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; + + // 신규: preferred_districts (객체), min_match_score, notify_enabled + payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object" + ? profile.preferred_districts + : {}; + payload.min_match_score = profile.min_match_score ?? null; + payload.notify_enabled = profile.notify_enabled ?? 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} + + )} + +
+
+ + {/* 프로필 완성도 힌트 */} + {(() => { + const missing = []; + if (!profile.income_level) missing.push('소득 수준'); + if (!profile.min_area || !profile.max_area) missing.push('희망 면적'); + if (!profile.max_price) missing.push('최대 예산'); + const hasDistricts = profile.preferred_districts && + Object.values(profile.preferred_districts).some(arr => arr?.length > 0); + if (!hasDistricts) missing.push('자치구 티어'); + if (missing.length === 0) return null; + return ( +
+ 💡 + + 매칭 정확도 개선 가능 — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다. + +
+ ); + })()} + +
+ {/* 기본 정보 */} +
+

기본 정보

+
+ + +
+
+ + {/* 자격 조건 */} +
+

자격 조건

+ +
+ + + + + + +
+ +
+ + + +
+ +
+ + + +
+
+ + {/* 선호 조건 */} +
+

선호 조건

+ +
+ + +
+ +
+ + + +
+
+ + {/* 자치구 5티어 */} + setProfile(prev => ({ ...prev, preferred_districts: next }))} + /> + + {/* 알림 설정 */} + setProfile(prev => ({ ...prev, ...patch }))} + passCount={passCount} + /> +
+
+
+ ); +} + +export default ProfileTab;