refactor(subscription): 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
2026-07-09 18:09:57 +09:00
parent 5eaca69f78
commit 4f5d7796c1
5 changed files with 1062 additions and 1047 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react';
import { apiGet, apiDelete } from '../../../api';
import { STATUS_CONFIG, STATUS_FILTERS, apiPatch } from '../subscriptionUtils';
import AnnouncementCard from './AnnouncementCard';
import AnnouncementDetail from './AnnouncementDetail';
import CalendarView from './CalendarView';
// ── 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 [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
const [calendarDay, setCalendarDay] = useState(null); // { label, items }
const size = viewMode === 'calendar' ? 200 : 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, viewMode]);
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${viewMode === 'calendar' ? ' is-active' : ''}`}
onClick={() => { setViewMode(v => v === 'calendar' ? 'list' : 'calendar'); setPage(1); setCalendarDay(null); }}
style={{ fontSize: 12 }}
title="캘린더 뷰 전환"
>
📅 캘린더
</button>
<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>
) : viewMode === 'calendar' ? (
<div style={{ display: 'grid', gap: 12 }}>
<CalendarView
items={items}
onDaySelect={(dayItems, label) => setCalendarDay({ items: dayItems, label })}
/>
{calendarDay && (
<div className="sub-panel">
<div className="sub-panel__head">
<div>
<p className="sub-panel__eyebrow">{calendarDay.label}</p>
<h3>공고 {calendarDay.items.length}</h3>
</div>
<button className="sub-filter-btn" onClick={() => setCalendarDay(null)} style={{ fontSize: 11 }}>닫기</button>
</div>
<div className="sub-panel__body" style={{ display: 'grid', gap: 8 }}>
{calendarDay.items.map(item => (
<div
key={item.id}
className="sub-card"
style={{ cursor: 'pointer', padding: '10px 14px' }}
onClick={() => { setViewMode('list'); handleSelect(item); }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>{item.house_nm}</span>
<span className="sub-badge" style={{ background: STATUS_CONFIG[item.status]?.bg, color: STATUS_CONFIG[item.status]?.color, flexShrink: 0 }}>
{item.status}
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>{item.region_name} · 접수 {item.receipt_start}</span>
</div>
))}
</div>
</div>
)}
</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>
);
}
export default AnnouncementsTab;

View File

@@ -0,0 +1,206 @@
import React, { useState, useEffect } from 'react';
import { apiGet, apiPost } from '../../../api';
import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
// ── 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>
);
}
export default DashboardTab;

View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { apiGet, apiPost } from '../../../api';
import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
// ── 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} />}
{match.district && (
<span className="sub-chip sub-chip--district">{match.district}</span>
)}
{(() => {
const tier = extractTier(match.match_reasons);
return tier ? (
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
{tier}티어
</span>
) : null;
})()}
</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>
)}
{match.score_breakdown && (
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
{[
{ key: 'region', max: 35, color: '#00d4ff' },
{ key: 'type', max: 10, color: '#8b5cf6' },
{ key: 'area', max: 15, color: '#f59e0b' },
{ key: 'price', max: 15, color: '#f43f5e' },
{ key: 'eligibility', max: 25, color: '#34d399' },
].map(({ key, max, color }) => {
const v = match.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
</div>
);
})}
</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>
);
}
export default MatchesTab;

View File

@@ -0,0 +1,399 @@
import React, { useState, useEffect } from 'react';
import { apiGet, apiPut } from '../../../api';
import { DEFAULT_PROFILE } from '../subscriptionUtils';
import DistrictTierEditor from './DistrictTierEditor';
import NotificationSettings from './NotificationSettings';
// ── ProfileTab ────────────────────────────────────────────────────────────────
function ProfileTab() {
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
const [passCount, setPassCount] = useState(null);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
useEffect(() => {
(async () => {
setLoading(true);
try {
const [data, dash] = await Promise.all([
apiGet('/api/realestate/profile'),
apiGet('/api/realestate/dashboard').catch(() => null),
]);
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);
}
if (dash?.pass_count != null) setPassCount(dash.pass_count);
} 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;
// 신규: preferred_districts (객체), min_match_score, notify_enabled
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
? profile.preferred_districts
: {};
payload.min_match_score = profile.min_match_score ?? null;
payload.notify_enabled = profile.notify_enabled ?? 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>
{/* 프로필 완성도 힌트 */}
{(() => {
const missing = [];
if (!profile.income_level) missing.push('소득 수준');
if (!profile.min_area || !profile.max_area) missing.push('희망 면적');
if (!profile.max_price) missing.push('최대 예산');
const hasDistricts = profile.preferred_districts &&
Object.values(profile.preferred_districts).some(arr => arr?.length > 0);
if (!hasDistricts) missing.push('자치구 티어');
if (missing.length === 0) return null;
return (
<div className="sub-profile-hint">
<span className="sub-profile-hint__icon">💡</span>
<span>
<strong>매칭 정확도 개선 가능</strong> {missing.join(', ')} 입력 정확한 점수를 산출합니다.
</span>
</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"
min="0"
max="300"
value={profile.income_level || ''}
onChange={e => handleChange('income_level', e.target.value)}
placeholder="도시근로자 월평균 대비 %"
/>
<span className="sub-form-hint">청년 140 / 신혼·생애최초 160 / 신생아 200 · 미입력 검증 생략</span>
</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>
{/* 자치구 5티어 */}
<DistrictTierEditor
value={profile.preferred_districts}
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
/>
{/* 알림 설정 */}
<NotificationSettings
minScore={profile.min_match_score ?? 70}
notifyEnabled={profile.notify_enabled ?? true}
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
passCount={passCount}
/>
</div>
</div>
</div>
);
}
export default ProfileTab;