Compare commits
2 Commits
8514232775
...
d1526af32c
| Author | SHA1 | Date | |
|---|---|---|---|
| d1526af32c | |||
| abd8762b5c |
@@ -1085,6 +1085,21 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sub-profile-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--accent-cyan) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-cyan) 25%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 16px 4px;
|
||||
}
|
||||
.sub-profile-hint__icon { flex-shrink: 0; font-size: 14px; }
|
||||
|
||||
.sub-form-input {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--line);
|
||||
@@ -1556,3 +1571,86 @@ input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription
|
||||
.ns-pass-count strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* === 캘린더 뷰 ========================================================= */
|
||||
.sub-calendar {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sub-calendar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.sub-calendar__weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.sub-calendar__weekday {
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sub-calendar__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
.sub-calendar__day {
|
||||
min-height: 58px;
|
||||
padding: 5px 4px 4px;
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sub-calendar__day:nth-child(7n) { border-right: none; }
|
||||
.sub-calendar__day.is-empty { background: var(--bg-tertiary); opacity: 0.35; }
|
||||
.sub-calendar__day.has-items { cursor: pointer; }
|
||||
.sub-calendar__day.has-items:hover { background: var(--surface-raised); }
|
||||
.sub-calendar__day.is-today .sub-calendar__day-num {
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.sub-calendar__day-num {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sub-calendar__dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.sub-calendar__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sub-calendar__more {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -688,6 +688,73 @@ function AnnouncementDetail({ item, onBookmark }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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([]);
|
||||
@@ -699,8 +766,10 @@ function AnnouncementsTab() {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
|
||||
const [calendarDay, setCalendarDay] = useState(null); // { label, items }
|
||||
|
||||
const size = 20;
|
||||
const size = viewMode === 'calendar' ? 200 : 20;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -720,7 +789,7 @@ function AnnouncementsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter]);
|
||||
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter, viewMode]);
|
||||
|
||||
const handleSelect = async (item) => {
|
||||
setSelected(item.id);
|
||||
@@ -790,6 +859,14 @@ function AnnouncementsTab() {
|
||||
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
|
||||
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
|
||||
/>
|
||||
<button
|
||||
className={`sub-filter-btn${viewMode === 'calendar' ? ' is-active' : ''}`}
|
||||
onClick={() => { setViewMode(v => v === 'calendar' ? 'list' : 'calendar'); setPage(1); setCalendarDay(null); }}
|
||||
style={{ fontSize: 12 }}
|
||||
title="캘린더 뷰 전환"
|
||||
>
|
||||
📅 캘린더
|
||||
</button>
|
||||
<button
|
||||
className="sub-filter-btn"
|
||||
onClick={handleDeleteClosed}
|
||||
@@ -805,6 +882,42 @@ function AnnouncementsTab() {
|
||||
<div className="sub-empty">불러오는 중...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="sub-empty">조건에 맞는 공고가 없습니다.</div>
|
||||
) : viewMode === 'calendar' ? (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<CalendarView
|
||||
items={items}
|
||||
onDaySelect={(dayItems, label) => setCalendarDay({ items: dayItems, label })}
|
||||
/>
|
||||
{calendarDay && (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow">{calendarDay.label}</p>
|
||||
<h3>공고 {calendarDay.items.length}건</h3>
|
||||
</div>
|
||||
<button className="sub-filter-btn" onClick={() => setCalendarDay(null)} style={{ fontSize: 11 }}>닫기</button>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: 'grid', gap: 8 }}>
|
||||
{calendarDay.items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="sub-card"
|
||||
style={{ cursor: 'pointer', padding: '10px 14px' }}
|
||||
onClick={() => { setViewMode('list'); handleSelect(item); }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>{item.house_nm}</span>
|
||||
<span className="sub-badge" style={{ background: STATUS_CONFIG[item.status]?.bg, color: STATUS_CONFIG[item.status]?.color, flexShrink: 0 }}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>{item.region_name} · 접수 {item.receipt_start}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sub-list-layout">
|
||||
{/* Card Grid */}
|
||||
@@ -1243,6 +1356,26 @@ function ProfileTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로필 완성도 힌트 */}
|
||||
{(() => {
|
||||
const missing = [];
|
||||
if (!profile.income_level) missing.push('소득 수준');
|
||||
if (!profile.min_area || !profile.max_area) missing.push('희망 면적');
|
||||
if (!profile.max_price) missing.push('최대 예산');
|
||||
const hasDistricts = profile.preferred_districts &&
|
||||
Object.values(profile.preferred_districts).some(arr => arr?.length > 0);
|
||||
if (!hasDistricts) missing.push('자치구 티어');
|
||||
if (missing.length === 0) return null;
|
||||
return (
|
||||
<div className="sub-profile-hint">
|
||||
<span className="sub-profile-hint__icon">💡</span>
|
||||
<span>
|
||||
<strong>매칭 정확도 개선 가능</strong> — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="sub-modal__form">
|
||||
{/* 기본 정보 */}
|
||||
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
|
||||
Reference in New Issue
Block a user