Compare commits
9 Commits
feat/reale
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 795f1d9bab | |||
| 7f29931f93 | |||
| 6bcf4c96e4 | |||
| 4f5d7796c1 | |||
| 5eaca69f78 | |||
| d2dae3d042 | |||
| d82550610a | |||
| 15cb30311e | |||
| 1b4fba3c19 |
19
CLAUDE.md
19
CLAUDE.md
@@ -18,7 +18,7 @@
|
|||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
||||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)<br>• **(모듈 분해: shell(67줄) + components/10 + subscriptionUtils)** |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
@@ -243,6 +243,23 @@ npm run preview # 빌드 결과물 미리보기
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 기능 모듈 구조 (컨벤션)
|
||||||
|
|
||||||
|
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `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/훅 인터페이스로만 통신.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
||||||
|
|
||||||
### 파일 구조
|
### 파일 구조
|
||||||
|
|||||||
@@ -222,3 +222,9 @@ apiPost('/api/travel/sync');
|
|||||||
| 공통 컴포넌트 | 10개 |
|
| 공통 컴포넌트 | 10개 |
|
||||||
| API 헬퍼 함수 | 65+ |
|
| API 헬퍼 함수 | 65+ |
|
||||||
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 기능 모듈 구조
|
||||||
|
|
||||||
|
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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/<feature>/
|
||||||
|
├── <Feature>.jsx # 페이지 shell — 라우팅 진입, 탭바/헤더/레이아웃, 하위 조합만
|
||||||
|
├── <Feature>.css # 스타일 (shell에서 1회 import → 전역 클래스 하위 공유)
|
||||||
|
├── components/ # 표현 단위 (카드/탭/상세/리스트…) 각 단일 책임
|
||||||
|
├── hooks/ # 상태·API·부수효과 훅 (선택)
|
||||||
|
└── <feature>Utils.js # 순수 헬퍼(포맷/매핑/계산) + <feature>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.
|
||||||
File diff suppressed because it is too large
Load Diff
90
src/pages/subscription/components/AnnouncementCard.jsx
Normal file
90
src/pages/subscription/components/AnnouncementCard.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HOUSE_TYPE_LABELS, extractTier, fmt, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
|
||||||
|
const dday = getDDays(item.receipt_start);
|
||||||
|
const priceText = item.min_price != null
|
||||||
|
? (item.min_price === item.max_price_display
|
||||||
|
? fmtPrice(item.min_price)
|
||||||
|
: `${fmtPrice(item.min_price)} ~ ${fmtPrice(item.max_price_display)}`)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sub-card${isSelected ? ' is-selected' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="sub-card__top">
|
||||||
|
<div className="sub-card__badges">
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
|
||||||
|
<span className="sub-type-badge" style={{ color: '#60a5fa' }}>
|
||||||
|
{HOUSE_TYPE_LABELS[item.house_secd]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.match_score > 0 && (
|
||||||
|
<span className="sub-badge" style={{
|
||||||
|
color: item.match_score >= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
|
||||||
|
background: item.match_score >= 70 ? 'rgba(52,211,153,0.1)' : item.match_score >= 40 ? 'rgba(245,158,11,0.1)' : 'rgba(148,163,184,0.1)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{item.match_score}점
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(item.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||||
|
fontSize: 16, color: item.is_bookmarked ? '#f59e0b' : 'var(--text-dim)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
title={item.is_bookmarked ? '즐겨찾기 해제' : '즐겨찾기'}
|
||||||
|
>
|
||||||
|
{item.is_bookmarked ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h4 className="sub-card__name">{item.house_nm || '(이름 없음)'}</h4>
|
||||||
|
<p className="sub-card__address">{item.address || item.region_name || '-'}</p>
|
||||||
|
<div className="sub-card__info">
|
||||||
|
<span>{item.total_units ? `${item.total_units}세대` : '-'}</span>
|
||||||
|
<span className="sub-card__dot">·</span>
|
||||||
|
<span>{item.region_name || '-'}</span>
|
||||||
|
{priceText && (
|
||||||
|
<>
|
||||||
|
<span className="sub-card__dot">·</span>
|
||||||
|
<span style={{ color: '#f59e0b' }}>{priceText}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sub-card__bottom">
|
||||||
|
{item.receipt_start && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{fmt(item.receipt_start)} ~ {fmt(item.receipt_end)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dday && (
|
||||||
|
<span
|
||||||
|
className="sub-card__dday"
|
||||||
|
style={{ color: getDDayColor(item.receipt_start) }}
|
||||||
|
>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementCard;
|
||||||
279
src/pages/subscription/components/AnnouncementDetail.jsx
Normal file
279
src/pages/subscription/components/AnnouncementDetail.jsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { HOUSE_TYPE_LABELS, fmtFull, getDDays, getDDayColor, fmtPrice } from '../subscriptionUtils';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
function AnnouncementDetail({ item, onBookmark }) {
|
||||||
|
const [detailTab, setDetailTab] = useState('info');
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<div className="sub-detail sub-detail--empty">
|
||||||
|
<span className="sub-detail__empty-icon">🏠</span>
|
||||||
|
<span>공고를 선택하면 상세 정보가 표시됩니다.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-detail">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sub-detail__header">
|
||||||
|
<div>
|
||||||
|
<div className="sub-detail__badges">
|
||||||
|
<StatusBadge status={item.status} size="lg" />
|
||||||
|
{item.house_secd && HOUSE_TYPE_LABELS[item.house_secd] && (
|
||||||
|
<span className="sub-type-badge sub-type-badge--lg" style={{ color: '#60a5fa' }}>
|
||||||
|
{HOUSE_TYPE_LABELS[item.house_secd]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="sub-detail__name">{item.house_nm}</h3>
|
||||||
|
<p className="sub-detail__address">{item.address || item.region_name}</p>
|
||||||
|
{item.match_score > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 20, fontWeight: 700, fontFamily: 'var(--font-display)',
|
||||||
|
color: item.match_score >= 70 ? '#34d399' : item.match_score >= 40 ? '#f59e0b' : '#94a3b8',
|
||||||
|
}}>
|
||||||
|
매칭 {item.match_score}점
|
||||||
|
</span>
|
||||||
|
{item.eligible_types?.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{(Array.isArray(item.eligible_types) ? item.eligible_types : []).map((t, i) => (
|
||||||
|
<span key={i} className="sub-tag is-neutral" style={{ fontSize: 10 }}>{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sub-detail__actions">
|
||||||
|
<button
|
||||||
|
onClick={() => onBookmark?.(item.id)}
|
||||||
|
className="sub-filter-btn"
|
||||||
|
style={{
|
||||||
|
color: item.is_bookmarked ? '#f59e0b' : undefined,
|
||||||
|
borderColor: item.is_bookmarked ? '#f59e0b' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.is_bookmarked ? '★ 즐겨찾기 해제' : '☆ 즐겨찾기'}
|
||||||
|
</button>
|
||||||
|
{item.homepage_url && (
|
||||||
|
<a href={item.homepage_url} target="_blank" rel="noreferrer"
|
||||||
|
className="sub-filter-btn" style={{ textDecoration: 'none' }}>
|
||||||
|
홈페이지
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.pblanc_url && (
|
||||||
|
<a href={item.pblanc_url} target="_blank" rel="noreferrer"
|
||||||
|
className="sub-filter-btn" style={{ textDecoration: 'none' }}>
|
||||||
|
공고문
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Tabs */}
|
||||||
|
<div className="sub-section-tabs">
|
||||||
|
<button
|
||||||
|
className={`sub-section-tab${detailTab === 'info' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDetailTab('info')}
|
||||||
|
>
|
||||||
|
기본 정보
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`sub-section-tab${detailTab === 'schedule' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDetailTab('schedule')}
|
||||||
|
>
|
||||||
|
일정
|
||||||
|
</button>
|
||||||
|
{item.models?.length > 0 && (
|
||||||
|
<button
|
||||||
|
className={`sub-section-tab${detailTab === 'models' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDetailTab('models')}
|
||||||
|
>
|
||||||
|
주택형 ({item.models.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Content */}
|
||||||
|
<div className="sub-detail__section-content">
|
||||||
|
{detailTab === 'info' && (
|
||||||
|
<div className="sub-compare">
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">단지명</span>
|
||||||
|
<span className="sub-compare__mine" style={{ gridColumn: 'span 2' }}>{item.house_nm}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">지역</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.region_name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">주소</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.address || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">총 세대수</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.total_units ? `${item.total_units}세대` : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">시공사</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.constructor || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">시행사</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.developer || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sub-compare__row">
|
||||||
|
<span className="sub-compare__label">입주 예정</span>
|
||||||
|
<span style={{ gridColumn: 'span 2' }}>{item.move_in_month || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'schedule' && (
|
||||||
|
<div className="sub-schedule-mini">
|
||||||
|
{[
|
||||||
|
{ label: '특별공급 접수', start: item.spsply_start, end: item.spsply_end },
|
||||||
|
{ label: '1순위 접수', start: item.gnrl_rank1_start, end: item.gnrl_rank1_end },
|
||||||
|
{ label: '일반공급 접수', start: item.receipt_start, end: item.receipt_end },
|
||||||
|
{ label: '당첨자 발표', start: item.winner_date },
|
||||||
|
{ label: '계약', start: item.contract_start, end: item.contract_end },
|
||||||
|
].filter(s => s.start).map((s, i) => {
|
||||||
|
const dday = getDDays(s.start);
|
||||||
|
return (
|
||||||
|
<div className="sub-schedule-mini__item" key={i}>
|
||||||
|
<div className="sub-schedule-mini__dot" style={{ background: getDDayColor(s.start) }} />
|
||||||
|
<div className="sub-schedule-mini__content">
|
||||||
|
<span className="sub-schedule-mini__label">{s.label}</span>
|
||||||
|
<span className="sub-schedule-mini__date">
|
||||||
|
{fmtFull(s.start)}{s.end ? ` ~ ${fmtFull(s.end)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dday && (
|
||||||
|
<span className="sub-schedule-mini__dday" style={{ color: getDDayColor(s.start) }}>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{![item.spsply_start, item.gnrl_rank1_start, item.receipt_start, item.winner_date, item.contract_start].some(Boolean) && (
|
||||||
|
<p className="sub-empty-sm">일정 정보가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTab === 'models' && item.models?.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{item.models.map((m, i) => {
|
||||||
|
const totalUnits = (m.general_units || 0) + (m.special_units || 0);
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-bright)' }}>
|
||||||
|
{m.house_ty || `주택형 ${i + 1}`}
|
||||||
|
</span>
|
||||||
|
{totalUnits > 0 && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
|
||||||
|
{totalUnits}세대
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.supply_area && (
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>공급면적 {m.supply_area}m²</span>
|
||||||
|
)}
|
||||||
|
{m.top_amount != null && (
|
||||||
|
<span style={{ color: '#f59e0b', fontWeight: 600 }}>
|
||||||
|
분양가 {fmtPrice(m.top_amount)}원
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.match_score !== undefined && item.match_score !== null && (
|
||||||
|
<div className="sub-match-analysis">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||||
|
<span className="sub-match-analysis__score">
|
||||||
|
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.score_breakdown && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
|
||||||
|
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
|
||||||
|
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
|
||||||
|
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
|
||||||
|
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
|
||||||
|
].map(({ key, label, max, color }) => {
|
||||||
|
const v = item.score_breakdown[key] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ display: 'grid', gap: 3 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 700, color }}>{v}</span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 3, background: color,
|
||||||
|
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
|
||||||
|
<ul className="sub-match-analysis__reasons">
|
||||||
|
{item.match_reasons.map((r, idx) => (
|
||||||
|
<li key={idx}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||||
|
<div className="sub-match-analysis__elig">
|
||||||
|
{item.eligible_types.map(t => (
|
||||||
|
<span key={t} className="sub-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementDetail;
|
||||||
221
src/pages/subscription/components/AnnouncementsTab.jsx
Normal file
221
src/pages/subscription/components/AnnouncementsTab.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="sub-tabs-bar">
|
||||||
|
<div className="sub-filter">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={`sub-filter-btn${statusFilter === f ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setStatusFilter(f); setPage(1); }}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className={`sub-filter-btn${bookmarkFilter ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setBookmarkFilter(v => !v); setPage(1); }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
★ 즐겨찾기
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
placeholder="지역 검색..."
|
||||||
|
value={regionFilter}
|
||||||
|
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
|
||||||
|
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`sub-filter-btn${viewMode === 'calendar' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setViewMode(v => v === 'calendar' ? 'list' : 'calendar'); setPage(1); setCalendarDay(null); }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
title="캘린더 뷰 전환"
|
||||||
|
>
|
||||||
|
📅 캘린더
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn"
|
||||||
|
onClick={handleDeleteClosed}
|
||||||
|
style={{ fontSize: 12, color: '#f87171' }}
|
||||||
|
title="status='완료' 공고 일괄 삭제"
|
||||||
|
>
|
||||||
|
🗑 종료 청약 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="sub-empty">불러오는 중...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="sub-empty">조건에 맞는 공고가 없습니다.</div>
|
||||||
|
) : viewMode === 'calendar' ? (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
<CalendarView
|
||||||
|
items={items}
|
||||||
|
onDaySelect={(dayItems, label) => setCalendarDay({ items: dayItems, label })}
|
||||||
|
/>
|
||||||
|
{calendarDay && (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">{calendarDay.label}</p>
|
||||||
|
<h3>공고 {calendarDay.items.length}건</h3>
|
||||||
|
</div>
|
||||||
|
<button className="sub-filter-btn" onClick={() => setCalendarDay(null)} style={{ fontSize: 11 }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{calendarDay.items.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="sub-card"
|
||||||
|
style={{ cursor: 'pointer', padding: '10px 14px' }}
|
||||||
|
onClick={() => { setViewMode('list'); handleSelect(item); }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>{item.house_nm}</span>
|
||||||
|
<span className="sub-badge" style={{ background: STATUS_CONFIG[item.status]?.bg, color: STATUS_CONFIG[item.status]?.color, flexShrink: 0 }}>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>{item.region_name} · 접수 {item.receipt_start}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sub-list-layout">
|
||||||
|
{/* Card Grid */}
|
||||||
|
<div>
|
||||||
|
<div className="sub-card-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<AnnouncementCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={selected === item.id}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
onBookmark={handleBookmark}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
>
|
||||||
|
‹ 이전
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-dim)', padding: '4px 8px' }}>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
다음 ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Panel */}
|
||||||
|
<div className="sub-detail-panel">
|
||||||
|
<AnnouncementDetail item={detail} onBookmark={handleBookmark} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementsTab;
|
||||||
70
src/pages/subscription/components/CalendarView.jsx
Normal file
70
src/pages/subscription/components/CalendarView.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { STATUS_CONFIG } from '../subscriptionUtils';
|
||||||
|
|
||||||
|
function CalendarView({ items, onDaySelect }) {
|
||||||
|
const [cur, setCur] = useState(() => {
|
||||||
|
const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const year = cur.getFullYear(), month = cur.getMonth();
|
||||||
|
|
||||||
|
const dateMap = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start;
|
||||||
|
if (!raw || raw.length < 8) continue;
|
||||||
|
const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
|
||||||
|
(map[key] = map[key] || []).push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const firstDow = new Date(year, month, 1).getDay();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < firstDow; i++) cells.push(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
|
||||||
|
const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-calendar">
|
||||||
|
<div className="sub-calendar__header">
|
||||||
|
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month-1, 1))}>‹</button>
|
||||||
|
<span>{year}년 {month+1}월</span>
|
||||||
|
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month+1, 1))}>›</button>
|
||||||
|
</div>
|
||||||
|
<div className="sub-calendar__weekdays">
|
||||||
|
{['일','월','화','수','목','금','토'].map(w => (
|
||||||
|
<div key={w} className="sub-calendar__weekday">{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sub-calendar__grid">
|
||||||
|
{cells.map((d, i) => {
|
||||||
|
const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null;
|
||||||
|
const dayItems = key ? (dateMap[key] || []) : [];
|
||||||
|
const isToday = key === todayKey;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`sub-calendar__day${!d ? ' is-empty' : ''}${isToday ? ' is-today' : ''}${dayItems.length > 0 ? ' has-items' : ''}`}
|
||||||
|
onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}년 ${month+1}월 ${d}일`)}
|
||||||
|
>
|
||||||
|
{d && <span className="sub-calendar__day-num">{d}</span>}
|
||||||
|
{dayItems.length > 0 && (
|
||||||
|
<div className="sub-calendar__dots">
|
||||||
|
{dayItems.slice(0, 3).map((it, j) => (
|
||||||
|
<span key={j} className="sub-calendar__dot" style={{ background: STATUS_CONFIG[it.status]?.color || '#888' }} />
|
||||||
|
))}
|
||||||
|
{dayItems.length > 3 && <span className="sub-calendar__more">+{dayItems.length - 3}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarView;
|
||||||
206
src/pages/subscription/components/DashboardTab.jsx
Normal file
206
src/pages/subscription/components/DashboardTab.jsx
Normal file
@@ -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 <div className="sub-empty">불러오는 중...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 20 }}>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="sub-stats-bar">
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value">{dashboard?.active_count ?? 0}</p>
|
||||||
|
<p className="sub-stat-item__label">진행중 공고</p>
|
||||||
|
</div>
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value" style={{ color: dashboard?.new_match_count > 0 ? '#f43f5e' : undefined }}>
|
||||||
|
{dashboard?.new_match_count ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="sub-stat-item__label">신규 매칭</p>
|
||||||
|
</div>
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value" style={{ color: dashboard?.bookmarked_count > 0 ? '#f59e0b' : undefined }}>
|
||||||
|
{dashboard?.bookmarked_count ?? 0}
|
||||||
|
</p>
|
||||||
|
<p className="sub-stat-item__label">즐겨찾기</p>
|
||||||
|
</div>
|
||||||
|
<div className="sub-stat-item">
|
||||||
|
<p className="sub-stat-item__value">{totalCount}</p>
|
||||||
|
<p className="sub-stat-item__label">전체 공고</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collection Status */}
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">데이터 수집</p>
|
||||||
|
<h3>공공데이터 수집 현황</h3>
|
||||||
|
{collectStatus && (
|
||||||
|
<p className="sub-panel__sub">
|
||||||
|
마지막 수집: {fmtDateTime(collectStatus.collected_at)}
|
||||||
|
{collectStatus.new_count != null && ` · 신규 ${collectStatus.new_count}건`}
|
||||||
|
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
|
||||||
|
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!collectStatus && <p className="sub-panel__sub">수집 이력이 없습니다.</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn is-active"
|
||||||
|
onClick={handleCollect}
|
||||||
|
disabled={collecting}
|
||||||
|
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{collecting ? '수집 중...' : '수집 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Schedules */}
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">일정</p>
|
||||||
|
<h3>다가오는 일정</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body">
|
||||||
|
{dashboard?.upcoming_schedules?.length > 0 ? (
|
||||||
|
<div className="sub-schedule-mini">
|
||||||
|
{dashboard.upcoming_schedules.map((s, i) => {
|
||||||
|
const dday = getDDays(s.date);
|
||||||
|
return (
|
||||||
|
<div className="sub-schedule-mini__item" key={i}>
|
||||||
|
<div
|
||||||
|
className="sub-schedule-mini__dot"
|
||||||
|
style={{ background: getDDayColor(s.date) }}
|
||||||
|
/>
|
||||||
|
<div className="sub-schedule-mini__content">
|
||||||
|
<span className="sub-schedule-mini__label">
|
||||||
|
{s.house_nm || s.label || '공고'}
|
||||||
|
</span>
|
||||||
|
<span className="sub-schedule-mini__date">
|
||||||
|
{fmtFull(s.date)} · {s.event || s.type || '일정'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dday && (
|
||||||
|
<span
|
||||||
|
className="sub-schedule-mini__dday"
|
||||||
|
style={{ color: getDDayColor(s.date) }}
|
||||||
|
>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="sub-empty-sm">다가오는 일정이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookmarked */}
|
||||||
|
{dashboard?.bookmarked?.length > 0 && (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">즐겨찾기</p>
|
||||||
|
<h3>관심 공고</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body">
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{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 (
|
||||||
|
<div key={item.id} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '10px 14px', borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--line)', background: 'var(--surface)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gap: 2 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: '#f59e0b', fontSize: 14 }}>★</span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>
|
||||||
|
{item.house_nm}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{item.region_name || '-'}
|
||||||
|
{priceText && <> · <span style={{ color: '#f59e0b' }}>{priceText}</span></>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dday && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: getDDayColor(item.receipt_start) }}>
|
||||||
|
{dday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardTab;
|
||||||
228
src/pages/subscription/components/MatchesTab.jsx
Normal file
228
src/pages/subscription/components/MatchesTab.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
<div className="sub-tabs-bar">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-dim)' }}>
|
||||||
|
총 {total}건의 매칭 결과
|
||||||
|
</p>
|
||||||
|
{myPoints && (
|
||||||
|
<span className="sub-badge" style={{
|
||||||
|
color: myPoints.total >= 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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn is-active"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{refreshing ? '재계산 중...' : '재계산'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="sub-empty">불러오는 중...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="sub-empty">
|
||||||
|
매칭 결과가 없습니다. 프로필을 설정하고 재계산을 실행해 보세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{items.map((match) => (
|
||||||
|
<div
|
||||||
|
key={match.id}
|
||||||
|
className="sub-card"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '1fr auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderLeft: match.is_new ? '3px solid #f43f5e' : undefined,
|
||||||
|
cursor: match.is_new ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => match.is_new && handleMarkRead(match.id)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h4 className="sub-card__name" style={{ margin: 0 }}>
|
||||||
|
{match.house_nm || `공고 #${match.announcement_id}`}
|
||||||
|
</h4>
|
||||||
|
{match.is_new && (
|
||||||
|
<span className="sub-badge" style={{ color: '#f43f5e', background: 'rgba(244,63,94,0.1)' }}>
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
||||||
|
{match.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(match.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<p className="sub-card__address" style={{ margin: 0 }}>
|
||||||
|
{match.region_name || '-'}
|
||||||
|
{match.receipt_start && (
|
||||||
|
<span style={{ marginLeft: 8 }}>
|
||||||
|
{fmt(match.receipt_start)} ~ {fmt(match.receipt_end)}
|
||||||
|
{(() => {
|
||||||
|
const dd = getDDays(match.receipt_start);
|
||||||
|
return dd ? <span style={{ marginLeft: 6, fontWeight: 600, color: getDDayColor(match.receipt_start) }}>{dd}</span> : null;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{match.eligible_types && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{(Array.isArray(match.eligible_types)
|
||||||
|
? match.eligible_types
|
||||||
|
: (() => { try { return JSON.parse(match.eligible_types); } catch { return []; } })()
|
||||||
|
).map((t, i) => (
|
||||||
|
<span key={i} className="sub-tag is-neutral" style={{ fontSize: 10 }}>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match.match_reasons?.length > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{(Array.isArray(match.match_reasons)
|
||||||
|
? match.match_reasons
|
||||||
|
: (() => { try { return JSON.parse(match.match_reasons); } catch { return []; } })()
|
||||||
|
).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match.score_breakdown && (
|
||||||
|
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: (match.match_score ?? 0) >= 70 ? '#34d399' : (match.match_score ?? 0) >= 40 ? '#f59e0b' : '#f87171',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{match.match_score ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
매칭 점수
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{myPoints && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, padding: '3px 8px', borderRadius: 4,
|
||||||
|
background: myPoints.total >= 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}점
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 8 }}>
|
||||||
|
<button className="sub-filter-btn" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
||||||
|
‹ 이전
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-dim)', padding: '4px 8px' }}>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button className="sub-filter-btn" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||||
|
다음 ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchesTab;
|
||||||
399
src/pages/subscription/components/ProfileTab.jsx
Normal file
399
src/pages/subscription/components/ProfileTab.jsx
Normal file
@@ -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 <div className="sub-empty">불러오는 중...</div>;
|
||||||
|
|
||||||
|
const pts = profile.subscription_points;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
{/* 가점 카드 */}
|
||||||
|
{pts && pts.total > 0 && (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">청약 가점</p>
|
||||||
|
<h3>내 가점 현황</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-display)', fontSize: 36, fontWeight: 700,
|
||||||
|
color: pts.total >= 60 ? '#34d399' : pts.total >= 40 ? '#f59e0b' : '#f87171',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{pts.total}<span style={{ fontSize: 16, color: 'var(--text-muted)', fontWeight: 400 }}> / {pts.max_total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{ label: '무주택기간', data: pts.homeless_duration, color: '#00d4ff' },
|
||||||
|
{ label: '부양가족 수', data: pts.dependents, color: '#8b5cf6' },
|
||||||
|
{ label: '청약통장 가입기간', data: pts.subscription_period, color: '#f59e0b' },
|
||||||
|
].map(({ label, data, color }) => (
|
||||||
|
<div key={label} style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 700, color }}>{data.score}</span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}> / {data.max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 3, background: color,
|
||||||
|
width: `${(data.score / data.max) * 100}%`, transition: 'width 0.3s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{data.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">프로필</p>
|
||||||
|
<h3>내 청약 프로필</h3>
|
||||||
|
<p className="sub-panel__sub">자격 조건과 선호 조건을 설정하면 공고 매칭에 활용됩니다. <span style={{ color: '#f43f5e', fontSize: 11 }}>* 필수 입력</span></p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{message && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: message.startsWith('저장 완료') ? '#34d399' : '#f87171',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="sub-filter-btn is-active"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로필 완성도 힌트 */}
|
||||||
|
{(() => {
|
||||||
|
const missing = [];
|
||||||
|
if (!profile.income_level) missing.push('소득 수준');
|
||||||
|
if (!profile.min_area || !profile.max_area) missing.push('희망 면적');
|
||||||
|
if (!profile.max_price) missing.push('최대 예산');
|
||||||
|
const hasDistricts = profile.preferred_districts &&
|
||||||
|
Object.values(profile.preferred_districts).some(arr => arr?.length > 0);
|
||||||
|
if (!hasDistricts) missing.push('자치구 티어');
|
||||||
|
if (missing.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="sub-profile-hint">
|
||||||
|
<span className="sub-profile-hint__icon">💡</span>
|
||||||
|
<span>
|
||||||
|
<strong>매칭 정확도 개선 가능</strong> — {missing.join(', ')} 입력 시 더 정확한 점수를 산출합니다.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="sub-modal__form">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<p className="sub-form-section__title">기본 정보</p>
|
||||||
|
<div className="sub-form-row">
|
||||||
|
<label className="sub-form-label">
|
||||||
|
이름
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
value={profile.name || ''}
|
||||||
|
onChange={e => handleChange('name', e.target.value)}
|
||||||
|
placeholder="이름"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
나이
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.age || ''}
|
||||||
|
onChange={e => handleChange('age', e.target.value)}
|
||||||
|
placeholder="만 나이"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자격 조건 */}
|
||||||
|
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<p className="sub-form-section__title">자격 조건</p>
|
||||||
|
|
||||||
|
<div className="sub-form-checks">
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_homeless} onChange={() => handleCheckbox('is_homeless')} />
|
||||||
|
무주택자 *
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_householder} onChange={() => handleCheckbox('is_householder')} />
|
||||||
|
세대주 *
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.has_dependents} onChange={() => handleCheckbox('has_dependents')} />
|
||||||
|
부양가족 있음
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_newlywed} onChange={() => handleCheckbox('is_newlywed')} />
|
||||||
|
신혼부부
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.has_newborn} onChange={() => handleCheckbox('has_newborn')} />
|
||||||
|
출산/입양
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-check">
|
||||||
|
<input type="checkbox" checked={!!profile.is_first_home} onChange={() => handleCheckbox('is_first_home')} />
|
||||||
|
생애최초
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
청약 납입 기간 (개월) *
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.subscription_months || ''}
|
||||||
|
onChange={e => handleChange('subscription_months', e.target.value)}
|
||||||
|
placeholder="예: 84"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
청약 납입 금액 (만원)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.subscription_amount || ''}
|
||||||
|
onChange={e => handleChange('subscription_amount', e.target.value)}
|
||||||
|
placeholder="예: 1500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
가족 수 *
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.family_members || ''}
|
||||||
|
onChange={e => handleChange('family_members', e.target.value)}
|
||||||
|
placeholder="본인 포함"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
자녀 수
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.children_count || ''}
|
||||||
|
onChange={e => handleChange('children_count', e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
혼인 기간 (개월)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.marriage_months || ''}
|
||||||
|
onChange={e => handleChange('marriage_months', e.target.value)}
|
||||||
|
placeholder="미혼이면 비워두세요"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
소득 수준 (%)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="300"
|
||||||
|
value={profile.income_level || ''}
|
||||||
|
onChange={e => handleChange('income_level', e.target.value)}
|
||||||
|
placeholder="도시근로자 월평균 대비 %"
|
||||||
|
/>
|
||||||
|
<span className="sub-form-hint">청년 ≤140 / 신혼·생애최초 ≤160 / 신생아 ≤200 · 미입력 시 검증 생략</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선호 조건 */}
|
||||||
|
<div className="sub-form-section">
|
||||||
|
<p className="sub-form-section__title">선호 조건</p>
|
||||||
|
|
||||||
|
<div className="sub-form-row">
|
||||||
|
<label className="sub-form-label">
|
||||||
|
선호 지역 *
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
value={profile.preferred_regions || ''}
|
||||||
|
onChange={e => handleChange('preferred_regions', e.target.value)}
|
||||||
|
placeholder="예: 서울, 경기"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
선호 주택유형
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
value={profile.preferred_types || ''}
|
||||||
|
onChange={e => handleChange('preferred_types', e.target.value)}
|
||||||
|
placeholder="예: 국민주택, 민영주택"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sub-form-row--three" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
최소 면적 (m²)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.min_area || ''}
|
||||||
|
onChange={e => handleChange('min_area', e.target.value)}
|
||||||
|
placeholder="예: 59"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
최대 면적 (m²)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.max_area || ''}
|
||||||
|
onChange={e => handleChange('max_area', e.target.value)}
|
||||||
|
placeholder="예: 84"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sub-form-label">
|
||||||
|
최대 분양가 (만원)
|
||||||
|
<input
|
||||||
|
className="sub-form-input"
|
||||||
|
type="number"
|
||||||
|
value={profile.max_price || ''}
|
||||||
|
onChange={e => handleChange('max_price', e.target.value)}
|
||||||
|
placeholder="예: 80000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자치구 5티어 */}
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={profile.preferred_districts}
|
||||||
|
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 알림 설정 */}
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score ?? 70}
|
||||||
|
notifyEnabled={profile.notify_enabled ?? true}
|
||||||
|
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||||
|
passCount={passCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileTab;
|
||||||
14
src/pages/subscription/components/StatusBadge.jsx
Normal file
14
src/pages/subscription/components/StatusBadge.jsx
Normal file
@@ -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 (
|
||||||
|
<span className={cls} style={{ color: cfg.color, background: cfg.bg }}>
|
||||||
|
{status || '알 수 없음'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
14
src/pages/subscription/components/StatusBadge.test.jsx
Normal file
14
src/pages/subscription/components/StatusBadge.test.jsx
Normal file
@@ -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(<StatusBadge status="청약중" />);
|
||||||
|
expect(screen.getByText('청약중')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('미정의 status → "알 수 없음" 폴백', () => {
|
||||||
|
render(<StatusBadge status={null} />);
|
||||||
|
expect(screen.getByText('알 수 없음')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/pages/subscription/subscriptionUtils.js
Normal file
105
src/pages/subscription/subscriptionUtils.js
Normal file
@@ -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()}만`;
|
||||||
|
};
|
||||||
42
src/pages/subscription/subscriptionUtils.test.js
Normal file
42
src/pages/subscription/subscriptionUtils.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user