From 15cb30311e682c83d39b14ef36a06492d720112b Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 17:43:09 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs(refactor):=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EA=B5=AC=EC=A1=B0=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20+=20Subscription=20=EB=B6=84=ED=95=B4=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=C2=B7=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- ...6-07-09-fe-module-refactor-subscription.md | 552 ++++++++++++++++++ ...-fe-module-refactor-subscription-design.md | 125 ++++ 2 files changed, 677 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-fe-module-refactor-subscription.md create mode 100644 docs/superpowers/specs/2026-07-09-fe-module-refactor-subscription-design.md diff --git a/docs/superpowers/plans/2026-07-09-fe-module-refactor-subscription.md b/docs/superpowers/plans/2026-07-09-fe-module-refactor-subscription.md new file mode 100644 index 0000000..646001e --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-fe-module-refactor-subscription.md @@ -0,0 +1,552 @@ +# FE 모듈 구조 컨벤션 + Subscription 분해 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `Subscription.jsx`(1647줄)를 기능 모듈 구조(shell + components/ + utils)로 **동작 보존 분해**하고, 컨벤션을 CLAUDE.md/README에 기록한다. + +**Architecture:** 순수 추출(코드 이동)만. 상수·순수헬퍼→`subscriptionUtils.js`, 각 sub-component→`components/*.jsx`, `Subscription.jsx`는 shell로 축소. 각 Task는 코드를 옮기고 import를 배선한 뒤 build+전체 테스트 green으로 동작 보존을 확인. 로직/JSX/스타일 한 줄도 바꾸지 않음. + +**Tech Stack:** React 18 + Vite, Vitest + @testing-library/react. + +## Global Constraints + +- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼의 본문(JSX·로직)은 **verbatim(그대로)**. import 배선만 추가/변경. 동작·스타일·클래스명 불변. +- 경로: `Subscription.jsx`(`src/pages/subscription/`)는 api를 `'../../api'`로 import. `components/*.jsx`는 `'../../../api'`, utils는 `'../subscriptionUtils'`. shell은 CSS `'./Subscription.css'` import 유지. +- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러. +- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js(x)` 동일 디렉토리. +- `/realestate/listings` 교차링크(shell의 ``)와 `PullToRefresh`/`FAB`/탭 동작 유지. +- 커밋은 web-ui 경로, 변경 파일만 `git add`. 커밋 trailer: + ``` + Co-Authored-By: Claude Opus 4.8 (1M context) + Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS + ``` + +--- + +### Task 1: `subscriptionUtils.js` — 상수·순수헬퍼 추출 + 테스트 + +**Files:** +- Create: `src/pages/subscription/subscriptionUtils.js` +- Test: `src/pages/subscription/subscriptionUtils.test.js` +- Modify: `src/pages/subscription/Subscription.jsx` (해당 정의 제거 + import 추가) + +**Interfaces:** +- Produces: `STATUS_CONFIG`, `HOUSE_TYPE_LABELS`, `TABS`, `STATUS_FILTERS`, `DEFAULT_PROFILE`, `extractTier(reasons)`, `fmt(d)`, `fmtFull(d)`, `getDDays(d)`, `getDDayColor(d)`, `fmtDateTime(d)`, `apiPatch(path,body)`, `fmtPrice(v)`. + +- [ ] **Step 1: Write the failing test** — Create `src/pages/subscription/subscriptionUtils.test.js`: + +```js +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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js` +Expected: FAIL — `Failed to resolve import "./subscriptionUtils.js"`. + +- [ ] **Step 3: Create subscriptionUtils.js** (아래 내용 — `Subscription.jsx` 현재 상수/헬퍼를 그대로 옮김; `_diffDays`는 내부 유지·미export): + +```js +/* 청약(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()}만`; +}; +``` + +- [ ] **Step 4: Wire Subscription.jsx** — `src/pages/subscription/Subscription.jsx`에서: 위 상수/헬퍼의 **기존 정의(현 파일 상단 STATUS_CONFIG~fmtPrice, 로컬 apiPatch 포함)를 삭제**하고, 최상단 import 블록에 추가: + +```js +import { + STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE, + extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, +} from './subscriptionUtils'; +``` +(이 시점엔 컴포넌트들이 아직 Subscription.jsx 내부에 있으므로 import한 심볼을 그대로 사용. 나머지 컴포넌트 정의는 유지.) + +- [ ] **Step 5: Run tests + build** + +Run: `npm run test:run -- src/pages/subscription/subscriptionUtils.test.js` → PASS. +Run: `npm run test:run` → 전체 green. +Run: `npm run build` → `✓ built` (Subscription 동작 보존). + +- [ ] **Step 6: Commit** +```bash +git add src/pages/subscription/subscriptionUtils.js src/pages/subscription/subscriptionUtils.test.js src/pages/subscription/Subscription.jsx +git commit -m "refactor(subscription): 상수·순수헬퍼를 subscriptionUtils로 추출 + 테스트" +``` + +--- + +### Task 2: `StatusBadge` 컴포넌트 추출 + 스모크 + +**Files:** +- Create: `src/pages/subscription/components/StatusBadge.jsx` +- Test: `src/pages/subscription/components/StatusBadge.test.jsx` +- Modify: `src/pages/subscription/Subscription.jsx` + +**Interfaces:** +- Consumes (Task 1): `STATUS_CONFIG`. +- Produces: `StatusBadge` 기본 export — `({ status, size })`. + +- [ ] **Step 1: Write the failing smoke test** — Create `src/pages/subscription/components/StatusBadge.test.jsx`: + +```jsx +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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx` +Expected: FAIL — `Failed to resolve import "./StatusBadge.jsx"`. + +- [ ] **Step 3: Create StatusBadge.jsx** (현 `Subscription.jsx`의 StatusBadge 정의를 그대로 옮기고 상단 import 추가): + +```jsx +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; +``` + +- [ ] **Step 4: Wire Subscription.jsx** — StatusBadge 정의 삭제 + import 추가: +```js +import StatusBadge from './components/StatusBadge'; +``` + +- [ ] **Step 5: Run tests + build** + +Run: `npm run test:run -- src/pages/subscription/components/StatusBadge.test.jsx` → PASS. +Run: `npm run test:run` → green. `npm run build` → `✓ built`. + +- [ ] **Step 6: Commit** +```bash +git add src/pages/subscription/components/StatusBadge.jsx src/pages/subscription/components/StatusBadge.test.jsx src/pages/subscription/Subscription.jsx +git commit -m "refactor(subscription): StatusBadge 컴포넌트 추출 + 스모크" +``` + +--- + +### Task 3: 표현 컴포넌트 추출 (AnnouncementCard / AnnouncementDetail / CalendarView) + +**Files:** +- Create: `src/pages/subscription/components/AnnouncementCard.jsx` +- Create: `src/pages/subscription/components/AnnouncementDetail.jsx` +- Create: `src/pages/subscription/components/CalendarView.jsx` +- Modify: `src/pages/subscription/Subscription.jsx` + +**Interfaces:** +- Consumes (Task 1/2): utils 헬퍼, `StatusBadge`. +- Produces: 각 기본 export — `AnnouncementCard({item,isSelected,onClick,onBookmark})`, `AnnouncementDetail({item,onBookmark})`, `CalendarView({items,onDaySelect})`. + +- [ ] **Step 1: Create the three components (본문은 현 Subscription.jsx 정의 verbatim, 상단 import만 추가)** + +`AnnouncementCard.jsx` — 상단: +```jsx +import React from 'react'; +import { STATUS_CONFIG, HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; +``` +이어서 현 `Subscription.jsx`의 `function AnnouncementCard(...) { ... }` 본문을 그대로 붙이고 파일 끝에 `export default AnnouncementCard;`. + +`AnnouncementDetail.jsx` — 상단: +```jsx +import React, { useState } from 'react'; +import { HOUSE_TYPE_LABELS, fmt, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; +``` +이어서 현 `AnnouncementDetail(...)` 본문 verbatim + `export default AnnouncementDetail;`. (내부 `useState`(detailTab) 사용하므로 useState import 포함.) + +`CalendarView.jsx` — 상단: +```jsx +import React, { useState, useMemo } from 'react'; +import { STATUS_CONFIG } from '../subscriptionUtils'; +``` +이어서 현 `CalendarView(...)` 본문 verbatim + `export default CalendarView;`. (내부 `useState`/`useMemo` 사용.) + +> 각 컴포넌트가 실제로 참조하는 심볼만 import에 포함되어야 함(빌드가 미사용/누락을 검출). 위 목록은 현 코드 사용 기준. + +- [ ] **Step 2: Wire Subscription.jsx** — 세 컴포넌트 정의 삭제 + import 추가: +```js +import AnnouncementCard from './components/AnnouncementCard'; +import AnnouncementDetail from './components/AnnouncementDetail'; +import CalendarView from './components/CalendarView'; +``` +(AnnouncementsTab이 아직 Subscription.jsx 내부에 있으므로 import한 세 컴포넌트를 그대로 사용.) + +- [ ] **Step 3: Run tests + build** + +Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run build` → `✓ built`. Run: `npm run lint` → 신규 3파일 0 에러. + +- [ ] **Step 4: Commit** +```bash +git add src/pages/subscription/components/AnnouncementCard.jsx src/pages/subscription/components/AnnouncementDetail.jsx src/pages/subscription/components/CalendarView.jsx src/pages/subscription/Subscription.jsx +git commit -m "refactor(subscription): 공고 카드/상세/캘린더 컴포넌트 추출" +``` + +--- + +### Task 4: 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소 + +**Files:** +- Create: `src/pages/subscription/components/DashboardTab.jsx` +- Create: `src/pages/subscription/components/AnnouncementsTab.jsx` +- Create: `src/pages/subscription/components/MatchesTab.jsx` +- Create: `src/pages/subscription/components/ProfileTab.jsx` +- Modify: `src/pages/subscription/Subscription.jsx` (shell로 축소) + +**Interfaces:** +- Consumes: utils 헬퍼, StatusBadge, AnnouncementCard/AnnouncementDetail/CalendarView, 기존 `DistrictTierEditor`/`NotificationSettings`. +- Produces: 각 기본 export (자립형, props 없음). + +- [ ] **Step 1: Create the four tab components (본문 verbatim + 상단 import)** + +`DashboardTab.jsx` — 상단: +```jsx +import React, { useState, useEffect } from 'react'; +import { apiGet, apiPost } from '../../../api'; +import { fmtFull, fmtDateTime, fmtPrice, getDDays, getDDayColor } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; +``` +현 `DashboardTab()` 본문 verbatim + `export default DashboardTab;`. + +`AnnouncementsTab.jsx` — 상단: +```jsx +import React, { useState, useEffect } from 'react'; +import { apiGet, apiDelete } from '../../../api'; +import { STATUS_FILTERS, apiPatch } from '../subscriptionUtils'; +import AnnouncementCard from './AnnouncementCard'; +import AnnouncementDetail from './AnnouncementDetail'; +import CalendarView from './CalendarView'; +``` +현 `AnnouncementsTab()` 본문 verbatim + `export default AnnouncementsTab;`. + +`MatchesTab.jsx` — 상단: +```jsx +import React, { useState, useEffect } from 'react'; +import { apiGet, apiPost } from '../../../api'; +import { extractTier, fmt, getDDays, getDDayColor, apiPatch } from '../subscriptionUtils'; +import StatusBadge from './StatusBadge'; +``` +현 `MatchesTab()` 본문 verbatim + `export default MatchesTab;`. + +`ProfileTab.jsx` — 상단: +```jsx +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()` 본문 verbatim + `export default ProfileTab;`. + +> import 심볼은 각 탭이 실제 사용하는 것 기준(위 목록). 빌드가 누락/미사용 검출. + +- [ ] **Step 2: Reduce Subscription.jsx to the shell** — 4개 탭 정의 삭제. 파일을 아래로 정리(현 shell 본문 verbatim 유지, import만 재구성): + +```jsx +import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import PullToRefresh from '../../components/PullToRefresh'; +import FAB from '../../components/FAB'; +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'; + +// ── Subscription (Main) ── +function Subscription() { + const [activeTab, setActiveTab] = useState(0); + const [refreshKey, setRefreshKey] = useState(0); + + const handleRefresh = useCallback(async () => { + setRefreshKey(k => k + 1); + }, []); + + const handleFABClick = useCallback(() => { + setActiveTab(1); + }, []); + + return ( + +
+
+ + 실매물 · 안전마진 → + +
+
+
+

