diff --git a/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md b/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md new file mode 100644 index 0000000..1b0bb7f --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md @@ -0,0 +1,971 @@ +# 청약 타겟팅 프론트엔드 구현 계획 + +> **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:** 백엔드에서 추가된 자치구 5티어 매칭 기능을 `web-ui`의 청약(Subscription) 페이지에 노출 — 프로필 편집 UI(드래그&드롭 + 슬라이더 + 토글) + 카드/상세에 district·5티어·reasons 표시. + +**Architecture:** `Subscription.jsx`(1354줄, 단일 파일)의 ProfileTab에 신규 컴포넌트 2개(`DistrictTierEditor`, `NotificationSettings`)를 추가하고, `AnnouncementCard`/`AnnouncementDetail`/`MatchesTab` 3 곳에 district + 5티어 뱃지 + reasons 표시를 추가한다. 백엔드 응답은 이미 모든 필요 데이터를 포함하므로 API 변경 없음. + +**Tech Stack:** React 18 + Vite + JavaScript / Native HTML5 drag-and-drop / `window.matchMedia` 분기 / ESLint / 단위 테스트 인프라 없음(빌드 + lint + 수동 시각 검증) + +**스펙 참조:** `web-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md` + +**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (web-backend와 별도 git repo. commit/push도 web-ui repo에서 처리.) + +**검증 방식:** +- 단위 테스트 인프라 없음 → 각 task는 `npm run build` 통과 + `npm run lint` 통과로 1차 검증 +- 마지막 task에서 `npm run dev` + 브라우저로 수동 시각 검증 시나리오 일괄 실행 +- 백엔드는 NAS에 이미 배포됨(2a8635e..a508a56) → 실제 응답으로 동작 확인 가능 + +--- + +## Task 1: 모듈 상단 변경 — DEFAULT_PROFILE 확장 + extractTier 헬퍼 + +`Subscription.jsx` 모듈 상단에 신규 3 필드 default와 reasons → tier 추출 헬퍼를 추가. 이후 task들이 이 두 가지를 의존. + +**Files:** +- Modify: `web-ui/src/pages/subscription/Subscription.jsx` (모듈 상단 — `DEFAULT_PROFILE` 상수 + 새 헬퍼) + +- [ ] **Step 1: DEFAULT_PROFILE에 신규 3 필드 default 추가** + +`Subscription.jsx`에서 `DEFAULT_PROFILE` 상수 정의를 찾는다 (grep `DEFAULT_PROFILE =`). 끝부분에 3 필드 추가: + +```javascript +const DEFAULT_PROFILE = { + // ... 기존 필드 그대로 유지 + preferred_regions: '', + preferred_types: '', + min_area: '', + max_area: '', + max_price: '', + // 신규 (자치구 5티어 + 알림 설정) + preferred_districts: {}, + min_match_score: 70, + notify_enabled: true, +}; +``` + +(주의: 기존 마지막 필드 뒤에 콤마가 있는지 확인 후 일관성 유지) + +- [ ] **Step 2: `extractTier` 헬퍼 함수 추가** + +`DEFAULT_PROFILE` 정의 위(또는 fmt 헬퍼들 근처, 모듈 최상단 영역) 어딘가에 추가: + +```javascript +// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S") +function extractTier(reasons) { + for (const r of reasons || []) { + const m = r.match(/자치구 ([SABCD])티어/); + if (m) return m[1]; + } + return null; +} +``` + +- [ ] **Step 3: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors / 0 warnings. + +- [ ] **Step 4: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/Subscription.jsx +git commit -m "feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼" +``` + +--- + +## Task 2: DistrictTierEditor 컴포넌트 신규 + +자치구 5티어 분류 UI. 데스크톱 드래그&드롭 + 모바일 read-only. + +**Files:** +- Create: `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx` + +- [ ] **Step 1: 컴포넌트 파일 생성** + +```jsx +import { useEffect, useState } from "react"; + +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: [] }; + +function useIsDesktop() { + const [isDesktop, setIsDesktop] = useState( + typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches + ); + useEffect(() => { + if (typeof window === "undefined") return; + const mq = window.matchMedia("(min-width: 768px)"); + const handler = (e) => setIsDesktop(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + return isDesktop; +} + +export default function DistrictTierEditor({ value, onChange }) { + const isDesktop = useIsDesktop(); + const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key + + const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS; + + const unassigned = SEOUL_DISTRICTS.filter( + d => !TIERS.some(t => (current[t.key] || []).includes(d)) + ); + + const moveDistrict = (district, targetTier /* null = 미할당 */) => { + const next = { S: [], A: [], B: [], C: [], D: [] }; + for (const t of Object.keys(next)) { + next[t] = (current[t] || []).filter(d => d !== district); + } + if (targetTier) { + next[targetTier] = [...next[targetTier], district]; + } + onChange(next); + }; + + const onDragStart = (e, district) => { + e.dataTransfer.setData("text/district", district); + e.dataTransfer.effectAllowed = "move"; + }; + const onDragOver = (e, key) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOver !== key) setDragOver(key); + }; + const onDragLeave = () => setDragOver(null); + const onDrop = (e, targetTier /* null = 미할당 */) => { + e.preventDefault(); + const district = e.dataTransfer.getData("text/district"); + setDragOver(null); + if (district) moveDistrict(district, targetTier); + }; + + if (!isDesktop) { + return ( +
+
+

자치구 우선순위

+

지역 5티어

+
+
+ {TIERS.map(t => ( +
+ + {t.label} {t.weight} + + + {(current[t.key] || []).length === 0 + ? (없음) + : (current[t.key] || []).join(", ")} + +
+ ))} +

✏️ 자치구 분류는 PC에서 편집할 수 있어요

+
+
+ ); + } + + return ( +
+
+

자치구 우선순위

+

지역 5티어 (드래그해서 분류)

+
+
+ {/* 미할당 풀 */} +
onDragOver(e, "_unassigned")} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, null)} + > +

미할당 ({unassigned.length})

+
+ {unassigned.map(d => ( + onDragStart(e, d)} + className="sub-chip sub-chip--district dte-chip" + > + {d} + + ))} +
+
+ + {/* 5티어 그리드 */} +
+ {TIERS.map(t => ( +
onDragOver(e, t.key)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, t.key)} + > +
+ {t.label} {t.weight} +
+
+ {(current[t.key] || []).map(d => ( + onDragStart(e, d)} + className="sub-chip sub-chip--district dte-chip" + > + {d} + + + ))} +
+
+ ))} +
+
+
+ ); +} +``` + +- [ ] **Step 2: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors. 컴포넌트가 아직 사용되지 않으므로 dead code warning은 무시. + +- [ ] **Step 3: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/components/DistrictTierEditor.jsx +git commit -m "feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭" +``` + +--- + +## Task 3: NotificationSettings 컴포넌트 신규 + +임계값 슬라이더 + 알림 토글 + 미리보기. + +**Files:** +- Create: `web-ui/src/pages/subscription/components/NotificationSettings.jsx` + +- [ ] **Step 1: 컴포넌트 파일 생성** + +```jsx +export default function NotificationSettings({ minScore, notifyEnabled, onChange }) { + const score = minScore ?? 70; + const enabled = notifyEnabled ?? true; + + return ( +
+
+

알림 설정

+

🔔 텔레그램 알림

+
+
+ + + + +

