KPI 대시보드
🎯
상담 전환율
0%
↑ 0%
평균 상담시간
0분
↓ 0분
🔁
재방문율
0%
↑ 0%
🤖
AI 활용률
0%
↑ 0%
💰
월 매출
0원
↑ 0%
📅
오늘 상담
0
실시간

일별 매출 추이 (최근 7일, 만원)

치료 유형 믹스

상담 전환 퍼널

KPI 벤치마크 (목표 대비)

AI 인사이트

document.getElementById('app').insertAdjacentHTML('afterbegin', renderSidebar('dashboard')); // ============================================================ // 데이터 상태 — SampleData로 초기화 후 Supabase로 덮어쓰기 // ============================================================ const MIX_COLORS = ['var(--primary)', 'var(--accent)', 'var(--warning)', '#8B5CF6', 'var(--danger)', '#0F766E', 'var(--gray-500)']; let currentData = buildFromSample(); function buildFromSample() { return { kpi: SampleData.kpi, patients: SampleData.patients.slice(), daily: SampleData.dailyRevenue.slice(), mix: SampleData.treatmentMix.slice(), isLive: false }; } function buildFromLive(agg, delta) { const k = agg.kpi; const funnel = agg.funnel || {}; // funnel을 SampleData.patients 형태의 status 배열로 변환 (렌더 호환) const virtualPatients = []; Object.entries(funnel).forEach(([status, count]) => { for (let i = 0; i < count; i++) virtualPatients.push({ status }); }); const mixSum = (agg.treatmentMix || []).reduce((s, m) => s + (m.value || 0), 0) || 1; const mixWithColor = (agg.treatmentMix || []).map((m, i) => ({ name: m.name, value: Math.round(m.value / mixSum * 100), color: MIX_COLORS[i % MIX_COLORS.length] })); return { kpi: { conversionRate: k.conversionRate, conversionRateDelta: delta.conversionRate || 0, avgConsultMin: k.avgConsultMin, avgConsultMinDelta: delta.avgConsultMin || 0, revisitRate: k.revisitRate, revisitRateDelta: delta.revisitRate || 0, aiUsageRate: k.aiUsageRate, aiUsageRateDelta: delta.aiUsageRate || 0, monthlyRevenue: k.monthlyRevenue, monthlyRevenueDelta: delta.monthlyRevenue || 0, todayConsults: k.todayConsults }, patients: virtualPatients, daily: agg.dailyRevenue, mix: mixWithColor.length ? mixWithColor : SampleData.treatmentMix, isLive: true }; } // ---- KPI ---- function setDelta(id, delta, isLower = false, suffix = '%') { const el = document.getElementById(id); if (!el) return; const good = isLower ? delta < 0 : delta > 0; el.className = 'kpi-change ' + (delta === 0 ? '' : (good ? 'up' : 'down')); const arrow = delta > 0 ? '↑' : (delta < 0 ? '↓' : '→'); el.innerHTML = `${arrow} ${Math.abs(delta)}${suffix} vs 지난주`; } function renderKPIs(data) { const kpi = data.kpi, patients = data.patients; document.getElementById('kpiConv').textContent = kpi.conversionRate + '%'; setDelta('kpiConvDelta', kpi.conversionRateDelta, false); document.getElementById('kpiTime').textContent = kpi.avgConsultMin + '분'; setDelta('kpiTimeDelta', kpi.avgConsultMinDelta, true, '분'); document.getElementById('kpiRevisit').textContent = kpi.revisitRate + '%'; setDelta('kpiRevisitDelta', kpi.revisitRateDelta, false); document.getElementById('kpiAI').textContent = kpi.aiUsageRate + '%'; setDelta('kpiAIDelta', kpi.aiUsageRateDelta, false); document.getElementById('kpiRevenue').textContent = formatCurrency(kpi.monthlyRevenue); setDelta('kpiRevenueDelta', kpi.monthlyRevenueDelta, false); document.getElementById('kpiToday').textContent = kpi.todayConsults != null ? kpi.todayConsults : patients.filter(p => p.status === '상담대기' || p.status === '상담중').length; } // ---- Line Chart (Revenue) ---- function drawLineChart() { const daily = currentData.daily; if (!daily || !daily.length) return; const wrap = document.getElementById('revenueChartWrap'); const W = 600, H = 260, pad = 40; const innerW = W - pad * 2, innerH = H - pad * 2; const maxV = Math.max(...daily.map(d => d.revenue), 1); const minV = 0; const xStep = innerW / Math.max(daily.length - 1, 1); const points = daily.map((d, i) => { const x = pad + i * xStep; const y = pad + innerH - ((d.revenue - minV) / (maxV - minV)) * innerH; return { x, y, ...d }; }); const path = points.map((p, i) => (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' '); const area = path + ` L${points[points.length - 1].x},${pad + innerH} L${points[0].x},${pad + innerH} Z`; // y-axis ticks (4) const yTicks = []; for (let i = 0; i <= 4; i++) { const val = Math.round(minV + (maxV - minV) * (i / 4)); const y = pad + innerH - (i / 4) * innerH; yTicks.push(` ${val}`); } const xLabels = points.map(p => `${p.date}`).join(''); const dots = points.map(p => ` ${p.date}: ${p.revenue}만원 `).join(''); wrap.innerHTML = ` ${yTicks.join('')} ${dots} ${xLabels} `; } // ---- Pie/Donut Chart (Treatment Mix) ---- function drawPieChart() { const mix = currentData.mix; const wrap = document.getElementById('mixChartWrap'); if (!mix || !mix.length) { wrap.innerHTML = '
데이터 없음
'; return; } const W = 240, H = 240; const cx = W / 2, cy = H / 2, r = 90, ir = 55; const total = mix.reduce((s, m) => s + m.value, 0) || 1; let angle = -Math.PI / 2; const segments = mix.map(m => { const fraction = m.value / total; const a2 = angle + fraction * Math.PI * 2; const large = fraction > 0.5 ? 1 : 0; const x1 = cx + r * Math.cos(angle); const y1 = cy + r * Math.sin(angle); const x2 = cx + r * Math.cos(a2); const y2 = cy + r * Math.sin(a2); const x3 = cx + ir * Math.cos(a2); const y3 = cy + ir * Math.sin(a2); const x4 = cx + ir * Math.cos(angle); const y4 = cy + ir * Math.sin(angle); const d = `M${x1},${y1} A${r},${r} 0 ${large} 1 ${x2},${y2} L${x3},${y3} A${ir},${ir} 0 ${large} 0 ${x4},${y4} Z`; angle = a2; return ` ${m.name}: ${m.value}% `; }).join(''); wrap.innerHTML = ` ${segments} 총합 ${total}% `; document.getElementById('mixLegend').innerHTML = mix.map(m => `
${m.name} ${m.value}%
`).join(''); } // ---- Funnel ---- function renderFunnel() { const patients = currentData.patients; const funnelData = [ { label: '상담대기', count: patients.filter(p => p.status === '상담대기').length, color: 'var(--gray-400)' }, { label: '상담중', count: patients.filter(p => p.status === '상담중').length, color: 'var(--primary)' }, { label: '계약완료', count: patients.filter(p => p.status === '계약완료').length, color: 'var(--accent)' }, { label: '치료중', count: patients.filter(p => p.status === '치료중').length, color: 'var(--warning)' }, { label: '치료완료', count: patients.filter(p => p.status === '치료완료').length, color: '#0F766E' }, ]; const maxCount = Math.max(...funnelData.map(f => f.count), 1); const total = funnelData.reduce((s, f) => s + f.count, 0) || 1; document.getElementById('funnel').innerHTML = funnelData.map(f => `
${f.label}
${f.count}명
${Math.round((f.count / total) * 100)}%
`).join(''); } // ---- Benchmark ---- function renderBenchmark() { const bm = KPIEngine.benchmark(currentData.kpi); const bmLabels = { conversionRate: '전환율', avgConsultMin: '평균 상담시간', revisitRate: '재방문율', aiUsageRate: 'AI 활용률' }; const bmUnits = { conversionRate: '%', avgConsultMin: '분', revisitRate: '%', aiUsageRate: '%' }; document.getElementById('benchmark').innerHTML = Object.keys(bm).map(k => { const item = bm[k]; const unit = bmUnits[k]; const color = item.met ? 'var(--success)' : 'var(--danger)'; const icon = item.met ? '✓' : '✕'; return `
${bmLabels[k]}
현재 ${item.current}${unit} / 목표 ${item.target}${unit}
${item.delta > 0 ? '+' : ''}${item.delta}${unit} ${icon}
`; }).join(''); } // ---- Insights ---- function renderInsights() { const kpi = currentData.kpi; const insights = currentData.isLive ? [ { icon: '📈', title: '실데이터 연결됨', desc: `Supabase에서 실시간으로 집계 중입니다. 7일 매출·치료믹스·퍼널·6대 KPI 모두 DB 기반.`, color: 'var(--success)' }, { icon: '🎯', title: '전환율', desc: `현재 ${kpi.conversionRate}% — 목표 70% 대비 ${kpi.conversionRate >= 70 ? '달성' : (70 - kpi.conversionRate) + '%p 여유'}.`, color: 'var(--primary)' }, { icon: '⚠️', title: 'AI 활용률', desc: `${kpi.aiUsageRate}% — 상담 로그에 코칭 결과가 남은 세션 비중.`, color: 'var(--warning)' }, ] : [ { icon: '📈', title: '매출 상승 추세', desc: '최근 7일 매출이 평균 대비 24% 상승했습니다. 임플란트 상담 증가가 주 원인입니다.', color: 'var(--success)' }, { icon: '🎯', title: '전환율 개선 기회', desc: `AI 활용 상담의 계약 성공률이 비활용 대비 ${Math.max(1, Math.round((kpi.aiUsageRate || 0) / 35))}배 높습니다.`, color: 'var(--primary)' }, { icon: '⚠️', title: '주의: 고가치료 이탈', desc: '임플란트 상담 중 2회차 미도달 환자 3명. 자동화 리마인더 발송을 추천합니다.', color: 'var(--warning)' }, ]; document.getElementById('insights').innerHTML = insights.map(i => `
${i.icon}