Real Estate

+

청약 관리

+

+ 공공데이터 기반 청약 공고 자동 수집, 내 조건 매칭, 일정 관리를 한곳에서. +

+
+
+
+
+ {TABS.map((tab, i) => ( + + ))} +
+
+
+ {activeTab === 0 && } + {activeTab === 1 && } + {activeTab === 2 && } + {activeTab === 3 && } +
+ +
+
+ ); +} + +export default Subscription; +``` + +- [ ] **Step 3: Run full suite + lint + build** + +Run: `npm run test:run` → 전체 green(회귀 0). Run: `npm run lint` → 신규 파일 0 에러. Run: `npm run build` → `✓ built`. `Subscription.jsx`가 ~70줄 shell로 축소됐는지 확인(`wc -l`). + +- [ ] **Step 4: Commit** +```bash +git add src/pages/subscription/components/DashboardTab.jsx src/pages/subscription/components/AnnouncementsTab.jsx src/pages/subscription/components/MatchesTab.jsx src/pages/subscription/components/ProfileTab.jsx src/pages/subscription/Subscription.jsx +git commit -m "refactor(subscription): 4개 탭 컴포넌트 추출 → Subscription을 shell로 축소" +``` + +- [ ] **Step 5: Manual verification (dev server)** + +`npm run dev` → `http://localhost:3007/realestate` 접속. 4탭(대시보드/공고 목록/매칭 결과/내 프로필) 전환, 공고 카드 선택→상세, 캘린더 뷰, 즐겨찾기 토글, 프로필 저장이 리팩토링 전과 **동일 동작**인지 확인. 실매물 교차링크 동작 확인. + +--- + +### Task 5: Part A — 기능 모듈 구조 컨벤션 문서화 (CLAUDE.md + README) + +**Files:** +- Modify: `CLAUDE.md` (web-ui 루트) +- Modify: `README.md` + +- [ ] **Step 1: web-ui/CLAUDE.md — "기능 모듈 구조" 섹션 추가** (문서 상단 "주요 파일 위치" 또는 페이지 구조 근처): + +```markdown +## 기능 모듈 구조 (컨벤션) + +대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`). + +​``` +src/pages// +├── .jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만 +├── .css # 스타일 (shell에서 1회 import) +├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임 +├── hooks/ # 상태·API·부수효과 훅 (선택) +└── Utils.js # 순수 헬퍼(포맷/매핑/계산) + Utils.test.js +​``` + +규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신. +``` +그리고 페이지 구조 표의 `/realestate` 행 설명에 "(모듈 분해: shell + components/8 + subscriptionUtils)" 취지 반영. + +- [ ] **Step 2: README.md — 컨벤션 문단 추가** (프로젝트 통계/구조 근처): + +```markdown +### 기능 모듈 구조 + +대형 기능 페이지는 `src/pages//` 아래 **shell(`.jsx`) + `components/` + `hooks/` + `Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`) +``` + +- [ ] **Step 3: Commit** +```bash +git add CLAUDE.md README.md +git commit -m "docs: 기능 모듈 구조 컨벤션 하네스(CLAUDE.md)·README 기록" +``` + +--- + +## Self-Review 결과 + +**Spec coverage** (설계 §1–§7): +- Part A 컨벤션+문서 → Task 5 ✅ +- Part B file map(utils/StatusBadge/카드3/탭4/shell) → Task 1/2/3/4 ✅ +- §3 subscriptionUtils 이동 항목 → Task 1 ✅ (local apiPatch 이동 포함) +- §4 테스트(utils 유닛 + StatusBadge 스모크 + build/lint/수동) → Task 1/2 + 각 Task build/lint + Task 4 수동 ✅ +- §6 완료기준 → 각 Task 검증 + Task 5 ✅ + +**Placeholder scan:** 신규 코드(utils/StatusBadge/테스트/shell/docs)는 전체 코드 명시. 대형 컴포넌트는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 옮길 원본이 파일에 존재, import 심볼 목록 명시). ✅ + +**Type consistency:** utils export 심볼(Task1) ↔ Task2/3/4 import 일치. 컴포넌트 기본 export ↔ shell import 일치. api 경로(shell `../../api`, components `../../../api`, utils `../subscriptionUtils`) 일치. ✅ + +**참고:** 각 Task는 이전 Task 산출물(utils→StatusBadge→카드→탭→shell)에 순차 의존. 순서 준수 필수. 리팩토링이라 RED는 "신규 파일 미존재(import 실패)"로 성립, GREEN은 추출 후 통과 + 전체 회귀 0. diff --git a/docs/superpowers/specs/2026-07-09-fe-module-refactor-subscription-design.md b/docs/superpowers/specs/2026-07-09-fe-module-refactor-subscription-design.md new file mode 100644 index 0000000..52fc0f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-fe-module-refactor-subscription-design.md @@ -0,0 +1,125 @@ +# FE 기능 모듈 구조 컨벤션 + Subscription 분해 리팩토링 — 설계 + +- **작성일**: 2026-07-09 +- **역할/저장소**: FE (`web-ui`) +- **범위**: FE 대형 컴포넌트 책임 분해 리팩토링의 **1번째 서브프로젝트**. (전체 분해: Subscription→RealEstate→PortfolioTab→InstaCards→MusicStudio 순차, 각 별도 spec/plan.) +- **성격**: **behavior-preserving(동작 보존) 순수 추출**. 로직 재작성 금지, UI/동작 변경 없음. + +--- + +## 1. 배경 & 목표 + +기능이 늘며 일부 페이지 컴포넌트가 단일 파일에 과도한 책임을 담게 됨(예: `Subscription.jsx` 1647줄에 4탭+카드+상세+캘린더+헬퍼 전부). 하네스 엔지니어링 원칙(단일 책임·명확한 경계·CLAUDE.md 계층 문서화)에 맞춰 **기능 모듈 구조**로 분해하고, 그 컨벤션을 하네스(web-ui/CLAUDE.md)와 README에 기록한다. + +이 스펙은 두 부분: +- **Part A (공통 컨벤션 + 문서화)**: "기능 모듈 구조" 표준 정의 → 이후 모든 서브프로젝트가 참조. web-ui/CLAUDE.md + README 기록. +- **Part B (Subscription 분해)**: 컨벤션을 `Subscription.jsx`에 첫 적용. + +비목표: 동작/스타일 변경, 신규 기능, api.js 중복(local apiPatch) 정리 외 무관 리팩토링. + +--- + +## 2. Part A — 기능 모듈 구조 컨벤션 (하네스 기록) + +**표준 디렉토리 형태** (레퍼런스: `src/pages/listings/`): +``` +src/pages// +├── .jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만 +├── .css # 스타일 (shell에서 1회 import → 전역 클래스 하위 공유) +├── components/ # 표현 단위 (카드/탭/상세/리스트…) 각 단일 책임 +├── hooks/ # 상태·API·부수효과 훅 (선택) +└── Utils.js # 순수 헬퍼(포맷/매핑/계산) + 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. From d82550610a537be2ad734e83f9434af6abbc4e40 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 17:47:08 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor(subscription):=20=EC=83=81?= =?UTF-8?q?=EC=88=98=C2=B7=EC=88=9C=EC=88=98=ED=97=AC=ED=8D=BC=EB=A5=BC=20?= =?UTF-8?q?subscriptionUtils=EB=A1=9C=20=EC=B6=94=EC=B6=9C=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/subscription/Subscription.jsx | 114 +----------------- src/pages/subscription/subscriptionUtils.js | 105 ++++++++++++++++ .../subscription/subscriptionUtils.test.js | 42 +++++++ 3 files changed, 151 insertions(+), 110 deletions(-) create mode 100644 src/pages/subscription/subscriptionUtils.js create mode 100644 src/pages/subscription/subscriptionUtils.test.js diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index 08630bc..9881475 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -6,116 +6,10 @@ import FAB from '../../components/FAB'; import DistrictTierEditor from './components/DistrictTierEditor'; import NotificationSettings from './components/NotificationSettings'; 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()}만`; -}; +import { + STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE, + extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, +} from './subscriptionUtils'; // ── StatusBadge ────────────────────────────────────────────────────────────── function StatusBadge({ status, size }) { 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); + }); +}); From d2dae3d042d6be3a3d55053a65c10fb1fd675c8a Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 17:51:15 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor(subscription):=20StatusBadge=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EC=B6=9C=20+?= =?UTF-8?q?=20=EC=8A=A4=EB=AA=A8=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/subscription/Subscription.jsx | 12 +----------- src/pages/subscription/components/StatusBadge.jsx | 14 ++++++++++++++ .../subscription/components/StatusBadge.test.jsx | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 src/pages/subscription/components/StatusBadge.jsx create mode 100644 src/pages/subscription/components/StatusBadge.test.jsx diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index 9881475..24f708a 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -5,23 +5,13 @@ import PullToRefresh from '../../components/PullToRefresh'; import FAB from '../../components/FAB'; import DistrictTierEditor from './components/DistrictTierEditor'; import NotificationSettings from './components/NotificationSettings'; +import StatusBadge from './components/StatusBadge'; import './Subscription.css'; import { STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE, extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, } from './subscriptionUtils'; -// ── 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); 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(); + }); +}); From 5eaca69f783362718ca899e1b2c2b0a8f407ec40 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 17:58:58 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor(subscription):=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=B9=B4=EB=93=9C/=EC=83=81=EC=84=B8/=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/subscription/Subscription.jsx | 435 +----------------- .../components/AnnouncementCard.jsx | 90 ++++ .../components/AnnouncementDetail.jsx | 279 +++++++++++ .../subscription/components/CalendarView.jsx | 70 +++ 4 files changed, 444 insertions(+), 430 deletions(-) create mode 100644 src/pages/subscription/components/AnnouncementCard.jsx create mode 100644 src/pages/subscription/components/AnnouncementDetail.jsx create mode 100644 src/pages/subscription/components/CalendarView.jsx diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index 24f708a..3e3af70 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import { Link } from 'react-router-dom'; import PullToRefresh from '../../components/PullToRefresh'; @@ -6,9 +6,12 @@ import FAB from '../../components/FAB'; import DistrictTierEditor from './components/DistrictTierEditor'; import NotificationSettings from './components/NotificationSettings'; import StatusBadge from './components/StatusBadge'; +import AnnouncementCard from './components/AnnouncementCard'; +import AnnouncementDetail from './components/AnnouncementDetail'; +import CalendarView from './components/CalendarView'; import './Subscription.css'; import { - STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE, + STATUS_CONFIG, TABS, STATUS_FILTERS, DEFAULT_PROFILE, extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, } from './subscriptionUtils'; @@ -212,434 +215,6 @@ function DashboardTab() { ); } -// ── AnnouncementCard ───────────────────────────────────────────────────────── -function AnnouncementCard({ item, isSelected, onClick, onBookmark }) { - const dday = getDDays(item.receipt_start); - const priceText = item.min_price != null - ? (item.min_price === item.max_price_display - ? fmtPrice(item.min_price) - : `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`) - : null; - return ( -
-
-
- - {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([]); 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/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; From 4f5d7796c12c41ce271fc1b9832cadc556f1ebd8 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 18:09:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor(subscription):=204=EA=B0=9C=20?= =?UTF-8?q?=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=E2=86=92=20Subscription=EC=9D=84=20shell=EB=A1=9C?= =?UTF-8?q?=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/subscription/Subscription.jsx | 1055 +---------------- .../components/AnnouncementsTab.jsx | 221 ++++ .../subscription/components/DashboardTab.jsx | 206 ++++ .../subscription/components/MatchesTab.jsx | 228 ++++ .../subscription/components/ProfileTab.jsx | 399 +++++++ 5 files changed, 1062 insertions(+), 1047 deletions(-) create mode 100644 src/pages/subscription/components/AnnouncementsTab.jsx create mode 100644 src/pages/subscription/components/DashboardTab.jsx create mode 100644 src/pages/subscription/components/MatchesTab.jsx create mode 100644 src/pages/subscription/components/ProfileTab.jsx diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index 3e3af70..75f0756 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -1,1048 +1,15 @@ -import React, { useState, useEffect, 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 StatusBadge from './components/StatusBadge'; -import AnnouncementCard from './components/AnnouncementCard'; -import AnnouncementDetail from './components/AnnouncementDetail'; -import CalendarView from './components/CalendarView'; +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'; -import { - STATUS_CONFIG, TABS, STATUS_FILTERS, DEFAULT_PROFILE, - extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice, -} from './subscriptionUtils'; -// ── 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} -

-

즐겨찾기

-
-
-

{totalCount}

-

전체 공고

-
-
- - {/* 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} - - )} -
- ); - })} -
-
-
- )} -
- ); -} - -// ── 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); @@ -1052,7 +19,7 @@ function Subscription() { }, []); const handleFABClick = useCallback(() => { - setActiveTab(1); // 공고 목록 탭으로 이동 + setActiveTab(1); }, []); return ( @@ -1063,7 +30,6 @@ function Subscription() { 실매물 · 안전마진 →
- {/* Header */}

Real Estate

@@ -1073,8 +39,6 @@ function Subscription() {

- - {/* Tabs */}
{TABS.map((tab, i) => ( @@ -1088,15 +52,12 @@ function Subscription() { ))}
- - {/* Body */}
{activeTab === 0 && } {activeTab === 1 && } {activeTab === 2 && } {activeTab === 3 && }
-
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/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} +

+

즐겨찾기

+
+
+

{totalCount}

+

전체 공고

+
+
+ + {/* 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; From 6bcf4c96e4a5cafe254e9d943113cefdb782ff49 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 18:14:28 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=EA=B8=B0=EB=8A=A5=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EA=B5=AC=EC=A1=B0=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=ED=95=98=EB=84=A4=EC=8A=A4(CLAUDE.md)=C2=B7README=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- CLAUDE.md | 19 ++++++++++++++++++- README.md | 6 ++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1061497..dc84419 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ | `/stock` | `Stock` | 주식 뉴스/지수 | | `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) | | `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | -| `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) | +| `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)
• **(모듈 분해: shell + components/8 + subscriptionUtils)** | | `/realestate/property` | `RealEstate` | 관심 단지 정보 | | `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) | | `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) | @@ -243,6 +243,23 @@ npm run preview # 빌드 결과물 미리보기 --- +## 기능 모듈 구조 (컨벤션) + +대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`). + +``` +src/pages// +├── .jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만 +├── .css # 스타일 (shell에서 1회 import) +├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임 +├── hooks/ # 상태·API·부수효과 훅 (선택) +└── Utils.js # 순수 헬퍼(포맷/매핑/계산) + Utils.test.js +``` + +규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신. + +--- + ## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`) ### 파일 구조 diff --git a/README.md b/README.md index 7b94b3a..b89ea4d 100644 --- a/README.md +++ b/README.md @@ -222,3 +222,9 @@ apiPost('/api/travel/sync'); | 공통 컴포넌트 | 10개 | | API 헬퍼 함수 | 65+ | | 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable | + +--- + +### 기능 모듈 구조 + +대형 기능 페이지는 `src/pages//` 아래 **shell(`.jsx`) + `components/` + `hooks/` + `Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`) From 7f29931f937486a388fbb89a7f754b07404d232f Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 18:20:56 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(refactor):=20CLAUDE.md=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=ED=95=B4=20=ED=91=9C=EA=B8=B0=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95(components/8=E2=86=9210,=20shell=2067=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index dc84419..a14923d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ | `/stock` | `Stock` | 주식 뉴스/지수 | | `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) | | `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | -| `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)
• **(모듈 분해: shell + components/8 + subscriptionUtils)** | +| `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)
• **(모듈 분해: shell(67줄) + components/10 + subscriptionUtils)** | | `/realestate/property` | `RealEstate` | 관심 단지 정보 | | `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) | | `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |