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;