feat(realestate): 즐겨찾기 + D-day 오차 수정 + 가격 표시 + 필드명 수정

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:39:02 +09:00
parent 8af2824c12
commit bf5c7ba54e

View File

@@ -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() {
</p>
<p className="sub-stat-item__label">신규 매칭</p>
</div>
<div className="sub-stat-item">
<p className="sub-stat-item__value" style={{ color: dashboard?.bookmarked_count > 0 ? '#f59e0b' : undefined }}>
{dashboard?.bookmarked_count ?? 0}
</p>
<p className="sub-stat-item__label">즐겨찾기</p>
</div>
<div className="sub-stat-item">
<p className="sub-stat-item__value">{totalCount}</p>
<p className="sub-stat-item__label">전체 공고</p>
@@ -229,13 +256,68 @@ function DashboardTab() {
)}
</div>
</div>
{/* Bookmarked */}
{dashboard?.bookmarked?.length > 0 && (
<div className="sub-panel">
<div className="sub-panel__head">
<div>
<p className="sub-panel__eyebrow">즐겨찾기</p>
<h3>관심 공고</h3>
</div>
</div>
<div className="sub-panel__body">
<div style={{ display: 'grid', gap: 8 }}>
{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 (
<div key={item.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--line)', background: 'var(--surface)',
}}>
<div style={{ display: 'grid', gap: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#f59e0b', fontSize: 14 }}></span>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>
{item.house_nm}
</span>
<StatusBadge status={item.status} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{item.region_name || '-'}
{priceText && <> · <span style={{ color: '#f59e0b' }}>{priceText}</span></>}
</span>
</div>
{dday && (
<span style={{ fontSize: 13, fontWeight: 700, color: getDDayColor(item.receipt_start) }}>
{dday}
</span>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}
// ── 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 (
<div
className={`sub-card${isSelected ? ' is-selected' : ''}`}
@@ -250,6 +332,17 @@ function AnnouncementCard({ item, isSelected, onClick }) {
</span>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
fontSize: 16, color: item.is_bookmarked ? '#f59e0b' : 'var(--text-dim)',
lineHeight: 1,
}}
title={item.is_bookmarked ? '즐겨찾기 해제' : '즐겨찾기'}
>
{item.is_bookmarked ? '★' : '☆'}
</button>
</div>
<h4 className="sub-card__name">{item.house_nm || '(이름 없음)'}</h4>
<p className="sub-card__address">{item.address || item.region_name || '-'}</p>
@@ -257,6 +350,12 @@ function AnnouncementCard({ item, isSelected, onClick }) {
<span>{item.total_units ? `${item.total_units}세대` : '-'}</span>
<span className="sub-card__dot">·</span>
<span>{item.region_name || '-'}</span>
{priceText && (
<>
<span className="sub-card__dot">·</span>
<span style={{ color: '#f59e0b' }}>{priceText}</span>
</>
)}
</div>
<div className="sub-card__bottom">
{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 }) {
<p className="sub-detail__address">{item.address || item.region_name}</p>
</div>
<div className="sub-detail__actions">
<button
onClick={() => onBookmark?.(item.id)}
className="sub-filter-btn"
style={{
color: item.is_bookmarked ? '#f59e0b' : undefined,
borderColor: item.is_bookmarked ? '#f59e0b' : undefined,
}}
>
{item.is_bookmarked ? '★ 즐겨찾기 해제' : '☆ 즐겨찾기'}
</button>
{item.homepage_url && (
<a href={item.homepage_url} target="_blank" rel="noreferrer"
className="sub-filter-btn" style={{ textDecoration: 'none' }}>
@@ -416,7 +525,9 @@ function AnnouncementDetail({ item }) {
{detailTab === 'models' && item.models?.length > 0 && (
<div style={{ display: 'grid', gap: 8 }}>
{item.models.map((m, i) => (
{item.models.map((m, i) => {
const totalUnits = (m.general_units || 0) + (m.special_units || 0);
return (
<div key={i} style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-sm)',
@@ -428,24 +539,25 @@ function AnnouncementDetail({ item }) {
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text-bright)' }}>
{m.model_nm || m.house_ty || `주택형 ${i + 1}`}
{m.house_ty || `주택형 ${i + 1}`}
</span>
{m.supply_count && (
{totalUnits > 0 && (
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
{m.supply_count}세대
{totalUnits}세대
</span>
)}
</div>
{m.exclusive_area && (
<span style={{ color: 'var(--text-dim)' }}>전용 {m.exclusive_area}</span>
{m.supply_area && (
<span style={{ color: 'var(--text-dim)' }}>공급면적 {m.supply_area}</span>
)}
{m.supply_price && (
{m.top_amount != null && (
<span style={{ color: '#f59e0b', fontWeight: 600 }}>
분양가 {Number(m.supply_price).toLocaleString()}
분양가 {fmtPrice(m.top_amount)}
</span>
)}
</div>
))}
);
})}
</div>
)}
</div>
@@ -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,6 +639,14 @@ function AnnouncementsTab() {
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
className={`sub-filter-btn${bookmarkFilter ? ' is-active' : ''}`}
onClick={() => { setBookmarkFilter(v => !v); setPage(1); }}
style={{ fontSize: 12 }}
>
즐겨찾기
</button>
<input
className="sub-form-input"
placeholder="지역 검색..."
@@ -521,6 +655,7 @@ function AnnouncementsTab() {
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
/>
</div>
</div>
{loading ? (
<div className="sub-empty">불러오는 ...</div>
@@ -537,6 +672,7 @@ function AnnouncementsTab() {
item={item}
isSelected={selected === item.id}
onClick={() => handleSelect(item)}
onBookmark={handleBookmark}
/>
))}
</div>
@@ -567,7 +703,7 @@ function AnnouncementsTab() {
{/* Detail Panel */}
<div className="sub-detail-panel">
<AnnouncementDetail item={detail} />
<AnnouncementDetail item={detail} onBookmark={handleBookmark} />
</div>
</div>
)}
@@ -671,7 +807,7 @@ function MatchesTab() {
NEW
</span>
)}
{match.status && <StatusBadge status={match.status} />}
{match.ann_status && <StatusBadge status={match.ann_status} />}
</div>
<p className="sub-card__address" style={{ margin: 0 }}>
{match.region_name || '-'}
@@ -689,7 +825,7 @@ function MatchesTab() {
</div>
)}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
매칭일: {fmtDateTime(match.matched_at)}
매칭일: {fmtDateTime(match.created_at)}
</span>
</div>
<div style={{ textAlign: 'center', flexShrink: 0 }}>
@@ -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 ?? '-'}
</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
매칭 점수