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

31 KiB
Raw Blame History

청약 타겟팅 프론트엔드 구현 계획

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 필드 추가:

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 헬퍼들 근처, 모듈 최상단 영역) 어딘가에 추가:

// 매칭 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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors / 0 warnings.

  • Step 4: 커밋
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: 컴포넌트 파일 생성

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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors. 컴포넌트가 아직 사용되지 않으므로 dead code warning은 무시.

  • Step 3: 커밋
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: 컴포넌트 파일 생성

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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors.

  • Step 3: 커밋
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들 근처)

import DistrictTierEditor from "./components/DistrictTierEditor";
import NotificationSettings from "./components/NotificationSettings";
  • Step 2: handleSave 안 신규 3 필드 처리 추가

handleSave 함수 안에서 payload 변환 부분을 찾아 다음을 추가. 기존 preferred_regions / preferred_types 변환 직후에:

// 신규: 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"> (선호 조건 패널) 다음 + 저장 버튼 직전:

{/* 자치구 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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors.

  • Step 6: 커밋
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 파일 끝에 추가:

/* === 신규: 자치구 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 드래그&드롭 영역

같은 파일에 이어서 추가:

/* === 신규: 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 — 토글 + 슬라이더

같은 파일에 이어서 추가:

/* === 신규: 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

같은 파일에 이어서 추가:

/* === 신규: 매칭 분석 섹션 ========================================== */
.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: 빌드 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build

Expected: build 성공 (CSS 추가는 lint 영향 없음).

  • Step 6: 커밋
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에서 단지명·지역 라인 직후 또는 메타 라인에 다음 뱃지를 추가:

{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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors.

  • Step 3: 커밋
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 마지막 부분(다른 모든 섹션 다음)에 추가:

{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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors.

  • Step 3: 커밋
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 등을 표시하는 곳)을 찾는다. 거기에 다음 뱃지를 추가:

{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: 빌드 + 린트 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: build 성공, lint 0 errors.

  • Step 3: 커밋
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)
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: 빌드 최종 검증
cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm run build
npm run lint

Expected: 에러·경고 없음.

  • Step 4: 커밋
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)

배포

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 도입 시)