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 { 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}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 ─────────────────────────────────────────────────────────
|
// ── AnnouncementsTab ─────────────────────────────────────────────────────────
|
||||||
function AnnouncementsTab() {
|
function AnnouncementsTab() {
|
||||||
const [items, setItems] = useState([]);
|
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