매출 대시보드 완전 구현 완료
- /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')
|
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)
|
@app.errorhandler(404)
|
||||||
def not_found_error(error):
|
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