Utils.test.js
+```
+
+**규칙:**
+- 파일당 **단일 책임**, 권장 상한 **~300줄**(초과 시 분할 신호).
+- 순수 로직(포맷/매핑/계산)은 `*Utils.js`로 뽑고 **유닛 테스트 필수**.
+- 상태+API가 얽힌 로직은 `hooks/` 또는 자립형 탭 컴포넌트로.
+- shell은 "조합"만: 데이터 페칭·비즈니스 로직을 직접 갖지 않음.
+- 컴포넌트는 서로 잘 정의된 props/훅 인터페이스로만 통신.
+
+**문서화 대상:**
+- `web-ui/CLAUDE.md`: "기능 모듈 구조" 섹션 신설(위 형태·규칙), 페이지 구조 표에 리팩토링 반영.
+- `README.md`: 프로젝트 통계/구조 설명에 모듈 구조 컨벤션 1문단 추가.
+
+---
+
+## 3. Part B — Subscription.jsx 분해 (file map)
+
+현 `src/pages/subscription/Subscription.jsx`(1647줄)를 아래로 분해. **코드 이동만**(내용 동일), import 배선만 추가.
+
+```
+src/pages/subscription/
+├── Subscription.jsx # shell: PullToRefresh + 헤더 + 탭바 + FAB + activeTab/refreshKey. 4탭 조합. (~70줄)
+├── Subscription.css # (변경 없음, shell에서 import 유지)
+├── subscriptionUtils.js # 상수·순수헬퍼 이동
+├── subscriptionUtils.test.js # 순수헬퍼 유닛 테스트 (신규)
+└── components/
+ ├── StatusBadge.jsx # (기존 컴포넌트 이동)
+ ├── DashboardTab.jsx # 자립형(self-fetch)
+ ├── AnnouncementCard.jsx
+ ├── AnnouncementDetail.jsx # detailTab 상태 + 정보/일정/주택형/매칭분석
+ ├── CalendarView.jsx
+ ├── AnnouncementsTab.jsx # AnnouncementCard/AnnouncementDetail/CalendarView 조합
+ ├── MatchesTab.jsx
+ ├── ProfileTab.jsx # DistrictTierEditor/NotificationSettings(기존) 사용
+ ├── DistrictTierEditor.jsx # (기존 유지)
+ └── NotificationSettings.jsx # (기존 유지)
+```
+
+**`subscriptionUtils.js`로 이동할 항목:**
+- 상수: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`
+- 순수 헬퍼: `extractTier`, `fmt`, `fmtFull`, `_diffDays`, `getDDays`, `getDDayColor`, `fmtDateTime`, `fmtPrice`
+- `apiPatch`(현재 Subscription 로컬 정의) — 이동. (⚠️ `src/api.js`에 동일 기능 `apiPatch` 이미 존재 — 이번엔 **로컬 것을 utils로 그대로 이동**(동작 보존), api.js와의 dedupe는 후속 follow-up으로 기록.)
+
+**의존 관계(이동 시 import 배선):**
+- `DashboardTab` → apiGet/apiPost, STATUS_CONFIG, fmt류, getDDays/getDDayColor, fmtDateTime, fmtPrice, StatusBadge
+- `AnnouncementCard`/`AnnouncementDetail` → STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt/fmtFull, getDDays/getDDayColor, fmtPrice, StatusBadge
+- `AnnouncementsTab` → apiGet/apiDelete/apiPatch, STATUS_FILTERS, AnnouncementCard/AnnouncementDetail/CalendarView
+- `MatchesTab` → apiGet/apiPost/apiPatch, extractTier, fmt/getDDays/getDDayColor, StatusBadge
+- `ProfileTab` → apiGet/apiPut, DEFAULT_PROFILE, DistrictTierEditor/NotificationSettings
+- `Subscription`(shell) → TABS, 4탭 컴포넌트, PullToRefresh/FAB/Link
+
+**주의:** `/realestate/listings` 교차링크(현 shell의 Link)는 그대로 유지.
+
+---
+
+## 4. 테스트 (안전망 — 현재 Subscription은 테스트 0)
+
+behavior-preserving라 기존 동작 유지가 핵심. 안전망:
+1. **`subscriptionUtils.test.js` (신규, 필수)** — 순수 헬퍼 유닛:
+ - `extractTier`: "자치구 S티어: 강남구 (+25)" → "S"; 미매치 → null.
+ - `getDDays`/`getDDayColor`: D-day 경계(오늘=D-Day, 미래 D-N, 과거 D+N; 색 임계 0/3/7).
+ - `fmtPrice`(현 구현 동작 고정): 10000→"1억", 15000→"1.5억", 9999→"9,999만", null→null.
+ - `_diffDays`: 로컬 타임존 파싱 경계.
+2. **`StatusBadge.test.jsx` (신규, 스모크)** — status→색/라벨, 미정의 status 폴백.
+3. 각 탭 컴포넌트는 자립형(api 페치) → 이번 범위는 **스모크 최소화**(추출 검증은 build+lint+수동). 탭 스모크(vi.mock api)는 권장이나 필수 아님(후속).
+
+**검증 게이트(품질 게이트):** `npm run test:run`(신규 유닛 포함 전체 green) + `npm run lint`(신규 파일 0 에러) + `npm run build`(성공) + 개발서버 `/realestate` 4탭 수동 확인(공고 목록/상세/캘린더/매칭/프로필 저장 동작 동일).
+
+---
+
+## 5. 컴포넌트 구조 (분해 후 검증 관점)
+
+각 파일이 "무엇을 하는가 / 어떻게 쓰는가 / 무엇에 의존하는가"로 독립 이해 가능해야 함:
+- `AnnouncementDetail`(현 272줄) → 여전히 큰 편이나 단일 책임(공고 상세). 내부 detailTab 3섹션은 한 컴포넌트 응집. 300줄 근처 → 필요 시 후속 분할 여지 기록.
+- `ProfileTab`(현 390줄) → 폼이 커서 300줄 초과. 이번엔 파일 추출까지만, 폼 섹션(기본/자격/선호) 하위 분할은 후속 follow-up으로 기록(과분해 회피, YAGNI).
+
+---
+
+## 6. 완료 기준 (Acceptance)
+
+- [ ] Part A: web-ui/CLAUDE.md "기능 모듈 구조" 섹션 + README 컨벤션 문단 기록.
+- [ ] Part B: Subscription.jsx가 shell(~70줄) + components/8 + subscriptionUtils(+test)로 분해, 동작/스타일 불변.
+- [ ] `subscriptionUtils.test.js`·`StatusBadge.test.jsx` 통과, 전체 `npm run test:run` green.
+- [ ] `npm run lint`·`npm run build` 통과.
+- [ ] `/realestate` 4탭 수동 검증 동일 동작.
+
+---
+
+## 7. 리스크 / 오픈 이슈
+
+- **테스트 부재 컴포넌트의 리팩토링**: 순수 추출로 위험 최소화 + build/lint/유닛테스트/수동검증. 로직 한 줄도 바꾸지 않음.
+- **거대 import 배선 실수 가능**: 추출 시 각 파일의 사용 심볼을 정확히 import해야 함(누락 시 build 실패로 즉시 검출).
+- **후속 follow-up(문서화, 이번 미적용)**: ProfileTab 폼 섹션 분할; local apiPatch ↔ api.js apiPatch dedupe; AnnouncementDetail 추가 분할.
+- **다음 서브프로젝트**: RealEstate(909)→PortfolioTab(671)→InstaCards(1013)→MusicStudio(1936), 각 동일 컨벤션 별도 spec/plan.
diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx
index 08630bc..75f0756 100644
--- a/src/pages/subscription/Subscription.jsx
+++ b/src/pages/subscription/Subscription.jsx
@@ -1,1589 +1,15 @@
-import React, { useState, useEffect, useMemo, useCallback } from 'react';
-import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
+import React, { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
-import DistrictTierEditor from './components/DistrictTierEditor';
-import NotificationSettings from './components/NotificationSettings';
+import { TABS } from './subscriptionUtils';
+import DashboardTab from './components/DashboardTab';
+import AnnouncementsTab from './components/AnnouncementsTab';
+import MatchesTab from './components/MatchesTab';
+import ProfileTab from './components/ProfileTab';
import './Subscription.css';
-// ── 상수 ───────────────────────────────────────────────────────────────────────
-const STATUS_CONFIG = {
- '청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
- '청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
- '결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
- '완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
-};
-
-const HOUSE_TYPE_LABELS = {
- '01': '국민주택',
- '02': '민영주택',
- '03': '도시형생활주택',
-};
-
-const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
-
-const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
-
-const DEFAULT_PROFILE = {
- name: '', age: '', is_homeless: false, is_householder: false,
- subscription_months: '', subscription_amount: '',
- family_members: '', has_dependents: false, children_count: '',
- is_newlywed: false, marriage_months: '',
- has_newborn: false, is_first_home: false, income_level: '',
- preferred_regions: '', preferred_types: '',
- min_area: '', max_area: '', max_price: '',
- // 신규 (자치구 5티어 + 알림 설정)
- preferred_districts: {},
- min_match_score: 70,
- notify_enabled: true,
-};
-
-// ── 유틸 ──────────────────────────────────────────────────────────────────────
-
-// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
-function extractTier(reasons) {
- for (const r of reasons || []) {
- const m = r.match(/자치구 ([SABCD])티어/);
- if (m) return m[1];
- }
- return null;
-}
-
-const fmt = (d) => {
- if (!d) return '-';
- return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
-};
-
-const fmtFull = (d) => {
- if (!d) return '-';
- return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
-};
-
-const _diffDays = (d) => {
- if (!d) return null;
- // 로컬 타임존으로 통일하여 D-day 계산 (UTC 파싱 방지)
- const [y, m, day] = d.split('-').map(Number);
- const target = new Date(y, m - 1, day);
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- return Math.round((target - today) / 86400000);
-};
-
-const getDDays = (d) => {
- const diff = _diffDays(d);
- if (diff === null) return null;
- if (diff === 0) return 'D-Day';
- return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
-};
-
-const getDDayColor = (d) => {
- const diff = _diffDays(d);
- if (diff === null) return 'var(--text-dim)';
- if (diff <= 0) return '#f87171';
- if (diff <= 3) return '#f59e0b';
- if (diff <= 7) return '#00d4ff';
- return 'var(--text-dim)';
-};
-
-const fmtDateTime = (d) => {
- if (!d) return '-';
- return new Date(d).toLocaleString('ko-KR', {
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit',
- });
-};
-
-async function apiPatch(path, body) {
- const opts = {
- method: 'PATCH',
- headers: { 'Accept': 'application/json' },
- };
- if (body !== undefined) {
- opts.headers['Content-Type'] = 'application/json';
- opts.body = JSON.stringify(body);
- }
- const res = await fetch(path, opts);
- if (!res.ok) {
- const text = await res.text().catch(() => '');
- throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
- }
- return res.json();
-}
-
-const fmtPrice = (v) => {
- if (v == null) return null;
- if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
- return `${v.toLocaleString()}만`;
-};
-
-// ── StatusBadge ──────────────────────────────────────────────────────────────
-function StatusBadge({ status, size }) {
- const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
- const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
- return (
-
- {status || '알 수 없음'}
-
- );
-}
-
-// ── DashboardTab ─────────────────────────────────────────────────────────────
-function DashboardTab() {
- const [dashboard, setDashboard] = useState(null);
- const [collectStatus, setCollectStatus] = useState(null);
- const [totalCount, setTotalCount] = useState(0);
- const [collecting, setCollecting] = useState(false);
- const [loading, setLoading] = useState(true);
-
- const load = async () => {
- setLoading(true);
- try {
- const [dash, status, ann] = await Promise.all([
- apiGet('/api/realestate/dashboard'),
- apiGet('/api/realestate/collect/status').catch(() => null),
- apiGet('/api/realestate/announcements?page=1&size=1'),
- ]);
- setDashboard(dash);
- setCollectStatus(status);
- setTotalCount(ann?.total || 0);
- } catch (e) {
- console.error('Dashboard load error:', e);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => { load(); }, []);
-
- const handleCollect = async () => {
- setCollecting(true);
- try {
- await apiPost('/api/realestate/collect');
- // Wait a moment then refresh status
- setTimeout(async () => {
- try {
- const status = await apiGet('/api/realestate/collect/status');
- setCollectStatus(status);
- } catch (_) {}
- setCollecting(false);
- load();
- }, 3000);
- } catch (e) {
- console.error('Collect error:', e);
- setCollecting(false);
- }
- };
-
- if (loading) return 불러오는 중...
;
-
- return (
-
- {/* Stats Cards */}
-
-
-
{dashboard?.active_count ?? 0}
-
진행중 공고
-
-
-
0 ? '#f43f5e' : undefined }}>
- {dashboard?.new_match_count ?? 0}
-
-
신규 매칭
-
-
-
0 ? '#f59e0b' : undefined }}>
- {dashboard?.bookmarked_count ?? 0}
-
-
즐겨찾기
-
-
-
-
- {/* Collection Status */}
-
-
-
-
데이터 수집
-
공공데이터 수집 현황
- {collectStatus && (
-
- 마지막 수집: {fmtDateTime(collectStatus.collected_at)}
- {collectStatus.new_count != null && ` · 신규 ${collectStatus.new_count}건`}
- {collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
- {collectStatus.error && · 오류: {collectStatus.error}}
-
- )}
- {!collectStatus &&
수집 이력이 없습니다.
}
-
-
-
-
-
- {/* Upcoming Schedules */}
-
-
-
- {dashboard?.upcoming_schedules?.length > 0 ? (
-
- {dashboard.upcoming_schedules.map((s, i) => {
- const dday = getDDays(s.date);
- return (
-
-
-
-
- {s.house_nm || s.label || '공고'}
-
-
- {fmtFull(s.date)} · {s.event || s.type || '일정'}
-
-
- {dday && (
-
- {dday}
-
- )}
-
- );
- })}
-
- ) : (
-
다가오는 일정이 없습니다.
- )}
-
-
-
- {/* Bookmarked */}
- {dashboard?.bookmarked?.length > 0 && (
-
-
-
-
- {dashboard.bookmarked.map((item) => {
- const dday = getDDays(item.receipt_start);
- const priceText = item.min_price != null
- ? (item.min_price === item.max_price_display
- ? fmtPrice(item.min_price)
- : `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`)
- : null;
- return (
-
-
-
- ★
-
- {item.house_nm}
-
-
-
-
- {item.region_name || '-'}
- {priceText && <> · {priceText}>}
-
-
- {dday && (
-
- {dday}
-
- )}
-
- );
- })}
-
-
-
- )}
-
- );
-}
-
-// ── 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 (
-
-
-
-
- {item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
-
- {HOUSE_TYPE_LABELS[item.house_secd]}
-
- )}
- {item.match_score > 0 && (
- = 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}점
-
- )}
- {item.district && (
- {item.district}
- )}
- {(() => {
- const tier = extractTier(item.match_reasons);
- return tier ? (
-
- {tier}티어
-
- ) : null;
- })()}
-
-
-
-
{item.house_nm || '(이름 없음)'}
-
{item.address || item.region_name || '-'}
-
- {item.total_units ? `${item.total_units}세대` : '-'}
- ·
- {item.region_name || '-'}
- {priceText && (
- <>
- ·
- {priceText}
- >
- )}
-
-
- {item.receipt_start && (
-
- {fmt(item.receipt_start)} ~ {fmt(item.receipt_end)}
-
- )}
- {dday && (
-
- {dday}
-
- )}
-
-
- );
-}
-
-// ── AnnouncementDetail ───────────────────────────────────────────────────────
-function AnnouncementDetail({ item, onBookmark }) {
- const [detailTab, setDetailTab] = useState('info');
-
- if (!item) {
- return (
-
- 🏠
- 공고를 선택하면 상세 정보가 표시됩니다.
-
- );
- }
-
- return (
-
- {/* Header */}
-
-
-
-
- {item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
-
- {HOUSE_TYPE_LABELS[item.house_secd]}
-
- )}
-
-
{item.house_nm}
-
{item.address || item.region_name}
- {item.match_score > 0 && (
-
-
= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
- }}>
- 매칭 {item.match_score}점
-
- {item.eligible_types?.length > 0 && (
-
- {(Array.isArray(item.eligible_types) ? item.eligible_types : []).map((t, i) => (
- {t}
- ))}
-
- )}
-
- )}
-
-
-
- {item.homepage_url && (
-
- 홈페이지
-
- )}
- {item.pblanc_url && (
-
- 공고문
-
- )}
-
-
-
- {/* Section Tabs */}
-
-
-
- {item.models?.length > 0 && (
-
- )}
-
-
- {/* Section Content */}
-
- {detailTab === 'info' && (
-
-
- 단지명
- {item.house_nm}
-
-
- 지역
- {item.region_name || '-'}
-
-
- 주소
- {item.address || '-'}
-
-
- 총 세대수
- {item.total_units ? `${item.total_units}세대` : '-'}
-
-
- 시공사
- {item.constructor || '-'}
-
-
- 시행사
- {item.developer || '-'}
-
-
- 입주 예정
- {item.move_in_month || '-'}
-
-
- )}
-
- {detailTab === 'schedule' && (
-
- {[
- { 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 (
-
-
-
- {s.label}
-
- {fmtFull(s.start)}{s.end ? ` ~ ${fmtFull(s.end)}` : ''}
-
-
- {dday && (
-
- {dday}
-
- )}
-
- );
- })}
- {![item.spsply_start, item.gnrl_rank1_start, item.receipt_start, item.winner_date, item.contract_start].some(Boolean) && (
-
일정 정보가 없습니다.
- )}
-
- )}
-
- {detailTab === 'models' && item.models?.length > 0 && (
-
- {item.models.map((m, i) => {
- const totalUnits = (m.general_units || 0) + (m.special_units || 0);
- return (
-
-
-
- {m.house_ty || `주택형 ${i + 1}`}
-
- {totalUnits > 0 && (
-
- {totalUnits}세대
-
- )}
-
- {m.supply_area && (
-
공급면적 {m.supply_area}m²
- )}
- {m.top_amount != null && (
-
- 분양가 {fmtPrice(m.top_amount)}원
-
- )}
-
- );
- })}
-
- )}
-
-
- {item.match_score !== undefined && item.match_score !== null && (
-
-
-
-
매칭 분석
-
- ⭐ {item.match_score} / 100
-
-
-
-
- {item.score_breakdown && (
-
-
📊 점수 분석
-
- {[
- { 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 (
-
-
- {label}
-
- {v}
- / {max}
-
-
-
-
- );
- })}
-
-
- )}
-
- {item.match_reasons && item.match_reasons.length > 0 && (
-
-
💡 매칭 사유
-
- {item.match_reasons.map((r, idx) => (
- - {r}
- ))}
-
-
- )}
-
- {item.eligible_types && item.eligible_types.length > 0 && (
-
-
✓ 신청 자격
-
- {item.eligible_types.map(t => (
- {t}
- ))}
-
-
- )}
-
- )}
-
- );
-}
-
-// ── 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 (
-
-
-
- {year}년 {month+1}월
-
-
-
- {['일','월','화','수','목','금','토'].map(w => (
-
{w}
- ))}
-
-
- {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 (
-
0 ? ' has-items' : ''}`}
- onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)}
- >
- {d &&
{d}}
- {dayItems.length > 0 && (
-
- {dayItems.slice(0, 3).map((it, j) => (
-
- ))}
- {dayItems.length > 3 && +{dayItems.length - 3}}
-
- )}
-
- );
- })}
-
-
- );
-}
-
-// ── AnnouncementsTab ─────────────────────────────────────────────────────────
-function AnnouncementsTab() {
- const [items, setItems] = useState([]);
- const [total, setTotal] = useState(0);
- const [page, setPage] = useState(1);
- const [statusFilter, setStatusFilter] = useState('전체');
- const [regionFilter, setRegionFilter] = useState('');
- const [bookmarkFilter, setBookmarkFilter] = useState(false);
- const [selected, setSelected] = useState(null);
- const [detail, setDetail] = useState(null);
- const [loading, setLoading] = useState(true);
- const [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
- const [calendarDay, setCalendarDay] = useState(null); // { label, items }
-
- const size = viewMode === 'calendar' ? 200 : 20;
-
- const load = async () => {
- setLoading(true);
- try {
- const params = new URLSearchParams({ page: String(page), size: String(size) });
- if (statusFilter !== '전체') params.set('status', statusFilter);
- if (regionFilter.trim()) params.set('region', regionFilter.trim());
- if (bookmarkFilter) params.set('bookmarked', 'true');
- const data = await apiGet(`/api/realestate/announcements?${params}`);
- setItems(data.items || []);
- setTotal(data.total || 0);
- } catch (e) {
- console.error('Announcements load error:', e);
- setItems([]);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter, viewMode]);
-
- const handleSelect = async (item) => {
- setSelected(item.id);
- try {
- const d = await apiGet(`/api/realestate/announcements/${item.id}`);
- setDetail(d);
- } catch (e) {
- console.error('Detail load error:', e);
- setDetail(item);
- }
- };
-
- const handleDeleteClosed = async () => {
- if (!confirm('종료된(완료) 청약 공고를 모두 삭제할까요?')) return;
- try {
- const res = await apiDelete('/api/realestate/announcements/closed');
- alert(`${res.deleted || 0}건 삭제되었습니다.`);
- setPage(1);
- load();
- } catch (e) {
- console.error('Delete closed error:', e);
- alert('삭제 실패');
- }
- };
-
- const handleBookmark = async (id) => {
- try {
- const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`);
- setItems(prev => prev.map(it =>
- it.id === id ? { ...it, is_bookmarked: updated.is_bookmarked } : it
- ));
- if (detail?.id === id) setDetail(prev => ({ ...prev, is_bookmarked: updated.is_bookmarked }));
- } catch (e) {
- console.error('Bookmark error:', e);
- }
- };
-
- const totalPages = Math.max(1, Math.ceil(total / size));
-
- return (
-
- {/* Filters */}
-
-
- {STATUS_FILTERS.map((f) => (
-
- ))}
-
-
-
- { setRegionFilter(e.target.value); setPage(1); }}
- style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
- />
-
-
-
-
-
- {loading ? (
-
불러오는 중...
- ) : items.length === 0 ? (
-
조건에 맞는 공고가 없습니다.
- ) : viewMode === 'calendar' ? (
-
-
setCalendarDay({ items: dayItems, label })}
- />
- {calendarDay && (
-
-
-
-
{calendarDay.label}
-
공고 {calendarDay.items.length}건
-
-
-
-
- {calendarDay.items.map(item => (
-
{ setViewMode('list'); handleSelect(item); }}
- >
-
- {item.house_nm}
-
- {item.status}
-
-
-
{item.region_name} · 접수 {item.receipt_start}
-
- ))}
-
-
- )}
-
- ) : (
-
- {/* Card Grid */}
-
-
- {items.map((item) => (
-
handleSelect(item)}
- onBookmark={handleBookmark}
- />
- ))}
-
-
- {/* Pagination */}
- {totalPages > 1 && (
-
-
-
- {page} / {totalPages}
-
-
-
- )}
-
-
- {/* Detail Panel */}
-
-
- )}
-
- );
-}
-
-// ── MatchesTab ────────────────────────────────────────────────────────────────
-function MatchesTab() {
- const [items, setItems] = useState([]);
- const [total, setTotal] = useState(0);
- const [myPoints, setMyPoints] = useState(null);
- const [page, setPage] = useState(1);
- const [refreshing, setRefreshing] = useState(false);
- const [loading, setLoading] = useState(true);
-
- const size = 20;
-
- const load = async () => {
- setLoading(true);
- try {
- const data = await apiGet(`/api/realestate/matches?page=${page}&size=${size}`);
- setItems(data.items || []);
- setTotal(data.total || 0);
- if (data.my_points) setMyPoints(data.my_points);
- } catch (e) {
- console.error('Matches load error:', e);
- setItems([]);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => { load(); }, [page]);
-
- const handleRefresh = async () => {
- setRefreshing(true);
- try {
- await apiPost('/api/realestate/matches/refresh');
- await load();
- } catch (e) {
- console.error('Refresh error:', e);
- } finally {
- setRefreshing(false);
- }
- };
-
- const handleMarkRead = async (id) => {
- try {
- await apiPatch(`/api/realestate/matches/${id}/read`);
- setItems(prev => prev.map(m => m.id === id ? { ...m, is_new: false } : m));
- } catch (e) {
- console.error('Mark read error:', e);
- }
- };
-
- const totalPages = Math.max(1, Math.ceil(total / size));
-
- return (
-
-
-
-
- 총 {total}건의 매칭 결과
-
- {myPoints && (
-
= 60 ? '#34d399' : myPoints.total >= 40 ? '#f59e0b' : '#f87171',
- background: myPoints.total >= 60 ? 'rgba(52,211,153,0.1)' : myPoints.total >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(248,113,113,0.1)',
- fontWeight: 700, fontSize: 12,
- }}>
- 내 가점 {myPoints.total}/{myPoints.max_total}
-
- )}
-
-
-
-
- {loading ? (
-
불러오는 중...
- ) : items.length === 0 ? (
-
- 매칭 결과가 없습니다. 프로필을 설정하고 재계산을 실행해 보세요.
-
- ) : (
- <>
-
- {items.map((match) => (
-
match.is_new && handleMarkRead(match.id)}
- >
-
-
-
- {match.house_nm || `공고 #${match.announcement_id}`}
-
- {match.is_new && (
-
- NEW
-
- )}
- {match.ann_status && }
- {match.district && (
- {match.district}
- )}
- {(() => {
- const tier = extractTier(match.match_reasons);
- return tier ? (
-
- {tier}티어
-
- ) : null;
- })()}
-
-
- {match.region_name || '-'}
- {match.receipt_start && (
-
- {fmt(match.receipt_start)} ~ {fmt(match.receipt_end)}
- {(() => {
- const dd = getDDays(match.receipt_start);
- return dd ? {dd} : null;
- })()}
-
- )}
-
- {match.eligible_types && (
-
- {(Array.isArray(match.eligible_types)
- ? match.eligible_types
- : (() => { try { return JSON.parse(match.eligible_types); } catch { return []; } })()
- ).map((t, i) => (
-
- {t}
-
- ))}
-
- )}
- {match.match_reasons?.length > 0 && (
-
- {(Array.isArray(match.match_reasons)
- ? match.match_reasons
- : (() => { try { return JSON.parse(match.match_reasons); } catch { return []; } })()
- ).join(' · ')}
-
- )}
- {match.score_breakdown && (
-
- {[
- { key: 'region', max: 35, color: '#00d4ff' },
- { key: 'type', max: 10, color: '#8b5cf6' },
- { key: 'area', max: 15, color: '#f59e0b' },
- { key: 'price', max: 15, color: '#f43f5e' },
- { key: 'eligibility', max: 25, color: '#34d399' },
- ].map(({ key, max, color }) => {
- const v = match.score_breakdown[key] ?? 0;
- return (
-
- );
- })}
-
- )}
-
-
-
-
= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171',
- lineHeight: 1,
- }}>
- {match.match_score ?? '-'}
-
-
- 매칭 점수
-
-
- {myPoints && (
-
= 50 ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)',
- color: myPoints.total >= 50 ? '#34d399' : '#f87171',
- fontWeight: 600, whiteSpace: 'nowrap',
- }}>
- 가점 {myPoints.total}점
-
- )}
-
-
- ))}
-
-
- {totalPages > 1 && (
-
-
-
- {page} / {totalPages}
-
-
-
- )}
- >
- )}
-
- );
-}
-
-// ── ProfileTab ────────────────────────────────────────────────────────────────
-function ProfileTab() {
- const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
- const [passCount, setPassCount] = useState(null);
- const [saving, setSaving] = useState(false);
- const [loading, setLoading] = useState(true);
- const [message, setMessage] = useState('');
-
- useEffect(() => {
- (async () => {
- setLoading(true);
- try {
- const [data, dash] = await Promise.all([
- apiGet('/api/realestate/profile'),
- apiGet('/api/realestate/dashboard').catch(() => null),
- ]);
- if (data && Object.keys(data).length > 0) {
- const display = { ...DEFAULT_PROFILE, ...data };
- if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
- if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
- setProfile(display);
- }
- if (dash?.pass_count != null) setPassCount(dash.pass_count);
- } catch (e) {
- console.error('Profile load error:', e);
- } finally {
- setLoading(false);
- }
- })();
- }, []);
-
- const handleChange = (key, value) => {
- setProfile(prev => ({ ...prev, [key]: value }));
- };
-
- const handleCheckbox = (key) => {
- setProfile(prev => ({ ...prev, [key]: !prev[key] }));
- };
-
- const handleSave = async () => {
- setSaving(true);
- setMessage('');
- try {
- const payload = { ...profile };
- // Convert numeric strings to numbers
- ['age', 'subscription_months', 'subscription_amount', 'family_members',
- 'children_count', 'marriage_months', 'min_area', 'max_area', 'max_price'
- ].forEach(k => {
- if (payload[k] !== '' && payload[k] != null) {
- payload[k] = Number(payload[k]);
- } else {
- payload[k] = null;
- }
- });
- // Convert comma-separated strings to arrays
- payload.preferred_regions = typeof payload.preferred_regions === 'string'
- ? payload.preferred_regions.split(',').map(s => s.trim()).filter(Boolean)
- : (payload.preferred_regions || []);
- payload.preferred_types = typeof payload.preferred_types === 'string'
- ? payload.preferred_types.split(',').map(s => s.trim()).filter(Boolean)
- : (payload.preferred_types || []);
- // Send empty arrays as null
- if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
- if (payload.preferred_types.length === 0) payload.preferred_types = null;
-
- // 신규: preferred_districts (객체), min_match_score, notify_enabled
- payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
- ? profile.preferred_districts
- : {};
- payload.min_match_score = profile.min_match_score ?? null;
- payload.notify_enabled = profile.notify_enabled ?? null;
-
- const updated = await apiPut('/api/realestate/profile', payload);
- if (updated && Object.keys(updated).length > 0) {
- // Convert arrays back to comma-separated strings for display
- const display = { ...DEFAULT_PROFILE, ...updated };
- if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
- if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
- setProfile(display);
- }
- setMessage('저장 완료');
- setTimeout(() => setMessage(''), 2000);
- } catch (e) {
- console.error('Profile save error:', e);
- setMessage('저장 실패: ' + e.message);
- } finally {
- setSaving(false);
- }
- };
-
- if (loading) return 불러오는 중...
;
-
- const pts = profile.subscription_points;
-
- return (
-
- {/* 가점 카드 */}
- {pts && pts.total > 0 && (
-
-
-
-
= 60 ? '#34d399' : pts.total >= 40 ? '#f59e0b' : '#f87171',
- lineHeight: 1,
- }}>
- {pts.total} / {pts.max_total}
-
-
-
- {[
- { label: '무주택기간', data: pts.homeless_duration, color: '#00d4ff' },
- { label: '부양가족 수', data: pts.dependents, color: '#8b5cf6' },
- { label: '청약통장 가입기간', data: pts.subscription_period, color: '#f59e0b' },
- ].map(({ label, data, color }) => (
-
-
- {label}
-
- {data.score}
- / {data.max}
-
-
-
-
{data.detail}
-
- ))}
-
-
- )}
-
-
-
-
-
프로필
-
내 청약 프로필
-
자격 조건과 선호 조건을 설정하면 공고 매칭에 활용됩니다. * 필수 입력
-
-
- {message && (
-
- {message}
-
- )}
-
-
-
-
- {/* 프로필 완성도 힌트 */}
- {(() => {
- 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 (
-
- 💡
-
- 매칭 정확도 개선 가능 — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다.
-
-
- );
- })()}
-
-
- {/* 기본 정보 */}
-
-
- {/* 자격 조건 */}
-
-
- {/* 선호 조건 */}
-
-
- {/* 자치구 5티어 */}
-
setProfile(prev => ({ ...prev, preferred_districts: next }))}
- />
-
- {/* 알림 설정 */}
- setProfile(prev => ({ ...prev, ...patch }))}
- passCount={passCount}
- />
-
-
-
- );
-}
-
-// ── Subscription (Main) ──────────────────────────────────────────────────────
+// ── Subscription (Main) ──
function Subscription() {
const [activeTab, setActiveTab] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
@@ -1593,7 +19,7 @@ function Subscription() {
}, []);
const handleFABClick = useCallback(() => {
- setActiveTab(1); // 공고 목록 탭으로 이동
+ setActiveTab(1);
}, []);
return (
@@ -1604,7 +30,6 @@ function Subscription() {
실매물 · 안전마진 →
- {/* Header */}
Real Estate
@@ -1614,8 +39,6 @@ function Subscription() {
-
- {/* Tabs */}
{TABS.map((tab, i) => (
@@ -1629,15 +52,12 @@ function Subscription() {
))}
-
- {/* Body */}
{activeTab === 0 &&
}
{activeTab === 1 &&
}
{activeTab === 2 &&
}
{activeTab === 3 &&
}
-
diff --git a/src/pages/subscription/components/AnnouncementCard.jsx b/src/pages/subscription/components/AnnouncementCard.jsx
new file mode 100644
index 0000000..bfed496
--- /dev/null
+++ b/src/pages/subscription/components/AnnouncementCard.jsx
@@ -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 (
+
+
+
+
+ {item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
+
+ {HOUSE_TYPE_LABELS[item.house_secd]}
+
+ )}
+ {item.match_score > 0 && (
+ = 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}점
+
+ )}
+ {item.district && (
+ {item.district}
+ )}
+ {(() => {
+ const tier = extractTier(item.match_reasons);
+ return tier ? (
+
+ {tier}티어
+
+ ) : null;
+ })()}
+
+
+
+
{item.house_nm || '(이름 없음)'}
+
{item.address || item.region_name || '-'}
+
+ {item.total_units ? `${item.total_units}세대` : '-'}
+ ·
+ {item.region_name || '-'}
+ {priceText && (
+ <>
+ ·
+ {priceText}
+ >
+ )}
+
+
+ {item.receipt_start && (
+
+ {fmt(item.receipt_start)} ~ {fmt(item.receipt_end)}
+
+ )}
+ {dday && (
+
+ {dday}
+
+ )}
+
+
+ );
+}
+
+export default AnnouncementCard;
diff --git a/src/pages/subscription/components/AnnouncementDetail.jsx b/src/pages/subscription/components/AnnouncementDetail.jsx
new file mode 100644
index 0000000..7eedd6b
--- /dev/null
+++ b/src/pages/subscription/components/AnnouncementDetail.jsx
@@ -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 (
+
+ 🏠
+ 공고를 선택하면 상세 정보가 표시됩니다.
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
+
+ {HOUSE_TYPE_LABELS[item.house_secd]}
+
+ )}
+
+
{item.house_nm}
+
{item.address || item.region_name}
+ {item.match_score > 0 && (
+
+
= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
+ }}>
+ 매칭 {item.match_score}점
+
+ {item.eligible_types?.length > 0 && (
+
+ {(Array.isArray(item.eligible_types) ? item.eligible_types : []).map((t, i) => (
+ {t}
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {item.homepage_url && (
+
+ 홈페이지
+
+ )}
+ {item.pblanc_url && (
+
+ 공고문
+
+ )}
+
+
+
+ {/* Section Tabs */}
+
+
+
+ {item.models?.length > 0 && (
+
+ )}
+
+
+ {/* Section Content */}
+
+ {detailTab === 'info' && (
+
+
+ 단지명
+ {item.house_nm}
+
+
+ 지역
+ {item.region_name || '-'}
+
+
+ 주소
+ {item.address || '-'}
+
+
+ 총 세대수
+ {item.total_units ? `${item.total_units}세대` : '-'}
+
+
+ 시공사
+ {item.constructor || '-'}
+
+
+ 시행사
+ {item.developer || '-'}
+
+
+ 입주 예정
+ {item.move_in_month || '-'}
+
+
+ )}
+
+ {detailTab === 'schedule' && (
+
+ {[
+ { 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 (
+
+
+
+ {s.label}
+
+ {fmtFull(s.start)}{s.end ? ` ~ ${fmtFull(s.end)}` : ''}
+
+
+ {dday && (
+
+ {dday}
+
+ )}
+
+ );
+ })}
+ {![item.spsply_start, item.gnrl_rank1_start, item.receipt_start, item.winner_date, item.contract_start].some(Boolean) && (
+
일정 정보가 없습니다.
+ )}
+
+ )}
+
+ {detailTab === 'models' && item.models?.length > 0 && (
+
+ {item.models.map((m, i) => {
+ const totalUnits = (m.general_units || 0) + (m.special_units || 0);
+ return (
+
+
+
+ {m.house_ty || `주택형 ${i + 1}`}
+
+ {totalUnits > 0 && (
+
+ {totalUnits}세대
+
+ )}
+
+ {m.supply_area && (
+
공급면적 {m.supply_area}m²
+ )}
+ {m.top_amount != null && (
+
+ 분양가 {fmtPrice(m.top_amount)}원
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {item.match_score !== undefined && item.match_score !== null && (
+
+
+
+
매칭 분석
+
+ ⭐ {item.match_score} / 100
+
+
+
+
+ {item.score_breakdown && (
+
+
📊 점수 분석
+
+ {[
+ { 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 (
+
+
+ {label}
+
+ {v}
+ / {max}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {item.match_reasons && item.match_reasons.length > 0 && (
+
+
💡 매칭 사유
+
+ {item.match_reasons.map((r, idx) => (
+ - {r}
+ ))}
+
+
+ )}
+
+ {item.eligible_types && item.eligible_types.length > 0 && (
+
+
✓ 신청 자격
+
+ {item.eligible_types.map(t => (
+ {t}
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
+
+export default AnnouncementDetail;
diff --git a/src/pages/subscription/components/AnnouncementsTab.jsx b/src/pages/subscription/components/AnnouncementsTab.jsx
new file mode 100644
index 0000000..46ae9fd
--- /dev/null
+++ b/src/pages/subscription/components/AnnouncementsTab.jsx
@@ -0,0 +1,221 @@
+import React, { useState, useEffect } from 'react';
+import { apiGet, apiDelete } from '../../../api';
+import { STATUS_CONFIG, STATUS_FILTERS, apiPatch } from '../subscriptionUtils';
+import AnnouncementCard from './AnnouncementCard';
+import AnnouncementDetail from './AnnouncementDetail';
+import CalendarView from './CalendarView';
+
+// ── AnnouncementsTab ─────────────────────────────────────────────────────────
+function AnnouncementsTab() {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [statusFilter, setStatusFilter] = useState('전체');
+ const [regionFilter, setRegionFilter] = useState('');
+ const [bookmarkFilter, setBookmarkFilter] = useState(false);
+ const [selected, setSelected] = useState(null);
+ const [detail, setDetail] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
+ const [calendarDay, setCalendarDay] = useState(null); // { label, items }
+
+ const size = viewMode === 'calendar' ? 200 : 20;
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams({ page: String(page), size: String(size) });
+ if (statusFilter !== '전체') params.set('status', statusFilter);
+ if (regionFilter.trim()) params.set('region', regionFilter.trim());
+ if (bookmarkFilter) params.set('bookmarked', 'true');
+ const data = await apiGet(`/api/realestate/announcements?${params}`);
+ setItems(data.items || []);
+ setTotal(data.total || 0);
+ } catch (e) {
+ console.error('Announcements load error:', e);
+ setItems([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter, viewMode]);
+
+ const handleSelect = async (item) => {
+ setSelected(item.id);
+ try {
+ const d = await apiGet(`/api/realestate/announcements/${item.id}`);
+ setDetail(d);
+ } catch (e) {
+ console.error('Detail load error:', e);
+ setDetail(item);
+ }
+ };
+
+ const handleDeleteClosed = async () => {
+ if (!confirm('종료된(완료) 청약 공고를 모두 삭제할까요?')) return;
+ try {
+ const res = await apiDelete('/api/realestate/announcements/closed');
+ alert(`${res.deleted || 0}건 삭제되었습니다.`);
+ setPage(1);
+ load();
+ } catch (e) {
+ console.error('Delete closed error:', e);
+ alert('삭제 실패');
+ }
+ };
+
+ const handleBookmark = async (id) => {
+ try {
+ const updated = await apiPatch(`/api/realestate/announcements/${id}/bookmark`);
+ setItems(prev => prev.map(it =>
+ it.id === id ? { ...it, is_bookmarked: updated.is_bookmarked } : it
+ ));
+ if (detail?.id === id) setDetail(prev => ({ ...prev, is_bookmarked: updated.is_bookmarked }));
+ } catch (e) {
+ console.error('Bookmark error:', e);
+ }
+ };
+
+ const totalPages = Math.max(1, Math.ceil(total / size));
+
+ return (
+
+ {/* Filters */}
+
+
+ {STATUS_FILTERS.map((f) => (
+
+ ))}
+
+
+
+ { setRegionFilter(e.target.value); setPage(1); }}
+ style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
+ />
+
+
+
+
+
+ {loading ? (
+
불러오는 중...
+ ) : items.length === 0 ? (
+
조건에 맞는 공고가 없습니다.
+ ) : viewMode === 'calendar' ? (
+
+
setCalendarDay({ items: dayItems, label })}
+ />
+ {calendarDay && (
+
+
+
+
{calendarDay.label}
+
공고 {calendarDay.items.length}건
+
+
+
+
+ {calendarDay.items.map(item => (
+
{ setViewMode('list'); handleSelect(item); }}
+ >
+
+ {item.house_nm}
+
+ {item.status}
+
+
+
{item.region_name} · 접수 {item.receipt_start}
+
+ ))}
+
+
+ )}
+
+ ) : (
+
+ {/* Card Grid */}
+
+
+ {items.map((item) => (
+
handleSelect(item)}
+ onBookmark={handleBookmark}
+ />
+ ))}
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ {page} / {totalPages}
+
+
+
+ )}
+
+
+ {/* Detail Panel */}
+
+
+ )}
+
+ );
+}
+
+export default AnnouncementsTab;
diff --git a/src/pages/subscription/components/CalendarView.jsx b/src/pages/subscription/components/CalendarView.jsx
new file mode 100644
index 0000000..3384b23
--- /dev/null
+++ b/src/pages/subscription/components/CalendarView.jsx
@@ -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 (
+
+
+
+ {year}년 {month+1}월
+
+
+
+ {['일','월','화','수','목','금','토'].map(w => (
+
{w}
+ ))}
+
+
+ {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 (
+
0 ? ' has-items' : ''}`}
+ onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)}
+ >
+ {d &&
{d}}
+ {dayItems.length > 0 && (
+
+ {dayItems.slice(0, 3).map((it, j) => (
+
+ ))}
+ {dayItems.length > 3 && +{dayItems.length - 3}}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+export default CalendarView;
diff --git a/src/pages/subscription/components/DashboardTab.jsx b/src/pages/subscription/components/DashboardTab.jsx
new file mode 100644
index 0000000..2c78b12
--- /dev/null
+++ b/src/pages/subscription/components/DashboardTab.jsx
@@ -0,0 +1,206 @@
+import React, { useState, useEffect } from 'react';
+import { apiGet, apiPost } from '../../../api';
+import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils';
+import StatusBadge from './StatusBadge';
+
+// ── DashboardTab ─────────────────────────────────────────────────────────────
+function DashboardTab() {
+ const [dashboard, setDashboard] = useState(null);
+ const [collectStatus, setCollectStatus] = useState(null);
+ const [totalCount, setTotalCount] = useState(0);
+ const [collecting, setCollecting] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const [dash, status, ann] = await Promise.all([
+ apiGet('/api/realestate/dashboard'),
+ apiGet('/api/realestate/collect/status').catch(() => null),
+ apiGet('/api/realestate/announcements?page=1&size=1'),
+ ]);
+ setDashboard(dash);
+ setCollectStatus(status);
+ setTotalCount(ann?.total || 0);
+ } catch (e) {
+ console.error('Dashboard load error:', e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, []);
+
+ const handleCollect = async () => {
+ setCollecting(true);
+ try {
+ await apiPost('/api/realestate/collect');
+ // Wait a moment then refresh status
+ setTimeout(async () => {
+ try {
+ const status = await apiGet('/api/realestate/collect/status');
+ setCollectStatus(status);
+ } catch (_) {}
+ setCollecting(false);
+ load();
+ }, 3000);
+ } catch (e) {
+ console.error('Collect error:', e);
+ setCollecting(false);
+ }
+ };
+
+ if (loading) return 불러오는 중...
;
+
+ return (
+
+ {/* Stats Cards */}
+
+
+
{dashboard?.active_count ?? 0}
+
진행중 공고
+
+
+
0 ? '#f43f5e' : undefined }}>
+ {dashboard?.new_match_count ?? 0}
+
+
신규 매칭
+
+
+
0 ? '#f59e0b' : undefined }}>
+ {dashboard?.bookmarked_count ?? 0}
+
+
즐겨찾기
+
+
+
+
+ {/* Collection Status */}
+
+
+
+
데이터 수집
+
공공데이터 수집 현황
+ {collectStatus && (
+
+ 마지막 수집: {fmtDateTime(collectStatus.collected_at)}
+ {collectStatus.new_count != null && ` · 신규 ${collectStatus.new_count}건`}
+ {collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
+ {collectStatus.error && · 오류: {collectStatus.error}}
+
+ )}
+ {!collectStatus &&
수집 이력이 없습니다.
}
+
+
+
+
+
+ {/* Upcoming Schedules */}
+
+
+
+ {dashboard?.upcoming_schedules?.length > 0 ? (
+
+ {dashboard.upcoming_schedules.map((s, i) => {
+ const dday = getDDays(s.date);
+ return (
+
+
+
+
+ {s.house_nm || s.label || '공고'}
+
+
+ {fmtFull(s.date)} · {s.event || s.type || '일정'}
+
+
+ {dday && (
+
+ {dday}
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
다가오는 일정이 없습니다.
+ )}
+
+
+
+ {/* Bookmarked */}
+ {dashboard?.bookmarked?.length > 0 && (
+
+
+
+
+ {dashboard.bookmarked.map((item) => {
+ const dday = getDDays(item.receipt_start);
+ const priceText = item.min_price != null
+ ? (item.min_price === item.max_price_display
+ ? fmtPrice(item.min_price)
+ : `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`)
+ : null;
+ return (
+
+
+
+ ★
+
+ {item.house_nm}
+
+
+
+
+ {item.region_name || '-'}
+ {priceText && <> · {priceText}>}
+
+
+ {dday && (
+
+ {dday}
+
+ )}
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
+
+export default DashboardTab;
diff --git a/src/pages/subscription/components/MatchesTab.jsx b/src/pages/subscription/components/MatchesTab.jsx
new file mode 100644
index 0000000..afdde18
--- /dev/null
+++ b/src/pages/subscription/components/MatchesTab.jsx
@@ -0,0 +1,228 @@
+import React, { useState, useEffect } from 'react';
+import { apiGet, apiPost } from '../../../api';
+import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils';
+import StatusBadge from './StatusBadge';
+
+// ── MatchesTab ────────────────────────────────────────────────────────────────
+function MatchesTab() {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [myPoints, setMyPoints] = useState(null);
+ const [page, setPage] = useState(1);
+ const [refreshing, setRefreshing] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const size = 20;
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const data = await apiGet(`/api/realestate/matches?page=${page}&size=${size}`);
+ setItems(data.items || []);
+ setTotal(data.total || 0);
+ if (data.my_points) setMyPoints(data.my_points);
+ } catch (e) {
+ console.error('Matches load error:', e);
+ setItems([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, [page]);
+
+ const handleRefresh = async () => {
+ setRefreshing(true);
+ try {
+ await apiPost('/api/realestate/matches/refresh');
+ await load();
+ } catch (e) {
+ console.error('Refresh error:', e);
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ const handleMarkRead = async (id) => {
+ try {
+ await apiPatch(`/api/realestate/matches/${id}/read`);
+ setItems(prev => prev.map(m => m.id === id ? { ...m, is_new: false } : m));
+ } catch (e) {
+ console.error('Mark read error:', e);
+ }
+ };
+
+ const totalPages = Math.max(1, Math.ceil(total / size));
+
+ return (
+
+
+
+
+ 총 {total}건의 매칭 결과
+
+ {myPoints && (
+
= 60 ? '#34d399' : myPoints.total >= 40 ? '#f59e0b' : '#f87171',
+ background: myPoints.total >= 60 ? 'rgba(52,211,153,0.1)' : myPoints.total >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(248,113,113,0.1)',
+ fontWeight: 700, fontSize: 12,
+ }}>
+ 내 가점 {myPoints.total}/{myPoints.max_total}
+
+ )}
+
+
+
+
+ {loading ? (
+
불러오는 중...
+ ) : items.length === 0 ? (
+
+ 매칭 결과가 없습니다. 프로필을 설정하고 재계산을 실행해 보세요.
+
+ ) : (
+ <>
+
+ {items.map((match) => (
+
match.is_new && handleMarkRead(match.id)}
+ >
+
+
+
+ {match.house_nm || `공고 #${match.announcement_id}`}
+
+ {match.is_new && (
+
+ NEW
+
+ )}
+ {match.ann_status && }
+ {match.district && (
+ {match.district}
+ )}
+ {(() => {
+ const tier = extractTier(match.match_reasons);
+ return tier ? (
+
+ {tier}티어
+
+ ) : null;
+ })()}
+
+
+ {match.region_name || '-'}
+ {match.receipt_start && (
+
+ {fmt(match.receipt_start)} ~ {fmt(match.receipt_end)}
+ {(() => {
+ const dd = getDDays(match.receipt_start);
+ return dd ? {dd} : null;
+ })()}
+
+ )}
+
+ {match.eligible_types && (
+
+ {(Array.isArray(match.eligible_types)
+ ? match.eligible_types
+ : (() => { try { return JSON.parse(match.eligible_types); } catch { return []; } })()
+ ).map((t, i) => (
+
+ {t}
+
+ ))}
+
+ )}
+ {match.match_reasons?.length > 0 && (
+
+ {(Array.isArray(match.match_reasons)
+ ? match.match_reasons
+ : (() => { try { return JSON.parse(match.match_reasons); } catch { return []; } })()
+ ).join(' · ')}
+
+ )}
+ {match.score_breakdown && (
+
+ {[
+ { key: 'region', max: 35, color: '#00d4ff' },
+ { key: 'type', max: 10, color: '#8b5cf6' },
+ { key: 'area', max: 15, color: '#f59e0b' },
+ { key: 'price', max: 15, color: '#f43f5e' },
+ { key: 'eligibility', max: 25, color: '#34d399' },
+ ].map(({ key, max, color }) => {
+ const v = match.score_breakdown[key] ?? 0;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171',
+ lineHeight: 1,
+ }}>
+ {match.match_score ?? '-'}
+
+
+ 매칭 점수
+
+
+ {myPoints && (
+
= 50 ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)',
+ color: myPoints.total >= 50 ? '#34d399' : '#f87171',
+ fontWeight: 600, whiteSpace: 'nowrap',
+ }}>
+ 가점 {myPoints.total}점
+
+ )}
+
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+
+
+ {page} / {totalPages}
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export default MatchesTab;
diff --git a/src/pages/subscription/components/ProfileTab.jsx b/src/pages/subscription/components/ProfileTab.jsx
new file mode 100644
index 0000000..201501f
--- /dev/null
+++ b/src/pages/subscription/components/ProfileTab.jsx
@@ -0,0 +1,399 @@
+import React, { useState, useEffect } from 'react';
+import { apiGet, apiPut } from '../../../api';
+import { DEFAULT_PROFILE } from '../subscriptionUtils';
+import DistrictTierEditor from './DistrictTierEditor';
+import NotificationSettings from './NotificationSettings';
+
+// ── ProfileTab ────────────────────────────────────────────────────────────────
+function ProfileTab() {
+ const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
+ const [passCount, setPassCount] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [message, setMessage] = useState('');
+
+ useEffect(() => {
+ (async () => {
+ setLoading(true);
+ try {
+ const [data, dash] = await Promise.all([
+ apiGet('/api/realestate/profile'),
+ apiGet('/api/realestate/dashboard').catch(() => null),
+ ]);
+ if (data && Object.keys(data).length > 0) {
+ const display = { ...DEFAULT_PROFILE, ...data };
+ if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
+ if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
+ setProfile(display);
+ }
+ if (dash?.pass_count != null) setPassCount(dash.pass_count);
+ } catch (e) {
+ console.error('Profile load error:', e);
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, []);
+
+ const handleChange = (key, value) => {
+ setProfile(prev => ({ ...prev, [key]: value }));
+ };
+
+ const handleCheckbox = (key) => {
+ setProfile(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ setMessage('');
+ try {
+ const payload = { ...profile };
+ // Convert numeric strings to numbers
+ ['age', 'subscription_months', 'subscription_amount', 'family_members',
+ 'children_count', 'marriage_months', 'min_area', 'max_area', 'max_price'
+ ].forEach(k => {
+ if (payload[k] !== '' && payload[k] != null) {
+ payload[k] = Number(payload[k]);
+ } else {
+ payload[k] = null;
+ }
+ });
+ // Convert comma-separated strings to arrays
+ payload.preferred_regions = typeof payload.preferred_regions === 'string'
+ ? payload.preferred_regions.split(',').map(s => s.trim()).filter(Boolean)
+ : (payload.preferred_regions || []);
+ payload.preferred_types = typeof payload.preferred_types === 'string'
+ ? payload.preferred_types.split(',').map(s => s.trim()).filter(Boolean)
+ : (payload.preferred_types || []);
+ // Send empty arrays as null
+ if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
+ if (payload.preferred_types.length === 0) payload.preferred_types = null;
+
+ // 신규: preferred_districts (객체), min_match_score, notify_enabled
+ payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
+ ? profile.preferred_districts
+ : {};
+ payload.min_match_score = profile.min_match_score ?? null;
+ payload.notify_enabled = profile.notify_enabled ?? null;
+
+ const updated = await apiPut('/api/realestate/profile', payload);
+ if (updated && Object.keys(updated).length > 0) {
+ // Convert arrays back to comma-separated strings for display
+ const display = { ...DEFAULT_PROFILE, ...updated };
+ if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
+ if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
+ setProfile(display);
+ }
+ setMessage('저장 완료');
+ setTimeout(() => setMessage(''), 2000);
+ } catch (e) {
+ console.error('Profile save error:', e);
+ setMessage('저장 실패: ' + e.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading) return 불러오는 중...
;
+
+ const pts = profile.subscription_points;
+
+ return (
+
+ {/* 가점 카드 */}
+ {pts && pts.total > 0 && (
+
+
+
+
= 60 ? '#34d399' : pts.total >= 40 ? '#f59e0b' : '#f87171',
+ lineHeight: 1,
+ }}>
+ {pts.total} / {pts.max_total}
+
+
+
+ {[
+ { label: '무주택기간', data: pts.homeless_duration, color: '#00d4ff' },
+ { label: '부양가족 수', data: pts.dependents, color: '#8b5cf6' },
+ { label: '청약통장 가입기간', data: pts.subscription_period, color: '#f59e0b' },
+ ].map(({ label, data, color }) => (
+
+
+ {label}
+
+ {data.score}
+ / {data.max}
+
+
+
+
{data.detail}
+
+ ))}
+
+
+ )}
+
+
+
+
+
프로필
+
내 청약 프로필
+
자격 조건과 선호 조건을 설정하면 공고 매칭에 활용됩니다. * 필수 입력
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+
+
+ {/* 프로필 완성도 힌트 */}
+ {(() => {
+ 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 (
+
+ 💡
+
+ 매칭 정확도 개선 가능 — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다.
+
+
+ );
+ })()}
+
+
+ {/* 기본 정보 */}
+
+
+ {/* 자격 조건 */}
+
+
+ {/* 선호 조건 */}
+
+
+ {/* 자치구 5티어 */}
+
setProfile(prev => ({ ...prev, preferred_districts: next }))}
+ />
+
+ {/* 알림 설정 */}
+ setProfile(prev => ({ ...prev, ...patch }))}
+ passCount={passCount}
+ />
+
+
+
+ );
+}
+
+export default ProfileTab;
diff --git a/src/pages/subscription/components/StatusBadge.jsx b/src/pages/subscription/components/StatusBadge.jsx
new file mode 100644
index 0000000..66c5e3e
--- /dev/null
+++ b/src/pages/subscription/components/StatusBadge.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { STATUS_CONFIG } from '../subscriptionUtils';
+
+function StatusBadge({ status, size }) {
+ const cfg = STATUS_CONFIG[status] || { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
+ const cls = size === 'lg' ? 'sub-badge sub-badge--lg' : 'sub-badge';
+ return (
+
+ {status || '알 수 없음'}
+
+ );
+}
+
+export default StatusBadge;
diff --git a/src/pages/subscription/components/StatusBadge.test.jsx b/src/pages/subscription/components/StatusBadge.test.jsx
new file mode 100644
index 0000000..148191b
--- /dev/null
+++ b/src/pages/subscription/components/StatusBadge.test.jsx
@@ -0,0 +1,14 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import StatusBadge from './StatusBadge.jsx';
+
+describe('StatusBadge', () => {
+ it('status 라벨 렌더', () => {
+ render();
+ expect(screen.getByText('청약중')).toBeInTheDocument();
+ });
+ it('미정의 status → "알 수 없음" 폴백', () => {
+ render();
+ expect(screen.getByText('알 수 없음')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/subscription/subscriptionUtils.js b/src/pages/subscription/subscriptionUtils.js
new file mode 100644
index 0000000..efca61c
--- /dev/null
+++ b/src/pages/subscription/subscriptionUtils.js
@@ -0,0 +1,105 @@
+/* 청약(Subscription) 모듈 상수·순수 헬퍼 */
+
+export const STATUS_CONFIG = {
+ '청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
+ '청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
+ '결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
+ '완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
+};
+
+export const HOUSE_TYPE_LABELS = {
+ '01': '국민주택',
+ '02': '민영주택',
+ '03': '도시형생활주택',
+};
+
+export const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
+
+export const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
+
+export const DEFAULT_PROFILE = {
+ name: '', age: '', is_homeless: false, is_householder: false,
+ subscription_months: '', subscription_amount: '',
+ family_members: '', has_dependents: false, children_count: '',
+ is_newlywed: false, marriage_months: '',
+ has_newborn: false, is_first_home: false, income_level: '',
+ preferred_regions: '', preferred_types: '',
+ min_area: '', max_area: '', max_price: '',
+ preferred_districts: {},
+ min_match_score: 70,
+ notify_enabled: true,
+};
+
+export function extractTier(reasons) {
+ for (const r of reasons || []) {
+ const m = r.match(/자치구 ([SABCD])티어/);
+ if (m) return m[1];
+ }
+ return null;
+}
+
+export const fmt = (d) => {
+ if (!d) return '-';
+ return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
+};
+
+export const fmtFull = (d) => {
+ if (!d) return '-';
+ return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
+};
+
+const _diffDays = (d) => {
+ if (!d) return null;
+ const [y, m, day] = d.split('-').map(Number);
+ const target = new Date(y, m - 1, day);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ return Math.round((target - today) / 86400000);
+};
+
+export const getDDays = (d) => {
+ const diff = _diffDays(d);
+ if (diff === null) return null;
+ if (diff === 0) return 'D-Day';
+ return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
+};
+
+export const getDDayColor = (d) => {
+ const diff = _diffDays(d);
+ if (diff === null) return 'var(--text-dim)';
+ if (diff <= 0) return '#f87171';
+ if (diff <= 3) return '#f59e0b';
+ if (diff <= 7) return '#00d4ff';
+ return 'var(--text-dim)';
+};
+
+export const fmtDateTime = (d) => {
+ if (!d) return '-';
+ return new Date(d).toLocaleString('ko-KR', {
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit',
+ });
+};
+
+export async function apiPatch(path, body) {
+ const opts = {
+ method: 'PATCH',
+ headers: { 'Accept': 'application/json' },
+ };
+ if (body !== undefined) {
+ opts.headers['Content-Type'] = 'application/json';
+ opts.body = JSON.stringify(body);
+ }
+ const res = await fetch(path, opts);
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
+ }
+ return res.json();
+}
+
+export const fmtPrice = (v) => {
+ if (v == null) return null;
+ if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
+ return `${v.toLocaleString()}만`;
+};
diff --git a/src/pages/subscription/subscriptionUtils.test.js b/src/pages/subscription/subscriptionUtils.test.js
new file mode 100644
index 0000000..ebfee8e
--- /dev/null
+++ b/src/pages/subscription/subscriptionUtils.test.js
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { extractTier, getDDays, getDDayColor, fmtPrice } from './subscriptionUtils.js';
+
+describe('extractTier', () => {
+ it('자치구 티어 문자열에서 등급 추출', () => {
+ expect(extractTier(['자치구 S티어: 강남구 (+25)'])).toBe('S');
+ expect(extractTier(['면적 +10', '자치구 B티어: 노원구 (+15)'])).toBe('B');
+ });
+ it('미매치/빈 입력 → null', () => {
+ expect(extractTier(['면적 적합'])).toBe(null);
+ expect(extractTier(null)).toBe(null);
+ });
+});
+
+describe('getDDays / getDDayColor (오늘 기준 경계)', () => {
+ const iso = (offsetDays) => {
+ const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + offsetDays);
+ const p = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
+ };
+ it('오늘=D-Day, 미래=D-N, 과거=D+N', () => {
+ expect(getDDays(iso(0))).toBe('D-Day');
+ expect(getDDays(iso(5))).toBe('D-5');
+ expect(getDDays(iso(-3))).toBe('D+3');
+ expect(getDDays(null)).toBe(null);
+ });
+ it('색 임계: 지남=빨강, ≤3=주황, ≤7=파랑, 그 외=dim', () => {
+ expect(getDDayColor(iso(-1))).toBe('#f87171');
+ expect(getDDayColor(iso(2))).toBe('#f59e0b');
+ expect(getDDayColor(iso(6))).toBe('#00d4ff');
+ expect(getDDayColor(iso(30))).toBe('var(--text-dim)');
+ });
+});
+
+describe('fmtPrice (현 구현 동작 고정)', () => {
+ it('억/만 변환', () => {
+ expect(fmtPrice(10000)).toBe('1억');
+ expect(fmtPrice(15000)).toBe('1.5억');
+ expect(fmtPrice(9999)).toBe('9,999만');
+ expect(fmtPrice(null)).toBe(null);
+ });
+});