사이드바 메뉴 및 구독 서비스 관리 페이지 구현 완료

🔧 사이드바 메뉴 개선:
- 구독 서비스 관리 메뉴 추가 (/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>
This commit is contained in:
시골약사 2025-09-11 19:52:19 +09:00
parent 35ecd4748e
commit e93f96abe4
2 changed files with 433 additions and 2 deletions

View File

@ -187,14 +187,29 @@
<i class="fas fa-store"></i> 약국 관리 <i class="fas fa-store"></i> 약국 관리
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'users' in request.endpoint %}active{% endif %}" href="{{ url_for('users_list') }}">
<i class="fas fa-users"></i> PQON 사용자 관리
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}" href="{{ url_for('machine_list') }}"> <a class="nav-link {% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}" href="{{ url_for('machine_list') }}">
<i class="fas fa-desktop"></i> 머신 관리 <i class="fas fa-desktop"></i> 머신 관리
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'users' in request.endpoint %}active{% endif %}" href="{{ url_for('users_list') }}"> <a class="nav-link {% if request.endpoint and 'vms' in request.endpoint %}active{% endif %}" href="{{ url_for('vm_list') }}">
<i class="fas fa-users"></i> 사용자 관리 <i class="fas fa-tv"></i> VM 관리 (VNC)
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'subscription' in request.endpoint %}active{% endif %}" href="{{ url_for('subscriptions_page') }}">
<i class="fas fa-box text-success"></i> 구독 서비스 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'revenue' in request.endpoint %}active{% endif %}" href="#" onclick="showComingSoon('매출 대시보드')">
<i class="fas fa-chart-pie text-warning"></i> 매출 대시보드
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -331,6 +346,11 @@
toast.show(); toast.show();
} }
} }
// 준비 중 기능 알림
function showComingSoon(featureName) {
showToast(`🚧 ${featureName} 기능은 현재 개발 중입니다. 곧 만나보실 수 있습니다!`, 'warning');
}
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}

View File

@ -0,0 +1,411 @@
{% 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 %}