Files
web-page-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md
gahusb 756f280bbc docs(spec): 청약 타겟팅 프론트엔드 설계
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only
- NotificationSettings: 임계값 슬라이더 + 알림 토글
- AnnouncementCard/MatchesTab: district + 5티어 뱃지
- AnnouncementDetail: 매칭 분석 섹션 (점수 + reasons + 자격)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:40:36 +09:00

398 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 청약 타겟팅 프론트엔드 설계 — 자치구 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
<DistrictTierEditor
value={preferredDistricts} // {"S":["강남구",...], "A":[...], "B":[...], "C":[...], "D":[...]}
onChange={(next) => 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
- 자치구 칩은 `<span draggable="true">` + `<button>×</button>` (`×` 클릭 시 미할당으로 복귀)
- 각 티어 슬롯은 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
<NotificationSettings
minScore={profile.min_match_score} // number 0~100
notifyEnabled={profile.notify_enabled} // bool
onChange={(patch) => setProfile({...profile, ...patch})}
/>
```
`onChange` 호출 예시: 슬라이더 변경 시 `onChange({ min_match_score: 75 })`, 토글 시 `onChange({ notify_enabled: false })`.
### 4.2 레이아웃
```
┌─ 🔔 알림 설정 ────────────────────────────────┐
│ 텔레그램 알림 ●━━━○ ON │
│ 매칭 임계값 ▬▬▬▬▬▬●▬▬▬ 70점 │
│ 0 50 100 │
│ │
│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│
└────────────────────────────────────────────────┘
```
### 4.3 컨트롤
- 토글: `<input type="checkbox" className="sub-toggle">` + 사용자 정의 CSS (Subscription.css에 `.sub-toggle` 신설)
- 슬라이더: `<input type="range" min="0" max="100" step="5">` + 우측 숫자 라벨
- 미리보기: `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 && (
<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;
})()}
```
`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
<DistrictTierEditor
value={profile.preferred_districts}
onChange={(next) => handleChange("preferred_districts", next)}
/>
<NotificationSettings
minScore={profile.min_match_score ?? 70}
notifyEnabled={profile.notify_enabled ?? true}
onChange={(patch) => 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 필요 시)