docs(refactor): 기능 모듈 구조 컨벤션 + Subscription 분해 설계·계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
@@ -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의 `<Link>`)와 `PullToRefresh`/`FAB`/탭 동작 유지.
|
||||
- 커밋은 web-ui 경로, 변경 파일만 `git add`. 커밋 trailer:
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
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(<StatusBadge status="청약중" />);
|
||||
expect(screen.getByText('청약중')).toBeInTheDocument();
|
||||
});
|
||||
it('미정의 status → "알 수 없음" 폴백', () => {
|
||||
render(<StatusBadge status={null} />);
|
||||
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 (
|
||||
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
|
||||
{status || '알 수 없음'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="sub">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
|
||||
실매물 · 안전마진 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="sub-header">
|
||||
<div>
|
||||
<p className="sub-kicker">Real Estate</p>
|
||||
<h1>청약 관리</h1>
|
||||
<p className="sub-desc">
|
||||
공공데이터 기반 청약 공고 자동 수집, 내 조건 매칭, 일정 관리를 한곳에서.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-tabs-bar">
|
||||
<div className="sub-tabs">
|
||||
{TABS.map((tab, i) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`sub-tab${activeTab === i ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveTab(i)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-body">
|
||||
{activeTab === 0 && <DashboardTab key={`dash-${refreshKey}`} />}
|
||||
{activeTab === 1 && <AnnouncementsTab key={`ann-${refreshKey}`} />}
|
||||
{activeTab === 2 && <MatchesTab key={`match-${refreshKey}`} />}
|
||||
{activeTab === 3 && <ProfileTab key={`prof-${refreshKey}`} />}
|
||||
</div>
|
||||
<FAB onClick={handleFABClick} label="공고 목록" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
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/<feature>/
|
||||
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||
├── <Feature>.css # 스타일 (shell에서 1회 import)
|
||||
├── components/ # 표현 단위(카드/탭/상세/리스트) 각 단일 책임
|
||||
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>Utils.test.js
|
||||
```
|
||||
|
||||
규칙: 파일당 단일 책임·권장 ≤300줄; 순수 로직은 `*Utils.js`로 추출 + 유닛 테스트; shell은 데이터 페칭/비즈니스 로직을 직접 갖지 않고 조합만; 컴포넌트는 props/훅 인터페이스로만 통신.
|
||||
```
|
||||
그리고 페이지 구조 표의 `/realestate` 행 설명에 "(모듈 분해: shell + components/8 + subscriptionUtils)" 취지 반영.
|
||||
|
||||
- [ ] **Step 2: README.md — 컨벤션 문단 추가** (프로젝트 통계/구조 근처):
|
||||
|
||||
```markdown
|
||||
### 기능 모듈 구조
|
||||
|
||||
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>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.
|
||||
Reference in New Issue
Block a user