+ {enabled + ? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.` + : "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."} +

+
+
+ ); +} +``` + +- [ ] **Step 2: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors. + +- [ ] **Step 3: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/components/NotificationSettings.jsx +git commit -m "feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글" +``` + +--- + +## Task 4: ProfileTab에 두 컴포넌트 통합 + handleSave 변경 + +신규 컴포넌트 2개를 ProfileTab에 import·렌더하고, handleSave가 신규 3 필드를 PUT body에 포함하도록 변경. + +**Files:** +- Modify: `web-ui/src/pages/subscription/Subscription.jsx` ProfileTab 함수 (956~1299줄 부근) + +- [ ] **Step 1: import 추가 (파일 상단의 다른 import들 근처)** + +```javascript +import DistrictTierEditor from "./components/DistrictTierEditor"; +import NotificationSettings from "./components/NotificationSettings"; +``` + +- [ ] **Step 2: handleSave 안 신규 3 필드 처리 추가** + +`handleSave` 함수 안에서 payload 변환 부분을 찾아 다음을 추가. 기존 `preferred_regions` / `preferred_types` 변환 직후에: + +```javascript +// 신규: 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; +``` + +- [ ] **Step 3: ProfileTab의 GET 응답 처리에 신규 3 필드 매핑 보강** + +`useEffect` 안의 `apiGet('/api/realestate/profile')` 응답 처리에서 `display = { ...DEFAULT_PROFILE, ...data }` 라인이 이미 있어 자동으로 spread 됨. 별도 수정 불필요. (백엔드가 항상 응답에 포함시키므로 fallback도 자연스러움.) + +확인 차원에서 `min_match_score`/`notify_enabled`/`preferred_districts`가 응답에 없을 경우 DEFAULT 값이 사용되는지 검증. + +- [ ] **Step 4: ProfileTab 렌더 — DistrictTierEditor / NotificationSettings 추가** + +`return ()` 안에서 기존 "선호 조건" 패널과 저장 버튼 사이에 두 컴포넌트 삽입. 정확한 위치는 기존 코드의 마지막 `
` (선호 조건 패널) 다음 + 저장 버튼 직전: + +```jsx +{/* 자치구 5티어 */} + setProfile(prev => ({ ...prev, preferred_districts: next }))} +/> + +{/* 알림 설정 */} + setProfile(prev => ({ ...prev, ...patch }))} +/> +``` + +- [ ] **Step 5: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors. + +- [ ] **Step 6: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/Subscription.jsx +git commit -m "feat(subscription): ProfileTab에 5티어/알림 설정 통합" +``` + +--- + +## Task 5: Subscription.css — 5티어 + 드래그영역 + 토글 + 슬라이더 + 매칭분석 스타일 + +신규 컴포넌트와 카드 표시 변경에 필요한 모든 CSS를 한 번에 추가. + +**Files:** +- Modify: `web-ui/src/pages/subscription/Subscription.css` (파일 끝에 신규 섹션 추가) + +- [ ] **Step 1: 5티어 + district 뱃지 색상** + +`Subscription.css` 파일 끝에 추가: + +```css +/* === 신규: 자치구 5티어 + district 뱃지 ============================== */ +.sub-chip--district { + background: #f3f4f6; + color: #374151; + border-color: #d1d5db; +} +.sub-chip--tier { + font-weight: 700; +} +.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; } +``` + +- [ ] **Step 2: DistrictTierEditor 드래그&드롭 영역** + +같은 파일에 이어서 추가: + +```css +/* === 신규: DistrictTierEditor ====================================== */ +.dte-pool { + border: 1px dashed var(--border-soft, #e5e7eb); + border-radius: 12px; + padding: 12px; + transition: background 0.15s, border-color 0.15s; +} +.dte-pool--over { + background: #f0f9ff; + border-color: #38bdf8; +} +.dte-pool__title { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.dte-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.dte-chip { + cursor: grab; + user-select: none; +} +.dte-chip:active { cursor: grabbing; } +.dte-chip__remove { + background: transparent; + border: 0; + color: inherit; + margin-left: 4px; + padding: 0 2px; + cursor: pointer; + font-size: 14px; + line-height: 1; + opacity: 0.6; +} +.dte-chip__remove:hover { opacity: 1; } + +.dte-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} +.dte-zone { + border: 1px solid var(--border-soft, #e5e7eb); + border-radius: 12px; + padding: 8px; + min-height: 120px; + transition: background 0.15s, border-color 0.15s; +} +.dte-zone--over { + background: #f0f9ff; + border-color: #38bdf8; +} +.dte-zone__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 6px; + font-weight: 700; + margin-bottom: 8px; +} +.dte-zone__weight { + font-size: 11px; + font-weight: 500; + opacity: 0.8; +} +.dte-zone__chips { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* 모바일 read-only 뷰 */ +.dte-row { + display: grid; + grid-template-columns: 80px 1fr; + align-items: center; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--border-soft, #e5e7eb); +} +.dte-row:last-of-type { border-bottom: 0; } +.dte-row__list { + color: var(--text, #1f2937); + font-size: 14px; +} +.dte-empty { + color: var(--text-muted, #6b7280); + font-style: italic; +} +.dte-mobile-hint { + margin: 4px 0 0; + color: var(--text-muted, #6b7280); + font-size: 13px; + text-align: center; +} +``` + +- [ ] **Step 3: NotificationSettings — 토글 + 슬라이더** + +같은 파일에 이어서 추가: + +```css +/* === 신규: NotificationSettings ==================================== */ +.ns-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.ns-row--column { + flex-direction: column; + align-items: stretch; +} +.ns-row__label { + font-weight: 600; + color: var(--text, #1f2937); +} +.ns-toggle { + display: inline-flex; + align-items: center; + gap: 8px; +} +.sub-toggle { + appearance: none; + width: 40px; + height: 22px; + background: #d1d5db; + border-radius: 11px; + position: relative; + cursor: pointer; + transition: background 0.2s; + margin: 0; +} +.sub-toggle::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.sub-toggle:checked { + background: #10b981; +} +.sub-toggle:checked::before { + transform: translateX(18px); +} +.sub-toggle__label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted, #6b7280); +} +.ns-slider { + width: 100%; + margin: 8px 0; +} +.ns-slider:disabled { + opacity: 0.5; +} +.ns-scale { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-muted, #6b7280); +} +.ns-hint { + margin: 0; + font-size: 13px; + color: var(--text-muted, #6b7280); + line-height: 1.5; +} +``` + +- [ ] **Step 4: 매칭 분석 섹션 + 모바일 그리드 fallback** + +같은 파일에 이어서 추가: + +```css +/* === 신규: 매칭 분석 섹션 ========================================== */ +.sub-match-analysis { + display: grid; + gap: 12px; + padding: 16px; + background: var(--surface-soft, #f9fafb); + border-radius: 12px; + margin-top: 16px; +} +.sub-match-analysis__score { + font-family: var(--font-display, system-ui); + font-size: 28px; + font-weight: 700; + color: var(--accent, #3b82f6); +} +.sub-match-analysis__reasons { + margin: 0; + padding-left: 18px; + color: var(--text, #1f2937); + font-size: 14px; + line-height: 1.7; +} +.sub-match-analysis__reasons li { + margin: 2px 0; +} +.sub-match-analysis__elig { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* 모바일 dte-grid → 1칼럼 */ +@media (max-width: 767px) { + .dte-grid { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 5: 빌드 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +``` + +Expected: build 성공 (CSS 추가는 lint 영향 없음). + +- [ ] **Step 6: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/Subscription.css +git commit -m "feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일" +``` + +--- + +## Task 6: AnnouncementCard에 district + 5티어 뱃지 + +매칭 결과 데이터가 있는 경우만 뱃지 표시. + +**Files:** +- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementCard 함수 (315~389줄 부근) + +- [ ] **Step 1: AnnouncementCard 안 메타 라인에 뱃지 추가** + +`AnnouncementCard` 컴포넌트의 JSX에서 단지명·지역 라인 직후 또는 메타 라인에 다음 뱃지를 추가: + +```jsx +{item.district && ( + {item.district} +)} +{(() => { + const tier = extractTier(item.match_reasons); + return tier ? ( + + {tier}티어 + + ) : null; +})()} +``` + +(`extractTier`는 Task 1에서 모듈 상단에 정의됨. JSX 안에서 직접 호출 가능.) + +정확한 삽입 위치: 카드 헤더의 region/area 라인 옆, 또는 status 뱃지 옆에 자연스럽게 배치. 기존 카드 구조에 맞춰 조정. + +- [ ] **Step 2: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors. + +- [ ] **Step 3: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/Subscription.jsx +git commit -m "feat(subscription): AnnouncementCard에 district + 5티어 뱃지" +``` + +--- + +## Task 7: AnnouncementDetail에 매칭 분석 섹션 + +매칭 결과가 있는 공고만 매칭 분석 섹션을 노출. + +**Files:** +- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementDetail 함수 (390~595줄 부근) + +- [ ] **Step 1: AnnouncementDetail 안 매칭 분석 섹션 추가** + +`AnnouncementDetail` 컴포넌트의 JSX 마지막 부분(다른 모든 섹션 다음)에 추가: + +```jsx +{item.match_score !== undefined && item.match_score !== null && ( +
+
+

