Files
web-page/src/pages/subscription/Subscription.jsx
gahusb 0198fec43c refactor(responsive): Phase 3 코드 품질 개선
- Blog/BlogMarketing/Subscription/MusicStudio: 미사용 useIsMobile 제거
- Subscription: 미사용 Link import 제거
- Blog.css: 중복 display:block 제거
- BlogMarketing: dead prop onGenerate 제거
- Todo: 카드 버튼 터치 타겟 26→36px 확대

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:06:56 +09:00

1355 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Subscription.css';
// ── 상수 ───────────────────────────────────────────────────────────────────────
const STATUS_CONFIG = {
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
};
const HOUSE_TYPE_LABELS = {
'01': '국민주택',
'02': '민영주택',
'03': '도시형생활주택',
};
const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
const DEFAULT_PROFILE = {
name: '', age: '', is_homeless: false, is_householder: false,
subscription_months: '', subscription_amount: '',
family_members: '', has_dependents: false, children_count: '',
is_newlywed: false, marriage_months: '',
has_newborn: false, is_first_home: false, income_level: '',
preferred_regions: '', preferred_types: '',
min_area: '', max_area: '', max_price: '',
};
// ── 유틸 ──────────────────────────────────────────────────────────────────────
const fmt = (d) => {
if (!d) return '-';
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
};
const fmtFull = (d) => {
if (!d) return '-';
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
};
const _diffDays = (d) => {
if (!d) return null;
// 로컬 타임존으로 통일하여 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) => {
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';
return 'var(--text-dim)';
};
const fmtDateTime = (d) => {
if (!d) return '-';
return new Date(d).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
};
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}`);
}
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)' };
const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
return (
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
{status || '알 수 없음'}
</span>
);
}
// ── DashboardTab ─────────────────────────────────────────────────────────────
function DashboardTab() {
const [dashboard, setDashboard] = useState(null);
const [collectStatus, setCollectStatus] = useState(null);
const [totalCount, setTotalCount] = useState(0);
const [collecting, setCollecting] = useState(false);
const [loading, setLoading] = useState(true);
const load = async () => {
setLoading(true);
try {
const [dash, status, ann] = await Promise.all([
apiGet('/api/realestate/dashboard'),
apiGet('/api/realestate/collect/status').catch(() => null),
apiGet('/api/realestate/announcements?page=1&size=1'),
]);
setDashboard(dash);
setCollectStatus(status);
setTotalCount(ann?.total || 0);
} catch (e) {
console.error('Dashboard load error:', e);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleCollect = async () => {
setCollecting(true);
try {
await apiPost('/api/realestate/collect');
// Wait a moment then refresh status
setTimeout(async () => {
try {
const status = await apiGet('/api/realestate/collect/status');
setCollectStatus(status);
} catch (_) {}
setCollecting(false);
load();
}, 3000);
} catch (e) {
console.error('Collect error:', e);
setCollecting(false);
}
};
if (loading) return <div className="sub-empty">불러오는 ...</div>;
return (
<div style={{ display: 'grid', gap: 20 }}>
{/* Stats Cards */}
<div className="sub-stats-bar">
<div className="sub-stat-item">
<p className="sub-stat-item__value">{dashboard?.active_count ?? 0}</p>
<p className="sub-stat-item__label">진행중 공고</p>
</div>
<div className="sub-stat-item">
<p className="sub-stat-item__value" style={{ color: dashboard?.new_match_count > 0 ? '#f43f5e' : undefined }}>
{dashboard?.new_match_count ?? 0}
</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>
</div>
</div>
{/* Collection Status */}
<div className="sub-panel">
<div className="sub-panel__head">
<div>
<p className="sub-panel__eyebrow">데이터 수집</p>
<h3>공공데이터 수집 현황</h3>
{collectStatus && (
<p className="sub-panel__sub">
마지막 수집: {fmtDateTime(collectStatus.collected_at)}
{collectStatus.new_count != null && ` · 신규 ${collectStatus.new_count}`}
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}`}
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
</p>
)}
{!collectStatus && <p className="sub-panel__sub">수집 이력이 없습니다.</p>}
</div>
<button
className="sub-filter-btn is-active"
onClick={handleCollect}
disabled={collecting}
style={{ padding: '8px 20px', fontSize: 13 }}
>
{collecting ? '수집 중...' : '수집 실행'}
</button>
</div>
</div>
{/* Upcoming Schedules */}
<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">
{dashboard?.upcoming_schedules?.length > 0 ? (
<div className="sub-schedule-mini">
{dashboard.upcoming_schedules.map((s, i) => {
const dday = getDDays(s.date);
return (
<div className="sub-schedule-mini__item" key={i}>
<div
className="sub-schedule-mini__dot"
style={{ background: getDDayColor(s.date) }}
/>
<div className="sub-schedule-mini__content">
<span className="sub-schedule-mini__label">
{s.house_nm || s.label || '공고'}
</span>
<span className="sub-schedule-mini__date">
{fmtFull(s.date)} · {s.event || s.type || '일정'}
</span>
</div>
{dday && (
<span
className="sub-schedule-mini__dday"
style={{ color: getDDayColor(s.date) }}
>
{dday}
</span>
)}
</div>
);
})}
</div>
) : (
<p className="sub-empty-sm">다가오는 일정이 없습니다.</p>
)}
</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, 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' : ''}`}
onClick={onClick}
>
<div className="sub-card__top">
<div className="sub-card__badges">
<StatusBadge status={item.status} />
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
<span className="sub-type-badge" style={{ color: '#60a5fa' }}>
{HOUSE_TYPE_LABELS[item.house_secd]}
</span>
)}
{item.match_score > 0 && (
<span className="sub-badge" style={{
color: item.match_score >= 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}
</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>
<div className="sub-card__info">
<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 && (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{fmt(item.receipt_start)} ~ {fmt(item.receipt_end)}
</span>
)}
{dday && (
<span
className="sub-card__dday"
style={{ color: getDDayColor(item.receipt_start) }}
>
{dday}
</span>
)}
</div>
</div>
);
}
// ── AnnouncementDetail ───────────────────────────────────────────────────────
function AnnouncementDetail({ item, onBookmark }) {
const [detailTab, setDetailTab] = useState('info');
if (!item) {
return (
<div className="sub-detail sub-detail--empty">
<span className="sub-detail__empty-icon">🏠</span>
<span>공고를 선택하면 상세 정보가 표시됩니다.</span>
</div>
);
}
return (
<div className="sub-detail">
{/* Header */}
<div className="sub-detail__header">
<div>
<div className="sub-detail__badges">
<StatusBadge status={item.status} size="lg" />
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
<span className="sub-type-badge sub-type-badge--lg" style={{ color: '#60a5fa' }}>
{HOUSE_TYPE_LABELS[item.house_secd]}
</span>
)}
</div>
<h3 className="sub-detail__name">{item.house_nm}</h3>
<p className="sub-detail__address">{item.address || item.region_name}</p>
{item.match_score > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
<span style={{
fontSize: 20, fontWeight: 700, fontFamily: 'var(--font-display)',
color: item.match_score >= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
}}>
매칭 {item.match_score}
</span>
{item.eligible_types?.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(Array.isArray(item.eligible_types) ? item.eligible_types : []).map((t, i) => (
<span key={i} className="sub-tag is-neutral" style={{ fontSize: 10 }}>{t}</span>
))}
</div>
)}
</div>
)}
</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' }}>
홈페이지
</a>
)}
{item.pblanc_url && (
<a href={item.pblanc_url} target="_blank" rel="noreferrer"
className="sub-filter-btn" style={{ textDecoration: 'none' }}>
공고문
</a>
)}
</div>
</div>
{/* Section Tabs */}
<div className="sub-section-tabs">
<button
className={`sub-section-tab${detailTab === 'info' ? ' is-active' : ''}`}
onClick={() => setDetailTab('info')}
>
기본 정보
</button>
<button
className={`sub-section-tab${detailTab === 'schedule' ? ' is-active' : ''}`}
onClick={() => setDetailTab('schedule')}
>
일정
</button>
{item.models?.length > 0 && (
<button
className={`sub-section-tab${detailTab === 'models' ? ' is-active' : ''}`}
onClick={() => setDetailTab('models')}
>
주택형 ({item.models.length})
</button>
)}
</div>
{/* Section Content */}
<div className="sub-detail__section-content">
{detailTab === 'info' && (
<div className="sub-compare">
<div className="sub-compare__row">
<span className="sub-compare__label">단지명</span>
<span className="sub-compare__mine" style={{ gridColumn: 'span 2' }}>{item.house_nm}</span>
</div>
<div className="sub-compare__row">
<span className="sub-compare__label">지역</span>
<span style={{ gridColumn: 'span 2' }}>{item.region_name || '-'}</span>
</div>
<div className="sub-compare__row">
<span className="sub-compare__label">주소</span>
<span style={{ gridColumn: 'span 2' }}>{item.address || '-'}</span>
</div>
<div className="sub-compare__row">
<span className="sub-compare__label"> 세대수</span>
<span style={{ gridColumn: 'span 2' }}>{item.total_units ? `${item.total_units}세대` : '-'}</span>
</div>
<div className="sub-compare__row">
<span className="sub-compare__label">시공사</span>
<span style={{ gridColumn: 'span 2' }}>{item.constructor || '-'}</span>
</div>
<div className="sub-compare__row">
<span className="sub-compare__label">시행사</span>
<span style={{ gridColumn: 'span 2' }}>{item.developer || '-'}</span>
</div>
<div className="sub-compare__row">
<span className="sub-compare__label">입주 예정</span>
<span style={{ gridColumn: 'span 2' }}>{item.move_in_month || '-'}</span>
</div>
</div>
)}
{detailTab === 'schedule' && (
<div className="sub-schedule-mini">
{[
{ 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 (
<div className="sub-schedule-mini__item" key={i}>
<div className="sub-schedule-mini__dot" style={{ background: getDDayColor(s.start) }} />
<div className="sub-schedule-mini__content">
<span className="sub-schedule-mini__label">{s.label}</span>
<span className="sub-schedule-mini__date">
{fmtFull(s.start)}{s.end ? ` ~ ${fmtFull(s.end)}` : ''}
</span>
</div>
{dday && (
<span className="sub-schedule-mini__dday" style={{ color: getDDayColor(s.start) }}>
{dday}
</span>
)}
</div>
);
})}
{![item.spsply_start, item.gnrl_rank1_start, item.receipt_start, item.winner_date, item.contract_start].some(Boolean) && (
<p className="sub-empty-sm">일정 정보가 없습니다.</p>
)}
</div>
)}
{detailTab === 'models' && item.models?.length > 0 && (
<div style={{ display: 'grid', gap: 8 }}>
{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)',
padding: '12px 14px',
background: 'var(--surface)',
display: 'grid',
gap: 4,
fontSize: 12,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text-bright)' }}>
{m.house_ty || `주택형 ${i + 1}`}
</span>
{totalUnits > 0 && (
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
{totalUnits}세대
</span>
)}
</div>
{m.supply_area && (
<span style={{ color: 'var(--text-dim)' }}>공급면적 {m.supply_area}</span>
)}
{m.top_amount != null && (
<span style={{ color: '#f59e0b', fontWeight: 600 }}>
분양가 {fmtPrice(m.top_amount)}
</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
// ── AnnouncementsTab ─────────────────────────────────────────────────────────
function AnnouncementsTab() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
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);
const size = 20;
const load = async () => {
setLoading(true);
try {
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);
} catch (e) {
console.error('Announcements load error:', e);
setItems([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter]);
const handleSelect = async (item) => {
setSelected(item.id);
try {
const d = await apiGet(`/api/realestate/announcements/${item.id}`);
setDetail(d);
} catch (e) {
console.error('Detail load error:', e);
setDetail(item);
}
};
const handleDeleteClosed = async () => {
if (!confirm('종료된(완료) 청약 공고를 모두 삭제할까요?')) return;
try {
const res = await apiDelete('/api/realestate/announcements/closed');
alert(`${res.deleted || 0}건 삭제되었습니다.`);
setPage(1);
load();
} catch (e) {
console.error('Delete closed error:', e);
alert('삭제 실패');
}
};
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 (
<div style={{ display: 'grid', gap: 16 }}>
{/* Filters */}
<div className="sub-tabs-bar">
<div className="sub-filter">
{STATUS_FILTERS.map((f) => (
<button
key={f}
className={`sub-filter-btn${statusFilter === f ? ' is-active' : ''}`}
onClick={() => { setStatusFilter(f); setPage(1); }}
>
{f}
</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="지역 검색..."
value={regionFilter}
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
/>
<button
className="sub-filter-btn"
onClick={handleDeleteClosed}
style={{ fontSize: 12, color: '#f87171' }}
title="status='완료' 공고 일괄 삭제"
>
🗑 종료 청약 삭제
</button>
</div>
</div>
{loading ? (
<div className="sub-empty">불러오는 ...</div>
) : items.length === 0 ? (
<div className="sub-empty">조건에 맞는 공고가 없습니다.</div>
) : (
<div className="sub-list-layout">
{/* Card Grid */}
<div>
<div className="sub-card-grid">
{items.map((item) => (
<AnnouncementCard
key={item.id}
item={item}
isSelected={selected === item.id}
onClick={() => handleSelect(item)}
onBookmark={handleBookmark}
/>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
<button
className="sub-filter-btn"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
이전
</button>
<span style={{ fontSize: 13, color: 'var(--text-dim)', padding: '4px 8px' }}>
{page} / {totalPages}
</span>
<button
className="sub-filter-btn"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
다음
</button>
</div>
)}
</div>
{/* Detail Panel */}
<div className="sub-detail-panel">
<AnnouncementDetail item={detail} onBookmark={handleBookmark} />
</div>
</div>
)}
</div>
);
}
// ── MatchesTab ────────────────────────────────────────────────────────────────
function MatchesTab() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [myPoints, setMyPoints] = useState(null);
const [page, setPage] = useState(1);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const size = 20;
const load = async () => {
setLoading(true);
try {
const data = await apiGet(`/api/realestate/matches?page=${page}&size=${size}`);
setItems(data.items || []);
setTotal(data.total || 0);
if (data.my_points) setMyPoints(data.my_points);
} catch (e) {
console.error('Matches load error:', e);
setItems([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await apiPost('/api/realestate/matches/refresh');
await load();
} catch (e) {
console.error('Refresh error:', e);
} finally {
setRefreshing(false);
}
};
const handleMarkRead = async (id) => {
try {
await apiPatch(`/api/realestate/matches/${id}/read`);
setItems(prev => prev.map(m => m.id === id ? { ...m, is_new: false } : m));
} catch (e) {
console.error('Mark read error:', e);
}
};
const totalPages = Math.max(1, Math.ceil(total / size));
return (
<div style={{ display: 'grid', gap: 16 }}>
<div className="sub-tabs-bar">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-dim)' }}>
{total}건의 매칭 결과
</p>
{myPoints && (
<span className="sub-badge" style={{
color: myPoints.total >= 60 ? '#34d399' : myPoints.total >= 40 ? '#f59e0b' : '#f87171',
background: myPoints.total >= 60 ? 'rgba(52,211,153,0.1)' : myPoints.total >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(248,113,113,0.1)',
fontWeight: 700, fontSize: 12,
}}>
가점 {myPoints.total}/{myPoints.max_total}
</span>
)}
</div>
<button
className="sub-filter-btn is-active"
onClick={handleRefresh}
disabled={refreshing}
style={{ padding: '8px 20px', fontSize: 13 }}
>
{refreshing ? '재계산 중...' : '재계산'}
</button>
</div>
{loading ? (
<div className="sub-empty">불러오는 ...</div>
) : items.length === 0 ? (
<div className="sub-empty">
매칭 결과가 없습니다. 프로필을 설정하고 재계산을 실행해 보세요.
</div>
) : (
<>
<div style={{ display: 'grid', gap: 10 }}>
{items.map((match) => (
<div
key={match.id}
className="sub-card"
style={{
gridTemplateColumns: '1fr auto',
alignItems: 'center',
borderLeft: match.is_new ? '3px solid #f43f5e' : undefined,
cursor: match.is_new ? 'pointer' : 'default',
}}
onClick={() => match.is_new && handleMarkRead(match.id)}
>
<div style={{ display: 'grid', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 className="sub-card__name" style={{ margin: 0 }}>
{match.house_nm || `공고 #${match.announcement_id}`}
</h4>
{match.is_new && (
<span className="sub-badge" style={{ color: '#f43f5e', background: 'rgba(244,63,94,0.1)' }}>
NEW
</span>
)}
{match.ann_status && <StatusBadge status={match.ann_status} />}
</div>
<p className="sub-card__address" style={{ margin: 0 }}>
{match.region_name || '-'}
{match.receipt_start && (
<span style={{ marginLeft: 8 }}>
{fmt(match.receipt_start)} ~ {fmt(match.receipt_end)}
{(() => {
const dd = getDDays(match.receipt_start);
return dd ? <span style={{ marginLeft: 6, fontWeight: 600, color: getDDayColor(match.receipt_start) }}>{dd}</span> : null;
})()}
</span>
)}
</p>
{match.eligible_types && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(Array.isArray(match.eligible_types)
? match.eligible_types
: (() => { try { return JSON.parse(match.eligible_types); } catch { return []; } })()
).map((t, i) => (
<span key={i} className="sub-tag is-neutral" style={{ fontSize: 10 }}>
{t}
</span>
))}
</div>
)}
{match.match_reasons?.length > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{(Array.isArray(match.match_reasons)
? match.match_reasons
: (() => { try { return JSON.parse(match.match_reasons); } catch { return []; } })()
).join(' · ')}
</div>
)}
</div>
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
<div>
<div style={{
fontFamily: 'var(--font-display)',
fontSize: 28,
fontWeight: 700,
color: (match.match_score ?? 0) >= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171',
lineHeight: 1,
}}>
{match.match_score ?? '-'}
</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
매칭 점수
</div>
</div>
{myPoints && (
<div style={{
fontSize: 11, padding: '3px 8px', borderRadius: 4,
background: myPoints.total >= 50 ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)',
color: myPoints.total >= 50 ? '#34d399' : '#f87171',
fontWeight: 600, whiteSpace: 'nowrap',
}}>
가점 {myPoints.total}
</div>
)}
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 8 }}>
<button className="sub-filter-btn" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
이전
</button>
<span style={{ fontSize: 13, color: 'var(--text-dim)', padding: '4px 8px' }}>
{page} / {totalPages}
</span>
<button className="sub-filter-btn" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
다음
</button>
</div>
)}
</>
)}
</div>
);
}
// ── ProfileTab ────────────────────────────────────────────────────────────────
function ProfileTab() {
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await apiGet('/api/realestate/profile');
if (data && Object.keys(data).length > 0) {
const display = { ...DEFAULT_PROFILE, ...data };
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
setProfile(display);
}
} catch (e) {
console.error('Profile load error:', e);
} finally {
setLoading(false);
}
})();
}, []);
const handleChange = (key, value) => {
setProfile(prev => ({ ...prev, [key]: value }));
};
const handleCheckbox = (key) => {
setProfile(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = async () => {
setSaving(true);
setMessage('');
try {
const payload = { ...profile };
// Convert numeric strings to numbers
['age', 'subscription_months', 'subscription_amount', 'family_members',
'children_count', 'marriage_months', 'min_area', 'max_area', 'max_price'
].forEach(k => {
if (payload[k] !== '' && payload[k] != null) {
payload[k] = Number(payload[k]);
} else {
payload[k] = null;
}
});
// Convert comma-separated strings to arrays
payload.preferred_regions = typeof payload.preferred_regions === 'string'
? payload.preferred_regions.split(',').map(s => s.trim()).filter(Boolean)
: (payload.preferred_regions || []);
payload.preferred_types = typeof payload.preferred_types === 'string'
? payload.preferred_types.split(',').map(s => s.trim()).filter(Boolean)
: (payload.preferred_types || []);
// Send empty arrays as null
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
if (payload.preferred_types.length === 0) payload.preferred_types = null;
const updated = await apiPut('/api/realestate/profile', payload);
if (updated && Object.keys(updated).length > 0) {
// Convert arrays back to comma-separated strings for display
const display = { ...DEFAULT_PROFILE, ...updated };
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
setProfile(display);
}
setMessage('저장 완료');
setTimeout(() => setMessage(''), 2000);
} catch (e) {
console.error('Profile save error:', e);
setMessage('저장 실패: ' + e.message);
} finally {
setSaving(false);
}
};
if (loading) return <div className="sub-empty">불러오는 ...</div>;
const pts = profile.subscription_points;
return (
<div style={{ display: 'grid', gap: 16 }}>
{/* 가점 카드 */}
{pts && pts.total > 0 && (
<div className="sub-panel">
<div className="sub-panel__head">
<div>
<p className="sub-panel__eyebrow">청약 가점</p>
<h3> 가점 현황</h3>
</div>
<div style={{
fontFamily: 'var(--font-display)', fontSize: 36, fontWeight: 700,
color: pts.total >= 60 ? '#34d399' : pts.total >= 40 ? '#f59e0b' : '#f87171',
lineHeight: 1,
}}>
{pts.total}<span style={{ fontSize: 16, color: 'var(--text-muted)', fontWeight: 400 }}> / {pts.max_total}</span>
</div>
</div>
<div className="sub-panel__body" style={{ display: 'grid', gap: 12 }}>
{[
{ label: '무주택기간', data: pts.homeless_duration, color: '#00d4ff' },
{ label: '부양가족 수', data: pts.dependents, color: '#8b5cf6' },
{ label: '청약통장 가입기간', data: pts.subscription_period, color: '#f59e0b' },
].map(({ label, data, color }) => (
<div key={label} style={{ display: 'grid', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
<span>
<span style={{ fontWeight: 700, color }}>{data.score}</span>
<span style={{ color: 'var(--text-dim)' }}> / {data.max}</span>
</span>
</div>
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: color,
width: `${(data.score / data.max) * 100}%`, transition: 'width 0.3s',
}} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{data.detail}</span>
</div>
))}
</div>
</div>
)}
<div className="sub-panel">
<div className="sub-panel__head">
<div>
<p className="sub-panel__eyebrow">프로필</p>
<h3> 청약 프로필</h3>
<p className="sub-panel__sub">자격 조건과 선호 조건을 설정하면 공고 매칭에 활용됩니다. <span style={{ color: '#f43f5e', fontSize: 11 }}>* 필수 입력</span></p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{message && (
<span style={{
fontSize: 12,
color: message.startsWith('저장 완료') ? '#34d399' : '#f87171',
}}>
{message}
</span>
)}
<button
className="sub-filter-btn is-active"
onClick={handleSave}
disabled={saving}
style={{ padding: '8px 20px', fontSize: 13 }}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
<div className="sub-modal__form">
{/* 기본 정보 */}
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
<p className="sub-form-section__title">기본 정보</p>
<div className="sub-form-row">
<label className="sub-form-label">
이름
<input
className="sub-form-input"
value={profile.name || ''}
onChange={e => handleChange('name', e.target.value)}
placeholder="이름"
/>
</label>
<label className="sub-form-label">
나이
<input
className="sub-form-input"
type="number"
value={profile.age || ''}
onChange={e => handleChange('age', e.target.value)}
placeholder="만 나이"
/>
</label>
</div>
</div>
{/* 자격 조건 */}
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
<p className="sub-form-section__title">자격 조건</p>
<div className="sub-form-checks">
<label className="sub-form-check">
<input type="checkbox" checked={!!profile.is_homeless} onChange={() => handleCheckbox('is_homeless')} />
무주택자 *
</label>
<label className="sub-form-check">
<input type="checkbox" checked={!!profile.is_householder} onChange={() => handleCheckbox('is_householder')} />
세대주 *
</label>
<label className="sub-form-check">
<input type="checkbox" checked={!!profile.has_dependents} onChange={() => handleCheckbox('has_dependents')} />
부양가족 있음
</label>
<label className="sub-form-check">
<input type="checkbox" checked={!!profile.is_newlywed} onChange={() => handleCheckbox('is_newlywed')} />
신혼부부
</label>
<label className="sub-form-check">
<input type="checkbox" checked={!!profile.has_newborn} onChange={() => handleCheckbox('has_newborn')} />
출산/입양
</label>
<label className="sub-form-check">
<input type="checkbox" checked={!!profile.is_first_home} onChange={() => handleCheckbox('is_first_home')} />
생애최초
</label>
</div>
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<label className="sub-form-label">
청약 납입 기간 (개월) *
<input
className="sub-form-input"
type="number"
value={profile.subscription_months || ''}
onChange={e => handleChange('subscription_months', e.target.value)}
placeholder="예: 84"
/>
</label>
<label className="sub-form-label">
청약 납입 금액 (만원)
<input
className="sub-form-input"
type="number"
value={profile.subscription_amount || ''}
onChange={e => handleChange('subscription_amount', e.target.value)}
placeholder="예: 1500"
/>
</label>
<label className="sub-form-label">
가족 *
<input
className="sub-form-input"
type="number"
value={profile.family_members || ''}
onChange={e => handleChange('family_members', e.target.value)}
placeholder="본인 포함"
/>
</label>
</div>
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<label className="sub-form-label">
자녀
<input
className="sub-form-input"
type="number"
value={profile.children_count || ''}
onChange={e => handleChange('children_count', e.target.value)}
placeholder="0"
/>
</label>
<label className="sub-form-label">
혼인 기간 (개월)
<input
className="sub-form-input"
type="number"
value={profile.marriage_months || ''}
onChange={e => handleChange('marriage_months', e.target.value)}
placeholder="미혼이면 비워두세요"
/>
</label>
<label className="sub-form-label">
소득 수준 (%)
<input
className="sub-form-input"
type="number"
value={profile.income_level || ''}
onChange={e => handleChange('income_level', e.target.value)}
placeholder="도시근로자 평균 대비 %"
/>
</label>
</div>
</div>
{/* 선호 조건 */}
<div className="sub-form-section">
<p className="sub-form-section__title">선호 조건</p>
<div className="sub-form-row">
<label className="sub-form-label">
선호 지역 *
<input
className="sub-form-input"
value={profile.preferred_regions || ''}
onChange={e => handleChange('preferred_regions', e.target.value)}
placeholder="예: 서울, 경기"
/>
</label>
<label className="sub-form-label">
선호 주택유형
<input
className="sub-form-input"
value={profile.preferred_types || ''}
onChange={e => handleChange('preferred_types', e.target.value)}
placeholder="예: 국민주택, 민영주택"
/>
</label>
</div>
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<label className="sub-form-label">
최소 면적 ()
<input
className="sub-form-input"
type="number"
value={profile.min_area || ''}
onChange={e => handleChange('min_area', e.target.value)}
placeholder="예: 59"
/>
</label>
<label className="sub-form-label">
최대 면적 ()
<input
className="sub-form-input"
type="number"
value={profile.max_area || ''}
onChange={e => handleChange('max_area', e.target.value)}
placeholder="예: 84"
/>
</label>
<label className="sub-form-label">
최대 분양가 (만원)
<input
className="sub-form-input"
type="number"
value={profile.max_price || ''}
onChange={e => handleChange('max_price', e.target.value)}
placeholder="예: 80000"
/>
</label>
</div>
</div>
</div>
</div>
</div>
);
}
// ── Subscription (Main) ──────────────────────────────────────────────────────
function Subscription() {
const [activeTab, setActiveTab] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = useCallback(async () => {
setRefreshKey(k => k + 1);
}, []);
const handleFABClick = useCallback(() => {
setActiveTab(1); // 공고 목록 탭으로 이동
}, []);
return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="sub">
{/* Header */}
<div className="sub-header">
<div>
<p className="sub-kicker">Real Estate</p>
<h1>청약 관리</h1>
<p className="sub-desc">
공공데이터 기반 청약 공고 자동 수집, 조건 매칭, 일정 관리를 한곳에서.
</p>
</div>
</div>
{/* Tabs */}
<div className="sub-tabs-bar">
<div className="sub-tabs">
{TABS.map((tab, i) => (
<button
key={tab}
className={`sub-tab${activeTab === i ? ' is-active' : ''}`}
onClick={() => setActiveTab(i)}
>
{tab}
</button>
))}
</div>
</div>
{/* Body */}
<div className="sub-body">
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
</div>
<FAB onClick={handleFABClick} label="공고 목록" />
</div>
</PullToRefresh>
);
}
export default Subscription;