diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx
index 141a133..f0c5c42 100644
--- a/src/pages/subscription/Subscription.jsx
+++ b/src/pages/subscription/Subscription.jsx
@@ -42,16 +42,26 @@ const fmtFull = (d) => {
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
};
-const getDDays = (d) => {
+const _diffDays = (d) => {
if (!d) return null;
- const diff = Math.ceil((new Date(d) - new Date().setHours(0, 0, 0, 0)) / 86400000);
+ // 로컬 타임존으로 통일하여 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) => {
- if (!d) return 'var(--text-dim)';
- const diff = Math.ceil((new Date(d) - new Date().setHours(0, 0, 0, 0)) / 86400000);
+ 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';
@@ -66,11 +76,16 @@ const fmtDateTime = (d) => {
});
};
-async function apiPatch(path) {
- const res = await fetch(path, {
+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}`);
@@ -78,6 +93,12 @@ async function apiPatch(path) {
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)' };
@@ -152,6 +173,12 @@ function DashboardTab() {
신규 매칭
+
+
0 ? '#f59e0b' : undefined }}>
+ {dashboard?.bookmarked_count ?? 0}
+
+
즐겨찾기
+
{totalCount}
전체 공고
@@ -229,13 +256,68 @@ function DashboardTab() {
)}
+
+ {/* 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 }) {
+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_nm || '(이름 없음)'}
{item.address || item.region_name || '-'}
@@ -257,6 +350,12 @@ function AnnouncementCard({ item, isSelected, onClick }) {
{item.total_units ? `${item.total_units}세대` : '-'}
·
{item.region_name || '-'}
+ {priceText && (
+ <>
+ ·
+ {priceText}
+ >
+ )}
{item.receipt_start && (
@@ -278,7 +377,7 @@ function AnnouncementCard({ item, isSelected, onClick }) {
}
// ── AnnouncementDetail ───────────────────────────────────────────────────────
-function AnnouncementDetail({ item }) {
+function AnnouncementDetail({ item, onBookmark }) {
const [detailTab, setDetailTab] = useState('info');
if (!item) {
@@ -307,6 +406,16 @@ function AnnouncementDetail({ item }) {
{item.address || item.region_name}
+
{item.homepage_url && (
@@ -416,36 +525,39 @@ function AnnouncementDetail({ item }) {
{detailTab === 'models' && item.models?.length > 0 && (
- {item.models.map((m, i) => (
-
-
-
- {m.model_nm || m.house_ty || `주택형 ${i + 1}`}
-
- {m.supply_count && (
-
- {m.supply_count}세대
+ {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)}원
)}
- {m.exclusive_area && (
- 전용 {m.exclusive_area}m²
- )}
- {m.supply_price && (
-
- 분양가 {Number(m.supply_price).toLocaleString()}만원
-
- )}
-
- ))}
+ );
+ })}
)}
@@ -460,6 +572,7 @@ function AnnouncementsTab() {
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);
@@ -472,6 +585,7 @@ function AnnouncementsTab() {
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);
@@ -483,7 +597,7 @@ function AnnouncementsTab() {
}
};
- useEffect(() => { load(); }, [page, statusFilter, regionFilter]);
+ useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter]);
const handleSelect = async (item) => {
setSelected(item.id);
@@ -496,6 +610,18 @@ function AnnouncementsTab() {
}
};
+ 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 (
@@ -513,13 +639,22 @@ function AnnouncementsTab() {
))}
- { setRegionFilter(e.target.value); setPage(1); }}
- style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
- />
+
+
+ { setRegionFilter(e.target.value); setPage(1); }}
+ style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
+ />
+
{loading ? (
@@ -537,6 +672,7 @@ function AnnouncementsTab() {
item={item}
isSelected={selected === item.id}
onClick={() => handleSelect(item)}
+ onBookmark={handleBookmark}
/>
))}
@@ -567,7 +703,7 @@ function AnnouncementsTab() {
{/* Detail Panel */}
)}
@@ -671,7 +807,7 @@ function MatchesTab() {
NEW
)}
- {match.status && }
+ {match.ann_status && }
{match.region_name || '-'}
@@ -689,7 +825,7 @@ function MatchesTab() {
)}
- 매칭일: {fmtDateTime(match.matched_at)}
+ 매칭일: {fmtDateTime(match.created_at)}
@@ -697,10 +833,10 @@ function MatchesTab() {
fontFamily: 'var(--font-display)',
fontSize: 28,
fontWeight: 700,
- color: match.score >= 70 ? '#34d399' : match.score >= 40 ? '#f59e0b' : '#f87171',
+ color: (match.match_score ?? 0) >= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171',
lineHeight: 1,
}}>
- {match.score ?? '-'}
+ {match.match_score ?? '-'}
매칭 점수