매칭 분석

+ + ⭐ {item.match_score} / 100 + +
+ + {item.match_reasons && item.match_reasons.length > 0 && ( +
+

💡 매칭 사유

+
    + {item.match_reasons.map((r, idx) => ( +
  • {r}
  • + ))} +
+
+ )} + + {item.eligible_types && item.eligible_types.length > 0 && ( +
+

✓ 신청 자격

+
+ {item.eligible_types.map(t => ( + {t} + ))} +
+
+ )} +
+)} +``` + +- [ ] **Step 2: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors. + +- [ ] **Step 3: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/Subscription.jsx +git commit -m "feat(subscription): AnnouncementDetail에 매칭 분석 섹션" +``` + +--- + +## Task 8: MatchesTab 매치 카드에 district + 5티어 뱃지 + +`MatchesTab`은 별도의 매치 카드 마크업을 가지고 있을 가능성이 높다. `AnnouncementCard`와 동일한 helper(`extractTier`) + 뱃지 패턴을 적용. + +**Files:** +- Modify: `web-ui/src/pages/subscription/Subscription.jsx` MatchesTab 함수 (763~955줄 부근) + +- [ ] **Step 1: MatchesTab의 매치 카드 마크업을 찾아 district + 5티어 뱃지 삽입** + +`MatchesTab` 함수 안에서 매치 한 건당 렌더하는 영역(보통 `match.house_nm` / `match.region_name` 등을 표시하는 곳)을 찾는다. 거기에 다음 뱃지를 추가: + +```jsx +{match.district && ( + {match.district} +)} +{(() => { + const tier = extractTier(match.match_reasons); + return tier ? ( + + {tier}티어 + + ) : null; +})()} +``` + +(`match` 변수명은 실제 코드의 변수명에 맞춰 조정. 보통 `match`, `m`, 또는 `item`) + +- [ ] **Step 2: 빌드 + 린트 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: build 성공, lint 0 errors. + +- [ ] **Step 3: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add src/pages/subscription/Subscription.jsx +git commit -m "feat(subscription): MatchesTab 카드에 district + 5티어 뱃지" +``` + +--- + +## Task 9: CLAUDE.md 업데이트 + 수동 시각 검증 + +**Files:** +- Modify: `web-ui/CLAUDE.md` (페이지/엔드포인트 표 업데이트) + +- [ ] **Step 1: CLAUDE.md 업데이트** + +`web-ui/CLAUDE.md`를 열고: +1. Subscription 페이지 설명 섹션에 신규 기능 한 줄 추가: + ``` + - 프로필: 자치구 5티어 분류(드래그&드롭), 알림 임계값/토글 (백엔드 2026-04-28-realestate-targeting-enhancement-design 참조) + - 카드/상세: district + 5티어 뱃지 + 매칭 사유 텍스트 + ``` +2. API 엔드포인트 매핑 표에 `/api/realestate/profile` PUT body가 `preferred_districts` (object), `min_match_score` (int), `notify_enabled` (bool)을 받는다는 한 줄 추가. + +- [ ] **Step 2: 수동 시각 검증 (dev server)** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run dev +``` + +브라우저에서 `http://localhost:3007` 접속 후 청약 페이지(Subscription) 진입. + +검증 시나리오 (모두 통과해야 함): + +| # | 시나리오 | 기대 결과 | +|---|---------|----------| +| 1 | 데스크톱 뷰포트(>=768px) → 프로필 탭 → 자치구 영역 표시 | 미할당 풀 + 5티어 그리드(S/A/B/C/D) 노출, 25개 자치구가 미할당 풀에 보임 | +| 2 | "강남구"를 S 슬롯으로 드래그 | S 슬롯에 들어가고 미할당에서 사라짐 | +| 3 | "송파구"를 A 슬롯으로, "강남구"를 다시 S에서 A로 드래그 | A 슬롯에 둘 다, S는 비워짐 | +| 4 | A 슬롯의 "송파구" 칩의 × 버튼 클릭 | 미할당 풀로 복귀 | +| 5 | 알림 토글 OFF → 슬라이더 disabled, 안내 텍스트가 "알림 OFF" 톤 | +| 6 | 슬라이더 80으로 변경 → "80점 이상 매치 시…" 텍스트 즉시 갱신 | +| 7 | "저장" 버튼 클릭 → 새로고침 → 자치구/임계값/토글 값 유지 | +| 8 | 모바일 뷰포트(<768px) | 자치구 영역이 read-only 리스트로 변경, 편집 영역 숨김, "PC에서 편집해주세요" 안내 표시 | +| 9 | 공고 탭 → 매칭 결과 있는 공고 카드 | district 뱃지 + 5티어 뱃지 표시 (매칭 데이터 있는 경우만) | +| 10 | 공고 카드 클릭 → 상세 모달 | 매칭 분석 섹션에 점수 + reasons + 자격 표시 | +| 11 | 매칭 탭 → 카드들 | district + 5티어 뱃지 표시 | +| 12 | 회귀 — 기존 프로필 필드(나이/청약통장/특공) 입력·저장 | 정상 동작 | + +문제 발견 시 해당 task로 돌아가 수정. + +- [ ] **Step 3: 빌드 최종 검증** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run build +npm run lint +``` + +Expected: 에러·경고 없음. + +- [ ] **Step 4: 커밋** + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +git add CLAUDE.md +git commit -m "docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트" +``` + +--- + +## 완료 기준 + +- 9개 task 모두 commit 완료 +- `npm run build` warning/error 없이 통과 +- `npm run lint` 0 errors / 0 warnings +- 12개 수동 시각 검증 시나리오 모두 통과 +- 매칭 점수 70점 이상 + notify_enabled=true + 자치구 S 티어에 강남구 설정 시, 백엔드(이미 NAS에 배포됨)가 신규 매칭 발견 시 텔레그램 알림 송신 (end-to-end) + +--- + +## 배포 + +```bash +cd C:\Users\jaeoh\Desktop\workspace\web-ui +npm run release:nas +``` + +NAS에 robocopy로 빌드 산출물 업로드. NAS Z 드라이브 연결이 전제(`\\gahusb.synology.me\docker`). + +--- + +## 참고 — 후속 별도 plan + +- Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링) +- 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요) +- 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요) +- 자치구 5티어 자동 추천 (사용자 가점·예산 기반) +- 알림 채널 추가 (이메일/Slack) +- 모바일 자치구 편집 지원 (touch backend 도입 시)