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:
@@ -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}m²</span>
|
||||
{m.supply_area && (
|
||||
<span style={{ color: 'var(--text-dim)' }}>공급면적 {m.supply_area}m²</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 }}>
|
||||
매칭 점수
|
||||
|
||||
Reference in New Issue
Block a user