diff --git a/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md b/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md new file mode 100644 index 0000000..6884c7c --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md @@ -0,0 +1,397 @@ +# 청약 타겟팅 프론트엔드 설계 — 자치구 5티어 + 알림 설정 + +> 대상: `web-ui/src/pages/subscription/` +> 백엔드 의존: 2026-04-28-realestate-targeting-enhancement-design.md (이미 배포됨) +> 후속 별도 스펙: Subscription.jsx 분할 리팩토링, 5축 progress bar, 추가 알림 채널 + +--- + +## 1. 목표 + +백엔드 청약 타겟팅 고도화로 추가된 3 프로필 필드(`preferred_districts`, `min_match_score`, `notify_enabled`)를 프론트 UI에 노출한다. 매칭 결과·공고 카드에는 자치구 + 5티어 뱃지를, 상세 모달에는 매칭 사유 텍스트를 추가해 사용자가 점수의 근거를 즉시 이해할 수 있게 한다. + +### 핵심 변경 + +- **ProfileTab**: 자치구 5티어 분류(드래그&드롭, PC 전용) + 임계값 슬라이더 + 알림 토글 +- **모바일**: 자치구 분류는 read-only — "PC에서 편집해주세요" 안내 +- **카드 표시**: AnnouncementCard / 매칭 카드에 district 뱃지 + 5티어 뱃지(reasons에서 derive) +- **상세 모달**: AnnouncementDetail에 "매칭 분석" 섹션 (점수 + reasons 텍스트 + 자격) + +### 변경하지 않는 것 + +- Subscription.jsx 자체 분할 — 본 스코프 외(별도 리팩토링) +- 백엔드 응답 형태 — 모든 필요 데이터는 이미 응답에 포함됨 +- 5축 점수 분해 시각화 — 백엔드 응답 변경 필요(별도) +- 알림 채널 추가 — 텔레그램 외 이메일/Slack은 별도 + +--- + +## 2. 컴포넌트 분할 + +### 2.1 신규 컴포넌트 2개 + +| 파일 | 책임 | 추정 크기 | +|------|------|----------| +| `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx` | 자치구 5티어 드래그&드롭 + 모바일 read-only | ~180줄 | +| `web-ui/src/pages/subscription/components/NotificationSettings.jsx` | 임계값 슬라이더 + 알림 토글 + 미리보기 | ~80줄 | + +ProfileTab(현재 343줄)에 그대로 추가하면 단일 함수가 거대화되어 가독성·유지보수가 떨어진다. 의미 단위로 분할. + +### 2.2 변경 받는 기존 컴포넌트 + +| 컴포넌트 (파일: Subscription.jsx) | 변경 | +|----|------| +| ProfileTab (956~1299줄) | 신규 컴포넌트 2개 import + 자치구 섹션 / 알림 설정 섹션 렌더 + handleSave에서 신규 3필드 송신 | +| AnnouncementCard (315~389줄) | district 뱃지 + 5티어 뱃지(`extractTier(reasons)`) | +| AnnouncementDetail (390~595줄) | "매칭 분석" 섹션 추가 (점수 + reasons + eligible_types) | +| MatchesTab (763~955줄) | 매치 카드에 district + 5티어 뱃지 + reasons 표시 | +| 모듈 상단 | `DEFAULT_PROFILE`에 신규 3필드 기본값 추가, `extractTier` 헬퍼 함수 | + +### 2.3 스타일 + +- `Subscription.css`: 5티어 뱃지 5 클래스(`.sub-chip--tier-S`~`D`), 드래그&드롭 hover/dragover, 슬라이더, 토글, district 뱃지 + +### 2.4 기각된 대안 + +| 대안 | 기각 사유 | +|------|-----------| +| 단일 파일에 모든 신규 UI | ProfileTab이 500줄+ 거대화, 디버깅 어려움 | +| Subscription.jsx 자체 분할 | 본 작업 스코프 외, 별도 리팩토링이 적절 | +| `react-dnd` 도입 | 의존성 +50KB, 모바일 어차피 사용 안 함. YAGNI | +| 5칼럼 체크박스 그리드 | 모바일/데스크톱 둘 다 무난하지만 드래그&드롭이 더 직관적이라 채택 안 함 | + +--- + +## 3. DistrictTierEditor 컴포넌트 + +### 3.1 인터페이스 + +```jsx + setProfile({...profile, preferred_districts: next})} +/> +``` + +`value`가 비어있거나 누락되면 빈 객체 fallback. `onChange`는 새 객체를 항상 한 번 호출(부모는 setState만 처리). + +### 3.2 상수 + +```jsx +const SEOUL_DISTRICTS = [ + "강남구","강동구","강북구","강서구","관악구", + "광진구","구로구","금천구","노원구","도봉구", + "동대문구","동작구","마포구","서대문구","서초구", + "성동구","성북구","송파구","양천구","영등포구", + "용산구","은평구","종로구","중구","중랑구", +]; + +const TIERS = [ + { key: "S", label: "S", weight: "100%" }, + { key: "A", label: "A", weight: "80%" }, + { key: "B", label: "B", weight: "60%" }, + { key: "C", label: "C", weight: "40%" }, + { key: "D", label: "D", weight: "20%" }, +]; + +const EMPTY_TIERS = { S:[], A:[], B:[], C:[], D:[] }; +``` + +### 3.3 데스크톱 레이아웃 (≥768px) + +``` +┌─ 자치구 우선순위 ─────────────────────────────────────────┐ +│ 미할당 (드래그해서 분류) │ +│ [강서구] [노원구] [도봉구] [중랑구] [관악구] ... │ +│ │ +│ ┌─ S 100% ─┐ ┌─ A 80% ─┐ ┌─ B 60% ─┐ ┌─ C 40% ─┐ ┌─ D 20% ─┐│ +│ │[강남구]× │ │[송파구]× │ │ │ │ │ │ ││ +│ │[서초구]× │ │[마포구]× │ │ │ │ │ │ ││ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘│ +└────────────────────────────────────────────────────────────┘ +``` + +- 5티어는 가로 5칼럼 그리드(`grid-template-columns: repeat(5, 1fr)`) +- 미할당 풀은 그리드 위, 가로 wrap +- 자치구 칩은 `` + `` (`×` 클릭 시 미할당으로 복귀) +- 각 티어 슬롯은 dropzone(`onDragOver` + `onDrop`) +- 미할당 풀도 dropzone(드래그해서 떨어뜨리면 해당 티어에서 제거) + +### 3.4 모바일 레이아웃 (<768px) — read-only + +``` +┌─ 자치구 우선순위 ──────────────┐ +│ S 100% 강남구, 서초구 │ +│ A 80% 송파구, 마포구 │ +│ B 60% (없음) │ +│ C 40% (없음) │ +│ D 20% (없음) │ +│ │ +│ ✏️ 자치구 분류는 PC에서 편집 │ +└──────────────────────────────────┘ +``` + +분기 로직: + +```jsx +const [isDesktop, setIsDesktop] = useState( + typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches +); +useEffect(() => { + const mq = window.matchMedia("(min-width: 768px)"); + const handler = (e) => setIsDesktop(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); +}, []); +``` + +`isDesktop=false`면 read-only 뷰만 렌더, 드래그 핸들러는 등록하지 않음. + +### 3.5 핵심 로직 + +```jsx +const handleDrop = (district, targetTier /* null = 미할당 */) => { + const current = value || EMPTY_TIERS; + const next = { ...EMPTY_TIERS }; + for (const t of Object.keys(EMPTY_TIERS)) { + next[t] = (current[t] || []).filter(d => d !== district); + } + if (targetTier) { + next[targetTier] = [...next[targetTier], district]; + } + onChange(next); +}; + +const unassigned = SEOUL_DISTRICTS.filter(d => + !TIERS.some(t => (value?.[t.key] || []).includes(d)) +); +``` + +`onChange`는 새 객체를 통째로 전달(immutable update). + +### 3.6 드래그&드롭 이벤트 (HTML5 native) + +| 이벤트 | 핸들러 | +|--------|--------| +| `onDragStart` (chip) | `e.dataTransfer.setData("district", districtName)` | +| `onDragOver` (zone) | `e.preventDefault()` (drop 허용) | +| `onDrop` (zone) | `e.preventDefault()` + `handleDrop(e.dataTransfer.getData("district"), tierKey)` | + +외부 라이브러리 없음. + +--- + +## 4. NotificationSettings 컴포넌트 + +### 4.1 인터페이스 + +```jsx + setProfile({...profile, ...patch})} +/> +``` + +`onChange` 호출 예시: 슬라이더 변경 시 `onChange({ min_match_score: 75 })`, 토글 시 `onChange({ notify_enabled: false })`. + +### 4.2 레이아웃 + +``` +┌─ 🔔 알림 설정 ────────────────────────────────┐ +│ 텔레그램 알림 ●━━━○ ON │ +│ 매칭 임계값 ▬▬▬▬▬▬●▬▬▬ 70점 │ +│ 0 50 100 │ +│ │ +│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│ +└────────────────────────────────────────────────┘ +``` + +### 4.3 컨트롤 + +- 토글: `` + 사용자 정의 CSS (Subscription.css에 `.sub-toggle` 신설) +- 슬라이더: `` + 우측 숫자 라벨 +- 미리보기: `notify_enabled === false` 일 때 경고 톤 메시지("알림 OFF — 메시지가 발송되지 않습니다") + +### 4.4 저장 동작 + +각 컨트롤 변경 시 `onChange`로 부모 state만 업데이트. 실제 PUT 요청은 ProfileTab 기존 "저장" 버튼이 일괄 처리(다른 모든 필드와 동일 패턴). + +### 4.5 카운트 미리보기 (스코프 외) + +"현재 임계값 통과 매치 N건" 같은 카운트 미리보기는 본 스펙에서 다루지 않는다. `dashboard.new_match_count`는 "미확인 매칭"이라 임계값 통과와 의미가 다르고, 정확한 카운트를 위해서는 백엔드에 `dashboard.pass_count` 필드 신설이 필요하다. 후속 스펙으로 분리. + +--- + +## 5. 카드 표시 변경 + +### 5.1 헬퍼 함수 (Subscription.jsx 모듈 상단) + +```jsx +function extractTier(reasons) { + for (const r of reasons || []) { + const m = r.match(/자치구 ([SABCD])티어/); + if (m) return m[1]; + } + return null; +} +``` + +- 백엔드 응답 변경 없이 reasons 배열에서 티어 도출 +- reasons 형식 예시: `"자치구 S티어: 강남구 (+25)"` (백엔드 matcher.py의 fmt와 일치) +- 광역만 매칭(legacy 모드)이면 티어 없음 → `null` + +### 5.2 AnnouncementCard + +기존 카드 제목/지역 라인 옆 또는 메타 라인에 추가: + +```jsx +{item.district && ( + {item.district} +)} +{(() => { + const tier = extractTier(item.match_reasons); + return tier ? ( + + {tier}티어 + + ) : null; +})()} +``` + +`item.match_reasons`는 매칭 결과가 있는 경우만 존재. 없으면 뱃지 미표시(공고 목록 탭에서 매칭 결과 없는 카드). + +### 5.3 AnnouncementDetail + +상세 모달 하단에 새 섹션: + +``` +┌─ 매칭 분석 ─────────────────────────────────┐ +│ ⭐ 점수: 90점 / 100점 │ +│ │ +│ 💡 매칭 사유 │ +│ • 광역 일치: 서울특별시 │ +│ • 자치구 S티어: 강남구 (+25) │ +│ • 예산 범위 내 모델 존재 (최고가 7.2억원) │ +│ • 자격 유형 2개: 일반1순위, 특별-신혼부부 │ +│ │ +│ ✓ 신청 자격 │ +│ [일반1순위] [특별-신혼부부] │ +└──────────────────────────────────────────────┘ +``` + +`item.match_score`, `item.match_reasons`, `item.eligible_types`는 이미 응답에 포함됨(get_unnotified_matches는 물론 get_matches/get_announcement도 enrich_items 거침). 매칭 결과가 없는 공고에는 이 섹션 자체를 렌더하지 않음(`item.match_score` 존재 여부로 분기). + +### 5.4 MatchesTab + +매치 카드는 이미 매칭 데이터를 받지만 district + 5티어 뱃지 표시가 부족할 가능성 높음. AnnouncementCard와 동일한 helper(`extractTier`)로 일관 표시. 카드 클릭 시 AnnouncementDetail 모달이 reasons 노출. + +### 5.5 5티어 뱃지 색상 (Subscription.css 신설) + +```css +.sub-chip--tier-S { background:#fee2e2; color:#dc2626; border-color:#fca5a5; } +.sub-chip--tier-A { background:#fef3c7; color:#d97706; border-color:#fcd34d; } +.sub-chip--tier-B { background:#d1fae5; color:#059669; border-color:#6ee7b7; } +.sub-chip--tier-C { background:#dbeafe; color:#2563eb; border-color:#93c5fd; } +.sub-chip--tier-D { background:#ede9fe; color:#7c3aed; border-color:#c4b5fd; } +.sub-chip--district { background:#f3f4f6; color:#374151; border-color:#d1d5db; } +``` + +--- + +## 6. ProfileTab 통합 + +### 6.1 DEFAULT_PROFILE 갱신 + +Subscription.jsx 모듈 상단의 `DEFAULT_PROFILE` 상수에 3 필드 default 추가: + +```jsx +const DEFAULT_PROFILE = { + // ... 기존 필드 + preferred_regions: '', + preferred_types: '', + min_area: '', + max_area: '', + max_price: '', + // 신규 + preferred_districts: {}, + min_match_score: 70, + notify_enabled: true, +}; +``` + +### 6.2 ProfileTab 렌더 추가 위치 + +자치구 섹션은 기존 "선호 조건" 섹션 다음, 알림 설정 섹션은 그 다음에 배치(저장 버튼 위): + +```jsx + handleChange("preferred_districts", next)} +/> + + setProfile(prev => ({ ...prev, ...patch }))} +/> +``` + +### 6.3 handleSave 변경 + +신규 3 필드는 변환 없이 그대로 PUT body에 포함: + +```jsx +// 기존 변환 로직 다음에 +payload.preferred_districts = profile.preferred_districts || {}; +payload.min_match_score = profile.min_match_score ?? null; +payload.notify_enabled = profile.notify_enabled ?? null; +``` + +JSON 형태(객체)는 백엔드 ProfileUpdate 모델에서 `Dict[str, List[str]]`로 받음(이미 구현됨). + +--- + +## 7. 테스트 전략 + +`web-ui` 레포는 단위 테스트 인프라가 빈약(컨벤션 확인 필요). 본 작업의 검증: + +| 영역 | 검증 방식 | +|------|-----------| +| 빌드 | `npm run build` warning/error 없음 | +| 데스크톱 자치구 편집 | 미할당 풀 → S 슬롯 드래그 → 저장 → 새로고침 → 유지 확인 | +| 자치구 티어 이동 | S → A로 드래그 → S에서 사라지고 A에 등장 | +| 자치구 해제 | × 버튼 또는 미할당 풀로 드래그 → 미할당 풀에 복귀 | +| 모바일 read-only | 개발자 도구 < 768px → 편집 영역 숨김 + 안내 메시지 표시 | +| 임계값 슬라이더 | 0→100 조절, 즉시 미리보기 텍스트 갱신, 저장·새로고침 후 유지 | +| 알림 토글 | OFF 시 경고 톤 안내 표시 | +| 매칭 카드 | district 뱃지 + 5티어 뱃지 표시 (해당 데이터 있는 경우) | +| 상세 모달 | 매칭 분석 섹션의 점수 + reasons + 자격 표시 | +| 회귀 | 기존 프로필 필드(나이/청약통장/특공 등) 입력·저장 정상 | + +`scripts/dev.bat` 또는 `cd web-ui && npm run dev`로 dev server 실행 후 브라우저에서 수동 검증. + +배포는 frontend 별도 절차: `cd web-ui && npm run release:nas` (NAS Z 드라이브에 robocopy). + +--- + +## 8. 스코프 + +### 본 스펙 범위 + +- ✅ DistrictTierEditor 신규 컴포넌트 +- ✅ NotificationSettings 신규 컴포넌트 +- ✅ ProfileTab 신규 3 필드 통합 + 저장 +- ✅ AnnouncementCard / MatchesTab district + 5티어 뱃지 +- ✅ AnnouncementDetail 매칭 분석 섹션 +- ✅ Subscription.css 5티어 뱃지 + 드래그 영역 + 토글 + 슬라이더 스타일 +- ✅ 모바일 read-only fallback + +### 후속 별도 스펙 + +- ❌ Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링) +- ❌ 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요) +- ❌ 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요) +- ❌ 자치구 5티어 자동 추천 (사용자 가점·예산 기반) +- ❌ 알림 채널 추가 (이메일/Slack) +- ❌ 모바일 자치구 편집 지원 (touch backend 필요 시)