- /revenue 경로로 매출 대시보드 페이지 추가 - 4개 매출 분석 API 엔드포인트 구현: * /api/analytics/revenue/monthly - 12개월 매출 트렌드 * /api/analytics/revenue/by-service - 서비스별 매출 비중 * /api/analytics/pharmacy-ranking - 약국별 구독료 순위 * /api/analytics/subscription-trends - 구독 성장/해지 트렌드 - Chart.js 기반 종합 시각화 대시보드: * 월별 매출 라인 차트 * 서비스별 매출 도넛 차트 * 약국 순위 테이블 * 구독 트렌드 바 차트 - 실시간 데이터 로딩 및 오류 처리 구현 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
543 lines
18 KiB
HTML
543 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}매출 대시보드 - PharmQ Super Admin{% endblock %}
|
|
|
|
{% block breadcrumb %}
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
|
<li class="breadcrumb-item active">매출 대시보드</li>
|
|
</ol>
|
|
</nav>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- 페이지 헤더 -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h1 class="h2 mb-0">
|
|
<i class="fas fa-chart-pie text-warning"></i>
|
|
매출 대시보드
|
|
</h1>
|
|
<p class="text-muted">PharmQ SaaS 매출 현황 및 트렌드 분석</p>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-warning" onclick="refreshRevenueData()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 매출 요약 카드 -->
|
|
<div class="row mb-4" id="revenue-summary-cards">
|
|
<div class="col-12 text-center">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">매출 요약 로딩 중...</span>
|
|
</div>
|
|
<p class="mt-2 text-muted">매출 요약을 불러오는 중입니다...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 차트 섹션 -->
|
|
<div class="row mb-4">
|
|
<!-- 월별 매출 트렌드 -->
|
|
<div class="col-lg-8 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-chart-line text-primary"></i> 월별 매출 트렌드 (최근 12개월)
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div style="height: 350px;">
|
|
<canvas id="monthlyRevenueChart"></canvas>
|
|
</div>
|
|
<div class="mt-3" id="monthly-chart-loading">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">차트 로딩 중...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 서비스별 매출 비중 -->
|
|
<div class="col-lg-4 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-chart-pie text-success"></i> 서비스별 매출 비중
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div style="height: 300px;">
|
|
<canvas id="serviceRevenueChart"></canvas>
|
|
</div>
|
|
<div class="mt-3" id="service-chart-loading">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-success" role="status">
|
|
<span class="visually-hidden">차트 로딩 중...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- 약국별 구독료 순위 -->
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-trophy text-warning"></i> 약국별 구독료 순위 (TOP 10)
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="pharmacy-ranking">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-warning" role="status">
|
|
<span class="visually-hidden">순위 로딩 중...</span>
|
|
</div>
|
|
<p class="mt-2 text-muted">약국별 순위를 불러오는 중입니다...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 구독 트렌드 -->
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-chart-bar text-info"></i> 구독 트렌드 (최근 6개월)
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div style="height: 250px;">
|
|
<canvas id="subscriptionTrendChart"></canvas>
|
|
</div>
|
|
<div class="mt-3" id="trend-chart-loading">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-info" role="status">
|
|
<span class="visually-hidden">트렌드 차트 로딩 중...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
let monthlyRevenueChart = null;
|
|
let serviceRevenueChart = null;
|
|
let subscriptionTrendChart = null;
|
|
|
|
// 페이지 로드 시 모든 데이터 로드
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadRevenueData();
|
|
});
|
|
|
|
// 모든 매출 데이터 로드
|
|
function loadRevenueData() {
|
|
loadRevenueSummary();
|
|
loadMonthlyRevenue();
|
|
loadServiceRevenue();
|
|
loadPharmacyRanking();
|
|
loadSubscriptionTrends();
|
|
}
|
|
|
|
// 매출 요약 로드
|
|
function loadRevenueSummary() {
|
|
fetch('/api/subscriptions/stats')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displayRevenueSummary(result.data);
|
|
} else {
|
|
showRevenueSummaryError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('매출 요약 로드 오류:', error);
|
|
showRevenueSummaryError();
|
|
});
|
|
}
|
|
|
|
// 매출 요약 표시
|
|
function displayRevenueSummary(data) {
|
|
const summaryHtml = `
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number">₩${data.total_revenue.toLocaleString()}</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-won-sign"></i> 현재 월 매출
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number">${data.total_subscriptions}</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-clipboard-list"></i> 활성 구독
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number">${data.subscribed_pharmacies}</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-store"></i> 구독 약국
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number">₩${Math.round(data.total_revenue / data.subscribed_pharmacies || 0).toLocaleString()}</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-calculator"></i> 평균 ARPU
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('revenue-summary-cards').innerHTML = summaryHtml;
|
|
}
|
|
|
|
// 월별 매출 로드
|
|
function loadMonthlyRevenue() {
|
|
fetch('/api/analytics/revenue/monthly')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displayMonthlyRevenueChart(result.data);
|
|
} else {
|
|
showMonthlyChartError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('월별 매출 로드 오류:', error);
|
|
showMonthlyChartError();
|
|
});
|
|
}
|
|
|
|
// 월별 매출 차트 표시
|
|
function displayMonthlyRevenueChart(data) {
|
|
document.getElementById('monthly-chart-loading').style.display = 'none';
|
|
|
|
const ctx = document.getElementById('monthlyRevenueChart').getContext('2d');
|
|
|
|
if (monthlyRevenueChart) {
|
|
monthlyRevenueChart.destroy();
|
|
}
|
|
|
|
monthlyRevenueChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map(item => item.month_name),
|
|
datasets: [{
|
|
label: '월 매출 (원)',
|
|
data: data.map(item => item.revenue),
|
|
borderColor: 'rgb(75, 192, 192)',
|
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
|
tension: 0.4,
|
|
fill: true
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
callback: function(value) {
|
|
return '₩' + value.toLocaleString();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
plugins: {
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
return '매출: ₩' + context.parsed.y.toLocaleString();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 서비스별 매출 로드
|
|
function loadServiceRevenue() {
|
|
fetch('/api/analytics/revenue/by-service')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displayServiceRevenueChart(result.data);
|
|
} else {
|
|
showServiceChartError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('서비스별 매출 로드 오류:', error);
|
|
showServiceChartError();
|
|
});
|
|
}
|
|
|
|
// 서비스별 매출 차트 표시
|
|
function displayServiceRevenueChart(data) {
|
|
document.getElementById('service-chart-loading').style.display = 'none';
|
|
|
|
const ctx = document.getElementById('serviceRevenueChart').getContext('2d');
|
|
|
|
if (serviceRevenueChart) {
|
|
serviceRevenueChart.destroy();
|
|
}
|
|
|
|
const colors = ['#FF6384', '#36A2EB', '#FFCE56'];
|
|
|
|
serviceRevenueChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: data.services.map(service => service.name),
|
|
datasets: [{
|
|
data: data.services.map(service => service.revenue),
|
|
backgroundColor: colors.slice(0, data.services.length),
|
|
borderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
const service = data.services[context.dataIndex];
|
|
return service.name + ': ₩' + service.revenue.toLocaleString() + ' (' + service.percentage + '%)';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 약국 순위 로드
|
|
function loadPharmacyRanking() {
|
|
fetch('/api/analytics/pharmacy-ranking')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displayPharmacyRanking(result.data);
|
|
} else {
|
|
showPharmacyRankingError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('약국 순위 로드 오류:', error);
|
|
showPharmacyRankingError();
|
|
});
|
|
}
|
|
|
|
// 약국 순위 표시
|
|
function displayPharmacyRanking(rankings) {
|
|
let html = '<div class="list-group list-group-flush">';
|
|
|
|
rankings.forEach(pharmacy => {
|
|
const rankIcon = pharmacy.rank <= 3 ?
|
|
`<i class="fas fa-medal text-warning"></i>` :
|
|
`<span class="badge bg-light text-dark">${pharmacy.rank}</span>`;
|
|
|
|
html += `
|
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center">
|
|
<div class="me-3">${rankIcon}</div>
|
|
<div>
|
|
<h6 class="mb-1">${pharmacy.pharmacy_name}</h6>
|
|
<small class="text-muted">${pharmacy.manager_name} · ${pharmacy.service_count}개 서비스</small>
|
|
</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<strong class="text-success">₩${pharmacy.monthly_fee.toLocaleString()}/월</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>';
|
|
document.getElementById('pharmacy-ranking').innerHTML = html;
|
|
}
|
|
|
|
// 구독 트렌드 로드
|
|
function loadSubscriptionTrends() {
|
|
fetch('/api/analytics/subscription-trends')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displaySubscriptionTrendChart(result.data);
|
|
} else {
|
|
showTrendChartError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('구독 트렌드 로드 오류:', error);
|
|
showTrendChartError();
|
|
});
|
|
}
|
|
|
|
// 구독 트렌드 차트 표시
|
|
function displaySubscriptionTrendChart(data) {
|
|
document.getElementById('trend-chart-loading').style.display = 'none';
|
|
|
|
const ctx = document.getElementById('subscriptionTrendChart').getContext('2d');
|
|
|
|
if (subscriptionTrendChart) {
|
|
subscriptionTrendChart.destroy();
|
|
}
|
|
|
|
subscriptionTrendChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.map(item => item.month),
|
|
datasets: [{
|
|
label: '신규 구독',
|
|
data: data.map(item => item.new_subscriptions),
|
|
backgroundColor: 'rgba(54, 162, 235, 0.8)'
|
|
}, {
|
|
label: '해지 구독',
|
|
data: data.map(item => item.cancelled_subscriptions),
|
|
backgroundColor: 'rgba(255, 99, 132, 0.8)'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
stepSize: 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 데이터 새로고침
|
|
function refreshRevenueData() {
|
|
showToast('매출 데이터를 새로고침 중...', 'info');
|
|
|
|
// 로딩 상태로 되돌리기
|
|
document.getElementById('revenue-summary-cards').innerHTML = `
|
|
<div class="col-12 text-center">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">새로고침 중...</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 모든 데이터 다시 로드
|
|
loadRevenueData();
|
|
}
|
|
|
|
// 오류 표시 함수들
|
|
function showRevenueSummaryError() {
|
|
document.getElementById('revenue-summary-cards').innerHTML = `
|
|
<div class="col-12 text-center text-muted py-4">
|
|
<i class="fas fa-exclamation-triangle fa-3x mb-3"></i>
|
|
<p>매출 요약을 불러오는 중 오류가 발생했습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showMonthlyChartError() {
|
|
document.getElementById('monthly-chart-loading').innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
|
|
<p>차트 데이터를 불러올 수 없습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showServiceChartError() {
|
|
document.getElementById('service-chart-loading').innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
|
|
<p>서비스 데이터를 불러올 수 없습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showPharmacyRankingError() {
|
|
document.getElementById('pharmacy-ranking').innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
|
|
<p>순위 데이터를 불러올 수 없습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showTrendChartError() {
|
|
document.getElementById('trend-chart-loading').innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
|
|
<p>트렌드 데이터를 불러올 수 없습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.stat-number {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.9rem;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.list-group-item {
|
|
border-left: none;
|
|
border-right: none;
|
|
}
|
|
|
|
.list-group-item:first-child {
|
|
border-top: none;
|
|
}
|
|
|
|
.list-group-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
</style>
|
|
{% endblock %} |