refactor(subscription): 공고 카드/상세/캘린더 컴포넌트 추출

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 17:58:58 +09:00
parent d2dae3d042
commit 5eaca69f78
4 changed files with 444 additions and 430 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import PullToRefresh from '../../components/PullToRefresh'; import PullToRefresh from '../../components/PullToRefresh';
@@ -6,9 +6,12 @@ import FAB from '../../components/FAB';
import DistrictTierEditor from './components/DistrictTierEditor'; import DistrictTierEditor from './components/DistrictTierEditor';
import NotificationSettings from './components/NotificationSettings'; import NotificationSettings from './components/NotificationSettings';
import StatusBadge from './components/StatusBadge'; import StatusBadge from './components/StatusBadge';
import AnnouncementCard from './components/AnnouncementCard';
import AnnouncementDetail from './components/AnnouncementDetail';
import CalendarView from './components/CalendarView';
import './Subscription.css'; import './Subscription.css';
import { import {
STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE, STATUS_CONFIG, TABS, STATUS_FILTERS, DEFAULT_PROFILE,
extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice,
} from './subscriptionUtils'; } from './subscriptionUtils';
@@ -212,434 +215,6 @@ function DashboardTab() {
); );
} }
// ── 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>
)}
{item.district && (
<span className="sub-chip sub-chip--district">{item.district}</span>
)}
{(() => {
const tier = extractTier(item.match_reasons);
return tier ? (
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
{tier}티어
</span>
) : null;
})()}
</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>
{item.match_score !== undefined && item.match_score !== null && (
<div className="sub-match-analysis">
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div>
<p className="sub-panel__eyebrow">매칭 분석</p>
<span className="sub-match-analysis__score">
{item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
</span>
</div>
</div>
{item.score_breakdown && (
<div style={{ marginTop: 12 }}>
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
<div style={{ display: 'grid', gap: 8 }}>
{[
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
].map(({ key, label, max, color }) => {
const v = item.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ display: 'grid', gap: 3 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
<span>
<span style={{ fontWeight: 700, color }}>{v}</span>
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
</span>
</div>
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: color,
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
{item.match_reasons && item.match_reasons.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
<ul className="sub-match-analysis__reasons">
{item.match_reasons.map((r, idx) => (
<li key={idx}>{r}</li>
))}
</ul>
</div>
)}
{item.eligible_types && item.eligible_types.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}> 신청 자격</p>
<div className="sub-match-analysis__elig">
{item.eligible_types.map(t => (
<span key={t} className="sub-chip">{t}</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ── CalendarView ─────────────────────────────────────────────────────────────
function CalendarView({ items, onDaySelect }) {
const [cur, setCur] = useState(() => {
const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1);
});
const year = cur.getFullYear(), month = cur.getMonth();
const dateMap = useMemo(() => {
const map = {};
for (const item of items) {
const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start;
if (!raw || raw.length < 8) continue;
const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
(map[key] = map[key] || []).push(item);
}
return map;
}, [items]);
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`;
return (
<div className="sub-calendar">
<div className="sub-calendar__header">
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month-1, 1))}></button>
<span>{year} {month+1}</span>
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month+1, 1))}></button>
</div>
<div className="sub-calendar__weekdays">
{['일','월','화','수','목','금','토'].map(w => (
<div key={w} className="sub-calendar__weekday">{w}</div>
))}
</div>
<div className="sub-calendar__grid">
{cells.map((d, i) => {
const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null;
const dayItems = key ? (dateMap[key] || []) : [];
const isToday = key === todayKey;
return (
<div
key={i}
className={`sub-calendar__day${!d ? ' is-empty' : ''}${isToday ? ' is-today' : ''}${dayItems.length > 0 ? ' has-items' : ''}`}
onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}${month+1}${d}`)}
>
{d && <span className="sub-calendar__day-num">{d}</span>}
{dayItems.length > 0 && (
<div className="sub-calendar__dots">
{dayItems.slice(0, 3).map((it, j) => (
<span key={j} className="sub-calendar__dot" style={{ background: STATUS_CONFIG[it.status]?.color || '#888' }} />
))}
{dayItems.length > 3 && <span className="sub-calendar__more">+{dayItems.length - 3}</span>}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── AnnouncementsTab ───────────────────────────────────────────────────────── // ── AnnouncementsTab ─────────────────────────────────────────────────────────
function AnnouncementsTab() { function AnnouncementsTab() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
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>
)}
{item.district && (
<span className="sub-chip sub-chip--district">{item.district}</span>
)}
{(() => {
const tier = extractTier(item.match_reasons);
return tier ? (
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
{tier}티어
</span>
) : null;
})()}
</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>
);
}
export default AnnouncementCard;

View File

@@ -0,0 +1,279 @@
import React, { useState } from 'react';
import { HOUSE_TYPE_LABELS, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
import StatusBadge from './StatusBadge';
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>
{item.match_score !== undefined && item.match_score !== null && (
<div className="sub-match-analysis">
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div>
<p className="sub-panel__eyebrow">매칭 분석</p>
<span className="sub-match-analysis__score">
{item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
</span>
</div>
</div>
{item.score_breakdown && (
<div style={{ marginTop: 12 }}>
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
<div style={{ display: 'grid', gap: 8 }}>
{[
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
].map(({ key, label, max, color }) => {
const v = item.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ display: 'grid', gap: 3 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
<span>
<span style={{ fontWeight: 700, color }}>{v}</span>
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
</span>
</div>
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: color,
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
{item.match_reasons && item.match_reasons.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
<ul className="sub-match-analysis__reasons">
{item.match_reasons.map((r, idx) => (
<li key={idx}>{r}</li>
))}
</ul>
</div>
)}
{item.eligible_types && item.eligible_types.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}> 신청 자격</p>
<div className="sub-match-analysis__elig">
{item.eligible_types.map(t => (
<span key={t} className="sub-chip">{t}</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
export default AnnouncementDetail;

View File

@@ -0,0 +1,70 @@
import React, { useState, useMemo } from 'react';
import { STATUS_CONFIG } from '../subscriptionUtils';
function CalendarView({ items, onDaySelect }) {
const [cur, setCur] = useState(() => {
const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1);
});
const year = cur.getFullYear(), month = cur.getMonth();
const dateMap = useMemo(() => {
const map = {};
for (const item of items) {
const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start;
if (!raw || raw.length < 8) continue;
const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
(map[key] = map[key] || []).push(item);
}
return map;
}, [items]);
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`;
return (
<div className="sub-calendar">
<div className="sub-calendar__header">
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month-1, 1))}></button>
<span>{year} {month+1}</span>
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month+1, 1))}></button>
</div>
<div className="sub-calendar__weekdays">
{['일','월','화','수','목','금','토'].map(w => (
<div key={w} className="sub-calendar__weekday">{w}</div>
))}
</div>
<div className="sub-calendar__grid">
{cells.map((d, i) => {
const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null;
const dayItems = key ? (dateMap[key] || []) : [];
const isToday = key === todayKey;
return (
<div
key={i}
className={`sub-calendar__day${!d ? ' is-empty' : ''}${isToday ? ' is-today' : ''}${dayItems.length > 0 ? ' has-items' : ''}`}
onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}${month+1}${d}`)}
>
{d && <span className="sub-calendar__day-num">{d}</span>}
{dayItems.length > 0 && (
<div className="sub-calendar__dots">
{dayItems.slice(0, 3).map((it, j) => (
<span key={j} className="sub-calendar__dot" style={{ background: STATUS_CONFIG[it.status]?.color || '#888' }} />
))}
{dayItems.length > 3 && <span className="sub-calendar__more">+{dayItems.length - 3}</span>}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export default CalendarView;