Files
web-page-backend/docs/superpowers/plans/2026-04-28-realestate-frontend-targeting.md
gahusb 71f52e4d59 docs(plan): 청약 타겟팅 프론트엔드 구현 계획
9 task TDD 분할 (단위 테스트 인프라 없음, 빌드+린트+수동 시각 검증):
- Task 1: DEFAULT_PROFILE 확장 + extractTier 헬퍼
- Task 2: DistrictTierEditor (드래그&드롭 + 모바일 read-only)
- Task 3: NotificationSettings (슬라이더 + 토글)
- Task 4: ProfileTab 통합 + handleSave
- Task 5: Subscription.css (5티어 + 드래그 영역 + 토글 + 슬라이더)
- Task 6-8: AnnouncementCard / AnnouncementDetail / MatchesTab district + 5티어 뱃지
- Task 9: CLAUDE.md + 수동 시각 검증 12 시나리오

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

972 lines
31 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# 청약 타겟팅 프론트엔드 구현 계획
> **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 (
<div className="sub-panel">
<div className="sub-panel__head">
<p className="sub-panel__eyebrow">자치구 우선순위</p>
<h3>지역 5티어</h3>
</div>
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
{TIERS.map(t => (
<div key={t.key} className="dte-row dte-row--readonly">
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
{t.label} {t.weight}
</span>
<span className="dte-row__list">
{(current[t.key] || []).length === 0
? <span className="dte-empty">(없음)</span>
: (current[t.key] || []).join(", ")}
</span>
</div>
))}
<p className="dte-mobile-hint"> 자치구 분류는 PC에서 편집할 있어요</p>
</div>
</div>
);
}
return (
<div className="sub-panel">
<div className="sub-panel__head">
<p className="sub-panel__eyebrow">자치구 우선순위</p>
<h3>지역 5티어 (드래그해서 분류)</h3>
</div>
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
{/* 미할당 풀 */}
<div
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
onDragOver={(e) => onDragOver(e, "_unassigned")}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, null)}
>
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
<div className="dte-chips">
{unassigned.map(d => (
<span
key={d}
draggable
onDragStart={(e) => onDragStart(e, d)}
className="sub-chip sub-chip--district dte-chip"
>
{d}
</span>
))}
</div>
</div>
{/* 5티어 그리드 */}
<div className="dte-grid">
{TIERS.map(t => (
<div
key={t.key}
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
onDragOver={(e) => onDragOver(e, t.key)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, t.key)}
>
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
{t.label} <span className="dte-zone__weight">{t.weight}</span>
</div>
<div className="dte-zone__chips">
{(current[t.key] || []).map(d => (
<span
key={d}
draggable
onDragStart={(e) => onDragStart(e, d)}
className="sub-chip sub-chip--district dte-chip"
>
{d}
<button
type="button"
className="dte-chip__remove"
onClick={() => moveDistrict(d, null)}
aria-label={`${d} 미할당으로`}
>
×
</button>
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
```
- [ ] **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 (
<div className="sub-panel">
<div className="sub-panel__head">
<p className="sub-panel__eyebrow">알림 설정</p>
<h3>🔔 텔레그램 알림</h3>
</div>
<div className="sub-panel__body" style={{ display: "grid", gap: 16 }}>
<label className="ns-row">
<span className="ns-row__label">텔레그램 알림</span>
<span className="ns-toggle">
<input
type="checkbox"
className="sub-toggle"
checked={enabled}
onChange={(e) => onChange({ notify_enabled: e.target.checked })}
/>
<span className="sub-toggle__label">{enabled ? "ON" : "OFF"}</span>
</span>
</label>
<label className="ns-row ns-row--column">
<span className="ns-row__label">매칭 임계값 {score}</span>
<input
type="range"
min="0"
max="100"
step="5"
value={score}
onChange={(e) => onChange({ min_match_score: Number(e.target.value) })}
className="ns-slider"
disabled={!enabled}
/>
<div className="ns-scale">
<span>0</span>
<span>50</span>
<span>100</span>
</div>
</label>
<p className="ns-hint">
{enabled
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
</p>
</div>
</div>
);
}
```
- [ ] **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 ()` 안에서 기존 "선호 조건" 패널과 저장 버튼 사이에 두 컴포넌트 삽입. 정확한 위치는 기존 코드의 마지막 `<div className="sub-panel">` (선호 조건 패널) 다음 + 저장 버튼 직전:
```jsx
{/* 자치구 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 }))}
/>
```
- [ ] **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 && (
<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;
})()}
```
(`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 && (
<div className="sub-match-analysis">
<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>
{item.match_reasons && item.match_reasons.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</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>
)}
```
- [ ] **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 && (
<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;
})()}
```
(`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 도입 시)