docs(spec): 청약 타겟팅 프론트엔드 설계
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only - NotificationSettings: 임계값 슬라이더 + 알림 토글 - AnnouncementCard/MatchesTab: district + 5티어 뱃지 - AnnouncementDetail: 매칭 분석 섹션 (점수 + reasons + 자격) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
<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 필요 시)
|
||||
Reference in New Issue
Block a user