🔧 사이드바 메뉴 개선: - 구독 서비스 관리 메뉴 추가 (/subscriptions) - 매출 대시보드 메뉴 추가 (준비 중 알림) - VM 관리 (VNC) 메뉴 정리 - PQON 사용자 관리로 명칭 통일 - showComingSoon() 함수로 준비 중 기능 알림 📊 구독 서비스 관리 페이지 (/subscriptions): - 구독 현황 통계 카드 (월 매출, 구독 수, 구독률) - 서비스별 구독 현황 차트 (클라우드PC, AI CCTV, CRM) - 약국별 구독 현황 테이블 (검색 및 필터링 지원) - 실시간 데이터 로딩 및 새로고침 기능 🎨 UI/UX 기능: - 서비스별 이모지 아이콘 시스템 (💻📷📊) - 반응형 디자인 및 색상 코딩 - 엔터키 지원 검색 기능 - 로딩 스피너 및 오류 처리 🔄 데이터 연동: - /api/subscriptions/stats 통계 API 활용 - /api/pharmacies/subscriptions 약국 현황 API 활용 - 약국 상세 페이지 연동 📱 사용자 경험: - 직관적인 네비게이션 구조 - 실시간 검색 및 필터링 - 상세 관리 버튼으로 약국별 구독 관리 접근 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
411 lines
15 KiB
HTML
411 lines
15 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-box text-success"></i>
|
|
구독 서비스 관리
|
|
</h1>
|
|
<p class="text-muted">PharmQ SaaS 구독 현황 및 매출 관리</p>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-success" onclick="refreshSubscriptionData()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 구독 통계 카드 -->
|
|
<div class="row mb-4" id="subscription-stats-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-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-chart-bar text-primary"></i> 서비스별 구독 현황
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row" id="service-stats">
|
|
<!-- 서비스별 통계가 여기에 로드됩니다 -->
|
|
<div class="col-12 text-center">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">서비스 통계 로딩 중...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 약국별 구독 현황 -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-list text-info"></i> 약국별 구독 현황
|
|
</h5>
|
|
<div class="input-group" style="width: 300px;">
|
|
<input type="text" class="form-control" id="searchInput" placeholder="약국명 또는 담당자 검색...">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="searchPharmacies()">
|
|
<i class="fas fa-search"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="pharmacy-subscription-list">
|
|
<!-- 약국별 구독 현황 테이블이 여기에 로드됩니다 -->
|
|
<div class="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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let pharmacySubscriptionData = [];
|
|
let filteredData = [];
|
|
|
|
// 페이지 로드 시 데이터 로드
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadSubscriptionStats();
|
|
loadPharmacySubscriptions();
|
|
});
|
|
|
|
// 구독 통계 로드
|
|
function loadSubscriptionStats() {
|
|
fetch('/api/subscriptions/stats')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displaySubscriptionStats(result.data);
|
|
} else {
|
|
showSubscriptionStatsError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('구독 통계 로드 오류:', error);
|
|
showSubscriptionStatsError();
|
|
});
|
|
}
|
|
|
|
// 구독 통계 표시
|
|
function displaySubscriptionStats(data) {
|
|
const statsHtml = `
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #38b2ac 0%, #2c7a7b 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, #805ad5 0%, #6b46c1 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, #ed8936 0%, #dd6b20 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number">${data.subscribed_pharmacies}/${data.total_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, #48bb78 0%, #38a169 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number">${data.subscription_rate}%</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-percentage"></i> 구독률
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('subscription-stats-cards').innerHTML = statsHtml;
|
|
|
|
// 서비스별 통계 표시
|
|
displayServiceStats(data.services);
|
|
}
|
|
|
|
// 서비스별 통계 표시
|
|
function displayServiceStats(services) {
|
|
const serviceIconMap = {
|
|
'CLOUD_PC': { icon: '💻', color: '#4f46e5', name: '클라우드 PC' },
|
|
'AI_CCTV': { icon: '📷', color: '#0891b2', name: 'AI CCTV' },
|
|
'CRM': { icon: '📊', color: '#ca8a04', name: 'CRM 시스템' }
|
|
};
|
|
|
|
let servicesHtml = '';
|
|
services.forEach(service => {
|
|
const serviceInfo = serviceIconMap[service.code] || { icon: '📦', color: '#6b7280', name: service.name };
|
|
|
|
servicesHtml += `
|
|
<div class="col-lg-4 col-md-6 mb-3">
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center mb-3">
|
|
<div class="me-3" style="font-size: 3rem;">${serviceInfo.icon}</div>
|
|
<div class="flex-grow-1">
|
|
<h5 class="mb-1" style="color: ${serviceInfo.color};">${serviceInfo.name}</h5>
|
|
<p class="text-muted mb-0">${service.count}개 약국이 구독 중</p>
|
|
</div>
|
|
</div>
|
|
<div class="row text-center">
|
|
<div class="col-6">
|
|
<div class="border-end">
|
|
<div class="h4 mb-0" style="color: ${serviceInfo.color};">${service.count}</div>
|
|
<small class="text-muted">구독 수</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="h4 mb-0" style="color: ${serviceInfo.color};">₩${service.revenue.toLocaleString()}</div>
|
|
<small class="text-muted">월 매출</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
document.getElementById('service-stats').innerHTML = servicesHtml;
|
|
}
|
|
|
|
// 약국별 구독 현황 로드
|
|
function loadPharmacySubscriptions() {
|
|
fetch('/api/pharmacies/subscriptions')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
pharmacySubscriptionData = result.data;
|
|
filteredData = [...pharmacySubscriptionData];
|
|
displayPharmacySubscriptions(filteredData);
|
|
} else {
|
|
showPharmacySubscriptionsError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('약국 구독 현황 로드 오류:', error);
|
|
showPharmacySubscriptionsError();
|
|
});
|
|
}
|
|
|
|
// 약국별 구독 현황 표시
|
|
function displayPharmacySubscriptions(data) {
|
|
if (data.length === 0) {
|
|
document.getElementById('pharmacy-subscription-list').innerHTML = `
|
|
<div class="text-center py-4">
|
|
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
|
<p class="text-muted">검색 결과가 없습니다.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const serviceIconMap = {
|
|
'CLOUD_PC': '💻',
|
|
'AI_CCTV': '📷',
|
|
'CRM': '📊'
|
|
};
|
|
|
|
let tableHtml = `
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>약국 정보</th>
|
|
<th>담당자</th>
|
|
<th>구독 서비스</th>
|
|
<th>월 구독료</th>
|
|
<th>액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
data.forEach(pharmacy => {
|
|
// 구독 서비스 아이콘 표시
|
|
let serviceIcons = '';
|
|
if (pharmacy.subscribed_services && pharmacy.subscribed_services.length > 0) {
|
|
pharmacy.subscribed_services.forEach(serviceCode => {
|
|
const icon = serviceIconMap[serviceCode] || '📦';
|
|
serviceIcons += `<span class="me-1" title="${serviceCode}">${icon}</span>`;
|
|
});
|
|
} else {
|
|
serviceIcons = '<span class="text-muted">구독 없음</span>';
|
|
}
|
|
|
|
tableHtml += `
|
|
<tr>
|
|
<td>
|
|
<div>
|
|
<strong>${pharmacy.name}</strong>
|
|
</div>
|
|
<div class="small text-muted">
|
|
<i class="fas fa-map-marker-alt"></i> ${pharmacy.address || '주소 미등록'}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>
|
|
<strong>${pharmacy.manager || '미등록'}</strong>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>${serviceIcons}</div>
|
|
<div class="small text-muted">${pharmacy.subscribed_services.length}개 서비스</div>
|
|
</td>
|
|
<td>
|
|
<strong class="text-success">₩${pharmacy.monthly_fee.toLocaleString()}/월</strong>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<a href="/pharmacy/${pharmacy.id}" class="btn btn-outline-primary">
|
|
<i class="fas fa-eye"></i> 상세
|
|
</a>
|
|
<button class="btn btn-outline-success" onclick="manageSubscription(${pharmacy.id}, '${pharmacy.name}')">
|
|
<i class="fas fa-cog"></i> 관리
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
tableHtml += `
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="mt-3 text-muted">
|
|
<small>총 ${data.length}개 약국 표시 중</small>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('pharmacy-subscription-list').innerHTML = tableHtml;
|
|
}
|
|
|
|
// 검색 기능
|
|
function searchPharmacies() {
|
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
|
|
|
|
if (searchTerm === '') {
|
|
filteredData = [...pharmacySubscriptionData];
|
|
} else {
|
|
filteredData = pharmacySubscriptionData.filter(pharmacy =>
|
|
pharmacy.name.toLowerCase().includes(searchTerm) ||
|
|
(pharmacy.manager && pharmacy.manager.toLowerCase().includes(searchTerm)) ||
|
|
(pharmacy.address && pharmacy.address.toLowerCase().includes(searchTerm))
|
|
);
|
|
}
|
|
|
|
displayPharmacySubscriptions(filteredData);
|
|
}
|
|
|
|
// 엔터키로 검색
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
searchPharmacies();
|
|
}
|
|
});
|
|
});
|
|
|
|
// 구독 관리 (약국 상세 페이지로 이동)
|
|
function manageSubscription(pharmacyId, pharmacyName) {
|
|
window.location.href = `/pharmacy/${pharmacyId}`;
|
|
}
|
|
|
|
// 데이터 새로고침
|
|
function refreshSubscriptionData() {
|
|
showToast('구독 데이터를 새로고침 중...', 'info');
|
|
loadSubscriptionStats();
|
|
loadPharmacySubscriptions();
|
|
}
|
|
|
|
// 오류 표시 함수들
|
|
function showSubscriptionStatsError() {
|
|
document.getElementById('subscription-stats-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>
|
|
<button class="btn btn-outline-primary btn-sm" onclick="loadSubscriptionStats()">
|
|
<i class="fas fa-redo"></i> 다시 시도
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showPharmacySubscriptionsError() {
|
|
document.getElementById('pharmacy-subscription-list').innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-exclamation-triangle fa-3x mb-3"></i>
|
|
<p>약국 구독 현황을 불러오는 중 오류가 발생했습니다.</p>
|
|
<button class="btn btn-outline-primary btn-sm" onclick="loadPharmacySubscriptions()">
|
|
<i class="fas fa-redo"></i> 다시 시도
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.stat-number {
|
|
font-size: 2.5rem;
|
|
font-weight: bold;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.9rem;
|
|
opacity: 0.9;
|
|
}
|
|
</style>
|
|
{% endblock %} |