diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index 24f708a..3e3af70 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import { Link } from 'react-router-dom'; import PullToRefresh from '../../components/PullToRefresh'; @@ -6,9 +6,12 @@ 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 './Subscription.css'; import { - STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE, + STATUS_CONFIG, TABS, STATUS_FILTERS, DEFAULT_PROFILE, extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, } from './subscriptionUtils'; @@ -212,434 +215,6 @@ function DashboardTab() { ); } -// ── 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.district && ( - {item.district} - )} - {(() => { - const tier = extractTier(item.match_reasons); - return tier ? ( - - {tier}티어 - - ) : null; - })()} -
- -
-

{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)}원 - - )} -
- ); - })} -
- )} -
- - {item.match_score !== undefined && item.match_score !== null && ( -
-
-
-

매칭 분석

- - ⭐ {item.match_score} / 100 - -
-
- - {item.score_breakdown && ( -
-

📊 점수 분석

-
- {[ - { key: 'region', label: '지역', max: 35, color: '#00d4ff' }, - { key: 'type', label: '유형', max: 10, color: '#8b5cf6' }, - { key: 'area', label: '면적', max: 15, color: '#f59e0b' }, - { key: 'price', label: '가격', max: 15, color: '#f43f5e' }, - { key: 'eligibility', label: '자격', max: 25, color: '#34d399' }, - ].map(({ key, label, max, color }) => { - const v = item.score_breakdown[key] ?? 0; - return ( -
-
- {label} - - {v} - / {max} - -
-
-
-
-
- ); - })} -
-
- )} - - {item.match_reasons && item.match_reasons.length > 0 && ( -
-

💡 매칭 사유

-
    - {item.match_reasons.map((r, idx) => ( -
  • {r}
  • - ))} -
-
- )} - - {item.eligible_types && item.eligible_types.length > 0 && ( -
-

✓ 신청 자격

-
- {item.eligible_types.map(t => ( - {t} - ))} -
-
- )} -
- )} -
- ); -} - -// ── CalendarView ───────────────────────────────────────────────────────────── -function CalendarView({ items, onDaySelect }) { - const [cur, setCur] = useState(() => { - const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); - }); - const year = cur.getFullYear(), month = cur.getMonth(); - - const dateMap = useMemo(() => { - const map = {}; - for (const item of items) { - const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start; - if (!raw || raw.length < 8) continue; - const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`; - (map[key] = map[key] || []).push(item); - } - return map; - }, [items]); - - const firstDow = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const cells = []; - for (let i = 0; i < firstDow; i++) cells.push(null); - for (let d = 1; d <= daysInMonth; d++) cells.push(d); - while (cells.length % 7 !== 0) cells.push(null); - - const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`; - - return ( -
-
- - {year}년 {month+1}월 - -
-
- {['일','월','화','수','목','금','토'].map(w => ( -
{w}
- ))} -
-
- {cells.map((d, i) => { - const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null; - const dayItems = key ? (dateMap[key] || []) : []; - const isToday = key === todayKey; - return ( -
0 ? ' has-items' : ''}`} - onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)} - > - {d && {d}} - {dayItems.length > 0 && ( -
- {dayItems.slice(0, 3).map((it, j) => ( - - ))} - {dayItems.length > 3 && +{dayItems.length - 3}} -
- )} -
- ); - })} -
-
- ); -} - // ── AnnouncementsTab ───────────────────────────────────────────────────────── function AnnouncementsTab() { const [items, setItems] = useState([]); diff --git a/src/pages/subscription/components/AnnouncementCard.jsx b/src/pages/subscription/components/AnnouncementCard.jsx new file mode 100644 index 0000000..bfed496 --- /dev/null +++ b/src/pages/subscription/components/AnnouncementCard.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; + +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.district && ( + {item.district} + )} + {(() => { + const tier = extractTier(item.match_reasons); + return tier ? ( + + {tier}티어 + + ) : null; + })()} +
+ +
+

{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} + + )} +
+
+ ); +} + +export default AnnouncementCard; diff --git a/src/pages/subscription/components/AnnouncementDetail.jsx b/src/pages/subscription/components/AnnouncementDetail.jsx new file mode 100644 index 0000000..7eedd6b --- /dev/null +++ b/src/pages/subscription/components/AnnouncementDetail.jsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react'; +import { HOUSE_TYPE_LABELS, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; + +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)}원 + + )} +
+ ); + })} +
+ )} +
+ + {item.match_score !== undefined && item.match_score !== null && ( +
+
+
+

매칭 분석

+ + ⭐ {item.match_score} / 100 + +
+
+ + {item.score_breakdown && ( +
+

📊 점수 분석

+
+ {[ + { key: 'region', label: '지역', max: 35, color: '#00d4ff' }, + { key: 'type', label: '유형', max: 10, color: '#8b5cf6' }, + { key: 'area', label: '면적', max: 15, color: '#f59e0b' }, + { key: 'price', label: '가격', max: 15, color: '#f43f5e' }, + { key: 'eligibility', label: '자격', max: 25, color: '#34d399' }, + ].map(({ key, label, max, color }) => { + const v = item.score_breakdown[key] ?? 0; + return ( +
+
+ {label} + + {v} + / {max} + +
+
+
+
+
+ ); + })} +
+
+ )} + + {item.match_reasons && item.match_reasons.length > 0 && ( +
+

💡 매칭 사유

+
    + {item.match_reasons.map((r, idx) => ( +
  • {r}
  • + ))} +
+
+ )} + + {item.eligible_types && item.eligible_types.length > 0 && ( +
+

✓ 신청 자격

+
+ {item.eligible_types.map(t => ( + {t} + ))} +
+
+ )} +
+ )} +
+ ); +} + +export default AnnouncementDetail; diff --git a/src/pages/subscription/components/CalendarView.jsx b/src/pages/subscription/components/CalendarView.jsx new file mode 100644 index 0000000..3384b23 --- /dev/null +++ b/src/pages/subscription/components/CalendarView.jsx @@ -0,0 +1,70 @@ +import React, { useState, useMemo } from 'react'; +import { STATUS_CONFIG } from '../subscriptionUtils'; + +function CalendarView({ items, onDaySelect }) { + const [cur, setCur] = useState(() => { + const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); + }); + const year = cur.getFullYear(), month = cur.getMonth(); + + const dateMap = useMemo(() => { + const map = {}; + for (const item of items) { + const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start; + if (!raw || raw.length < 8) continue; + const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`; + (map[key] = map[key] || []).push(item); + } + return map; + }, [items]); + + const firstDow = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const cells = []; + for (let i = 0; i < firstDow; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + while (cells.length % 7 !== 0) cells.push(null); + + const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`; + + return ( +
+
+ + {year}년 {month+1}월 + +
+
+ {['일','월','화','수','목','금','토'].map(w => ( +
{w}
+ ))} +
+
+ {cells.map((d, i) => { + const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null; + const dayItems = key ? (dateMap[key] || []) : []; + const isToday = key === todayKey; + return ( +
0 ? ' has-items' : ''}`} + onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)} + > + {d && {d}} + {dayItems.length > 0 && ( +
+ {dayItems.slice(0, 3).map((it, j) => ( + + ))} + {dayItems.length > 3 && +{dayItems.length - 3}} +
+ )} +
+ ); + })} +
+
+ ); +} + +export default CalendarView;