From bf5c7ba54e853d026d396d26bec15c5600b21192 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 7 Apr 2026 23:39:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(realestate):=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20+=20D-day=20=EC=98=A4=EC=B0=A8=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20+=20=EA=B0=80=EA=B2=A9=20=ED=91=9C=EC=8B=9C=20+=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D-day 계산 로컬 타임존 통일 (UTC 파싱 → 로컬 Date 파싱, 1일 오차 해결) - 즐겨찾기 토글 (카드 ☆/★ + 상세 패널 버튼 + 즐겨찾기 필터) - 대시보드에 즐겨찾기 섹션 + 가격 표시 - 모델 필드명 수정: supply_price→top_amount, exclusive_area→supply_area - 카드에 가격 범위 표시 (억/만원 자동 포맷) - 매칭 결과 필드명 수정: score→match_score, status→ann_status, matched_at→created_at Co-Authored-By: Claude Opus 4.6 --- src/pages/subscription/Subscription.jsx | 234 +++++++++++++++++++----- 1 file changed, 185 insertions(+), 49 deletions(-) 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 ?? '-'}
매칭 점수