- 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>
1355 lines
65 KiB
JavaScript
1355 lines
65 KiB
JavaScript
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}m²</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">
|
||
최소 면적 (m²)
|
||
<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">
|
||
최대 면적 (m²)
|
||
<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;
|