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:
@@ -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 { Link } from 'react-router-dom';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
@@ -6,9 +6,12 @@ import FAB from '../../components/FAB';
|
||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||
import NotificationSettings from './components/NotificationSettings';
|
||||
import StatusBadge from './components/StatusBadge';
|
||||
import AnnouncementCard from './components/AnnouncementCard';
|
||||
import AnnouncementDetail from './components/AnnouncementDetail';
|
||||
import CalendarView from './components/CalendarView';
|
||||
import './Subscription.css';
|
||||
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,
|
||||
} 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}m²</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 ─────────────────────────────────────────────────────────
|
||||
function AnnouncementsTab() {
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
90
src/pages/subscription/components/AnnouncementCard.jsx
Normal file
90
src/pages/subscription/components/AnnouncementCard.jsx
Normal 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;
|
||||
279
src/pages/subscription/components/AnnouncementDetail.jsx
Normal file
279
src/pages/subscription/components/AnnouncementDetail.jsx
Normal 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}m²</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;
|
||||
70
src/pages/subscription/components/CalendarView.jsx
Normal file
70
src/pages/subscription/components/CalendarView.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user