Compare commits
2 Commits
a508a5633a
...
71f52e4d59
| Author | SHA1 | Date | |
|---|---|---|---|
| 71f52e4d59 | |||
| 756f280bbc |
@@ -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 (
|
||||
<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 도입 시)
|
||||
@@ -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