매출 대시보드 완전 구현 완료
- /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>
This commit is contained in:
parent
e93f96abe4
commit
6116f3fd15
@ -1147,6 +1147,227 @@ def create_app(config_name=None):
|
||||
"""구독 서비스 관리 페이지"""
|
||||
return render_template('subscriptions/list.html')
|
||||
|
||||
# =================== 매출 대시보드 ===================
|
||||
|
||||
@app.route('/revenue')
|
||||
def revenue_dashboard():
|
||||
"""매출 대시보드 페이지"""
|
||||
return render_template('revenue/dashboard.html')
|
||||
|
||||
@app.route('/api/analytics/revenue/monthly', methods=['GET'])
|
||||
def api_monthly_revenue():
|
||||
"""월별 매출 통계"""
|
||||
try:
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
import calendar
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 최근 12개월 매출 데이터 생성
|
||||
current_date = datetime.now()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
# i개월 전 날짜 계산
|
||||
target_date = current_date - timedelta(days=30*i)
|
||||
year = target_date.year
|
||||
month = target_date.month
|
||||
month_name = calendar.month_name[month]
|
||||
|
||||
# 해당 월의 구독 수 및 매출 계산
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
COUNT(ps.id) as subscription_count,
|
||||
SUM(ps.monthly_fee) as total_revenue
|
||||
FROM pharmacy_subscriptions ps
|
||||
WHERE ps.subscription_status = 'ACTIVE'
|
||||
AND ps.start_date <= ?
|
||||
''', (f'{year}-{month:02d}-28',))
|
||||
|
||||
result = cursor.fetchone()
|
||||
subscription_count = result[0] or 0
|
||||
total_revenue = result[1] or 0
|
||||
|
||||
monthly_data.insert(0, {
|
||||
'month': f'{year}-{month:02d}',
|
||||
'month_name': f'{month_name[:3]} {year}',
|
||||
'subscription_count': subscription_count,
|
||||
'revenue': total_revenue
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': monthly_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 월별 매출 통계 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/analytics/revenue/by-service', methods=['GET'])
|
||||
def api_revenue_by_service():
|
||||
"""서비스별 매출 통계"""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 서비스별 매출 통계
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
sp.product_code,
|
||||
sp.product_name,
|
||||
COUNT(ps.id) as subscription_count,
|
||||
SUM(ps.monthly_fee) as total_revenue
|
||||
FROM service_products sp
|
||||
LEFT JOIN pharmacy_subscriptions ps ON sp.id = ps.product_id
|
||||
AND ps.subscription_status = 'ACTIVE'
|
||||
GROUP BY sp.id, sp.product_code, sp.product_name
|
||||
ORDER BY total_revenue DESC NULLS LAST
|
||||
''')
|
||||
|
||||
services = []
|
||||
total_revenue = 0
|
||||
|
||||
for row in cursor.fetchall():
|
||||
product_code, product_name, count, revenue = row
|
||||
revenue = revenue or 0
|
||||
total_revenue += revenue
|
||||
|
||||
services.append({
|
||||
'code': product_code,
|
||||
'name': product_name,
|
||||
'subscription_count': count,
|
||||
'revenue': revenue
|
||||
})
|
||||
|
||||
# 비율 계산
|
||||
for service in services:
|
||||
service['percentage'] = round((service['revenue'] / total_revenue * 100), 1) if total_revenue > 0 else 0
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'services': services,
|
||||
'total_revenue': total_revenue
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 서비스별 매출 통계 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/analytics/pharmacy-ranking', methods=['GET'])
|
||||
def api_pharmacy_ranking():
|
||||
"""약국별 구독료 순위"""
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 약국별 구독료 순위 (상위 10개)
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
p.pharmacy_name,
|
||||
p.manager_name,
|
||||
COUNT(ps.id) as service_count,
|
||||
SUM(ps.monthly_fee) as total_monthly_fee
|
||||
FROM pharmacies p
|
||||
JOIN pharmacy_subscriptions ps ON p.id = ps.pharmacy_id
|
||||
WHERE ps.subscription_status = 'ACTIVE'
|
||||
GROUP BY p.id, p.pharmacy_name, p.manager_name
|
||||
ORDER BY total_monthly_fee DESC
|
||||
LIMIT 10
|
||||
''')
|
||||
|
||||
rankings = []
|
||||
for i, row in enumerate(cursor.fetchall(), 1):
|
||||
pharmacy_name, manager_name, service_count, total_fee = row
|
||||
rankings.append({
|
||||
'rank': i,
|
||||
'pharmacy_name': pharmacy_name,
|
||||
'manager_name': manager_name,
|
||||
'service_count': service_count,
|
||||
'monthly_fee': total_fee
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': rankings
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 약국별 순위 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/analytics/subscription-trends', methods=['GET'])
|
||||
def api_subscription_trends():
|
||||
"""구독 트렌드 통계 (신규/해지)"""
|
||||
try:
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 최근 6개월 구독 트렌드
|
||||
trends = []
|
||||
current_date = datetime.now()
|
||||
|
||||
for i in range(6):
|
||||
target_date = current_date - timedelta(days=30*i)
|
||||
year = target_date.year
|
||||
month = target_date.month
|
||||
|
||||
# 해당 월 신규 구독 수
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*)
|
||||
FROM pharmacy_subscriptions
|
||||
WHERE strftime('%Y-%m', start_date) = ?
|
||||
''', (f'{year}-{month:02d}',))
|
||||
new_subscriptions = cursor.fetchone()[0]
|
||||
|
||||
# 해당 월 해지 구독 수
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*)
|
||||
FROM pharmacy_subscriptions
|
||||
WHERE subscription_status = 'CANCELLED'
|
||||
AND strftime('%Y-%m', updated_at) = ?
|
||||
''', (f'{year}-{month:02d}',))
|
||||
cancelled_subscriptions = cursor.fetchone()[0]
|
||||
|
||||
trends.insert(0, {
|
||||
'month': f'{year}-{month:02d}',
|
||||
'new_subscriptions': new_subscriptions,
|
||||
'cancelled_subscriptions': cancelled_subscriptions,
|
||||
'net_growth': new_subscriptions - cancelled_subscriptions
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': trends
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구독 트렌드 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# 에러 핸들러
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
|
||||
543
farmq-admin/templates/revenue/dashboard.html
Normal file
543
farmq-admin/templates/revenue/dashboard.html
Normal file
@ -0,0 +1,543 @@
|
||||
{% 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 %}
|
||||
Loading…
Reference in New Issue
Block a user