${i.title}

${i.desc}

`).join(''); } // ---- 상단 데이터 소스 배지 ---- function renderSourceBadge() { const existing = document.getElementById('dataSourceBadge'); if (existing) existing.remove(); const badge = document.createElement('div'); badge.id = 'dataSourceBadge'; badge.style.cssText = 'display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--radius-full); font-size:0.72rem; font-weight:800; letter-spacing:0.04em; margin-bottom:14px;'; if (currentData.isLive) { badge.style.background = 'var(--success-light-bg)'; badge.style.color = '#14532D'; badge.innerHTML = ' 🔴 LIVE · Supabase'; } else { badge.style.background = 'var(--warning-bg)'; badge.style.color = 'var(--warning-text)'; badge.textContent = '📊 DEMO · SampleData'; } const content = document.querySelector('.content'); if (content) content.insertBefore(badge, content.firstChild); } // ---- 오케스트레이터 ---- function renderAll() { renderKPIs(currentData); renderFunnel(); renderBenchmark(); renderInsights(); drawLineChart(); drawPieChart(); renderSourceBadge(); } renderAll(); window.addEventListener('resize', () => { drawLineChart(); drawPieChart(); }); // ---- Supabase 실데이터 로드 (있을 때) ---- (async () => { if (typeof SupabaseDB === 'undefined' || !SupabaseDB.isReady()) return; try { const [agg, prevSnaps] = await Promise.all([ SupabaseDB.getDashboardAggregates(), SupabaseDB.getKPIs({ limit: 7 }).catch(() => []) ]); // 어제 스냅샷(있으면) vs 오늘 집계 delta 계산 const yesterday = (prevSnaps || [])[0]; const delta = {}; if (yesterday) { delta.conversionRate = Math.round((agg.kpi.conversionRate - (yesterday.conversion_rate || 0)) * 10) / 10; delta.avgConsultMin = Math.round((agg.kpi.avgConsultMin - (yesterday.avg_consult_min || 0)) * 10) / 10; delta.revisitRate = Math.round((agg.kpi.revisitRate - (yesterday.revisit_rate || 0)) * 10) / 10; delta.aiUsageRate = Math.round((agg.kpi.aiUsageRate - (yesterday.ai_usage_rate || 0)) * 10) / 10; delta.monthlyRevenue = Math.round(((agg.kpi.monthlyRevenue - (yesterday.revenue || 0)) / Math.max(yesterday.revenue || 1, 1)) * 100); } // 데이터 없으면 SampleData 유지 (Supabase에 환자 0명이면 혼란) const hasAnyData = agg.kpi.totalPatients > 0 || agg.kpi.totalConsults > 0 || (agg.dailyRevenue || []).some(d => d.revenue > 0); if (!hasAnyData) { showToast('Supabase 연결됨 (데이터 없음) — SampleData로 표시합니다', 'info'); return; } currentData = buildFromLive(agg, delta); renderAll(); showToast('🔴 LIVE — Supabase 실데이터 반영', 'success'); } catch (e) { console.warn('대시보드 실데이터 로드 실패', e); } })(); // ============================================================ // 직원 관리 (원장 / 관리자만 접근) // ============================================================ function showStaffSection() { const session = typeof Session !== 'undefined' ? Session.get() : null; const isDirector = session && (session.role === '원장' || session.is_admin); const staffSection = document.getElementById('staffSection'); if (staffSection) staffSection.style.display = isDirector ? 'block' : 'none'; if (isDirector) renderStaffTable(); } async function renderStaffTable() { const tbody = document.getElementById('staffTable'); if (!tbody) return; tbody.innerHTML = '불러오는 중...'; try { const session = typeof Session !== 'undefined' ? Session.get() : null; if (!session || !session.clinic_id) throw new Error('세션 정보 없음'); const users = await SupabaseDB.getClinicUsers(session.clinic_id); const rows = users.map(u => ` ${safe(u.name)} ${safe(u.email)} ${safe(u.phone || '-')} ${safe(u.role)} ${(u.created_at || '').slice(0, 10)} ${u.id !== session.userId ? `` : '(본인)'} `).join(''); tbody.innerHTML = rows || '직원 없음'; } catch (e) { tbody.innerHTML = `조회 실패: ${e.message}`; } } async function registerStaff() { const name = (document.getElementById('staffName')?.value || '').trim(); const email = (document.getElementById('staffEmail')?.value || '').trim(); const phone = (document.getElementById('staffPhone')?.value || '').trim(); const role = document.getElementById('staffRole')?.value || '상담실장'; if (!name || !email || !phone) { showToast('모든 필드를 입력하세요', 'warning'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { showToast('이메일 형식이 올바르지 않습니다', 'warning'); return; } try { const session = typeof Session !== 'undefined' ? Session.get() : null; if (!session || !session.clinic_id) throw new Error('세션 정보 없음'); await SupabaseDB.addClinicUser({ clinicId: session.clinic_id, name, email, phone, role }); showToast('직원이 등록되었습니다', 'success'); document.getElementById('staffName').value = ''; document.getElementById('staffEmail').value = ''; document.getElementById('staffPhone').value = ''; await renderStaffTable(); } catch (e) { console.error('Register staff error:', e); showToast('등록 실패: ' + e.message, 'error'); } } async function deleteStaff(staffId) { if (!confirm('이 직원을 삭제하시겠습니까?')) return; try { await SupabaseDB.deleteClinicUser(staffId); showToast('직원이 삭제되었습니다', 'success'); await renderStaffTable(); } catch (e) { console.error('Delete staff error:', e); showToast('삭제 실패: ' + e.message, 'error'); } } // 초기화 showStaffSection();