feat(subscription): 5축 점수 breakdown 시각화 + 알림 대상 카운트

- AnnouncementDetail: 5축(지역/유형/면적/가격/자격) progress bar 추가
- MatchesTab: 카드마다 미니 5축 비례 바 추가 (색상 구분)
- ProfileTab: 마운트 시 dashboard도 함께 fetch → pass_count 취득
- NotificationSettings: passCount prop → "현재 N건 대상" 인라인 표시
- Subscription.css: .ns-pass-count 스타일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 08:56:36 +09:00
parent 573c0364bb
commit 7cbdbe6e8b
3 changed files with 93 additions and 11 deletions

View File

@@ -1534,3 +1534,18 @@ input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* === 신규: 알림 대상 카운트 뱃지 ===================================== */
.ns-pass-count {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: rgba(0, 212, 255, 0.12);
color: #00d4ff;
font-size: 11px;
font-weight: 600;
}
.ns-pass-count strong {
font-weight: 700;
}

View File

@@ -618,16 +618,52 @@ function AnnouncementDetail({ item, onBookmark }) {
{item.match_score !== undefined && item.match_score !== null && ( {item.match_score !== undefined && item.match_score !== null && (
<div className="sub-match-analysis"> <div className="sub-match-analysis">
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div> <div>
<p className="sub-panel__eyebrow">매칭 분석</p> <p className="sub-panel__eyebrow">매칭 분석</p>
<span className="sub-match-analysis__score"> <span className="sub-match-analysis__score">
{item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span> {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
</span> </span>
</div> </div>
</div>
{item.score_breakdown && (
<div style={{ marginTop: 12 }}>
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
<div style={{ display: 'grid', gap: 8 }}>
{[
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
].map(({ key, label, max, color }) => {
const v = item.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ display: 'grid', gap: 3 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
<span>
<span style={{ fontWeight: 700, color }}>{v}</span>
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
</span>
</div>
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: color,
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
{item.match_reasons && item.match_reasons.length > 0 && ( {item.match_reasons && item.match_reasons.length > 0 && (
<div> <div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p> <p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
<ul className="sub-match-analysis__reasons"> <ul className="sub-match-analysis__reasons">
{item.match_reasons.map((r, idx) => ( {item.match_reasons.map((r, idx) => (
<li key={idx}>{r}</li> <li key={idx}>{r}</li>
@@ -973,6 +1009,24 @@ function MatchesTab() {
).join(' · ')} ).join(' · ')}
</div> </div>
)} )}
{match.score_breakdown && (
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
{[
{ key: 'region', max: 35, color: '#00d4ff' },
{ key: 'type', max: 10, color: '#8b5cf6' },
{ key: 'area', max: 15, color: '#f59e0b' },
{ key: 'price', max: 15, color: '#f43f5e' },
{ key: 'eligibility', max: 25, color: '#34d399' },
].map(({ key, max, color }) => {
const v = match.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
</div>
);
})}
</div>
)}
</div> </div>
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}> <div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
<div> <div>
@@ -1026,6 +1080,7 @@ function MatchesTab() {
// ── ProfileTab ──────────────────────────────────────────────────────────────── // ── ProfileTab ────────────────────────────────────────────────────────────────
function ProfileTab() { function ProfileTab() {
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE }); const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
const [passCount, setPassCount] = useState(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@@ -1034,13 +1089,17 @@ function ProfileTab() {
(async () => { (async () => {
setLoading(true); setLoading(true);
try { try {
const data = await apiGet('/api/realestate/profile'); const [data, dash] = await Promise.all([
apiGet('/api/realestate/profile'),
apiGet('/api/realestate/dashboard').catch(() => null),
]);
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
const display = { ...DEFAULT_PROFILE, ...data }; const display = { ...DEFAULT_PROFILE, ...data };
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', '); if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', '); if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
setProfile(display); setProfile(display);
} }
if (dash?.pass_count != null) setPassCount(dash.pass_count);
} catch (e) { } catch (e) {
console.error('Profile load error:', e); console.error('Profile load error:', e);
} finally { } finally {
@@ -1379,6 +1438,7 @@ function ProfileTab() {
minScore={profile.min_match_score ?? 70} minScore={profile.min_match_score ?? 70}
notifyEnabled={profile.notify_enabled ?? true} notifyEnabled={profile.notify_enabled ?? true}
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))} onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
passCount={passCount}
/> />
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) { export default function NotificationSettings({ minScore, notifyEnabled, onChange, passCount }) {
const score = minScore ?? 70; const score = minScore ?? 70;
const enabled = notifyEnabled ?? true; const enabled = notifyEnabled ?? true;
@@ -42,9 +42,16 @@ export default function NotificationSettings({ minScore, notifyEnabled, onChange
</label> </label>
<p className="ns-hint"> <p className="ns-hint">
{enabled {enabled ? (
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.` <>
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."} 💡 {score} 이상 매치 텔레그램에 자동 알림합니다.
{passCount != null && (
<span className="ns-pass-count">
현재 <strong>{passCount}</strong> 대상
</span>
)}
</>
) : "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
</p> </p>
</div> </div>
</div> </div>