📋 기획 및 설계: - PharmQ SaaS 서비스 기획서 작성 - 구독 서비스 라인업 정의 (클라우드PC, AI CCTV, CRM) - DB 스키마 설계 및 API 아키텍처 설계 🗄️ 데이터베이스 구조: - service_products: 서비스 상품 마스터 테이블 - pharmacy_subscriptions: 약국별 구독 현황 테이블 - subscription_usage_logs: 서비스 이용 로그 테이블 - billing_history: 결제 이력 테이블 - 샘플 데이터 자동 생성 (21개 구독, 월 118만원 매출) 🔧 백엔드 API 구현: - 구독 현황 통계 API (/api/subscriptions/stats) - 약국별 구독 조회 API (/api/pharmacies/subscriptions) - 구독 상세 정보 API (/api/pharmacy/{id}/subscriptions) - 구독 생성/해지 API (/api/subscriptions) 🖥️ 프론트엔드 UI 구현: - 대시보드 구독 현황 카드 (월 매출, 구독 수, 구독률 등) - 약국 목록에 구독 상태 아이콘 및 월 구독료 표시 - 약국 상세 페이지 구독 서비스 섹션 추가 - 실시간 구독 생성/해지 기능 구현 ✨ 주요 특징: - 서비스별 색상 코딩 및 이모지 아이콘 시스템 - 실시간 업데이트 (구독 생성/해지 즉시 반영) - 반응형 디자인 (모바일/태블릿 최적화) - 툴팁 기반 상세 정보 표시 📊 현재 구독 현황: - 총 월 매출: ₩1,180,000 - 구독 약국: 10/14개 (71.4%) - AI CCTV: 6개 약국, CRM: 10개 약국, 클라우드PC: 5개 약국 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
453 lines
19 KiB
HTML
453 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}대시보드 - 팜큐 약국 관리 시스템{% endblock %}
|
|
|
|
{% block breadcrumb %}
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item active">
|
|
<i class="fas fa-tachometer-alt"></i> 대시보드
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<h1 class="h2 mb-0">
|
|
<i class="fas fa-tachometer-alt text-primary"></i>
|
|
대시보드
|
|
</h1>
|
|
<p class="text-muted">팜큐 약국 네트워크 전체 현황</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 카드 -->
|
|
<div class="row mb-4">
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card stat-card">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number" id="total-pharmacies">{{ stats.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" id="online-machines" data-stat="online">{{ stats.online_machines }}</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-circle text-success"></i> 온라인 머신
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number" id="offline-machines" data-stat="offline">{{ stats.offline_machines }}</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-circle text-danger"></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" id="avg-temp">{{ stats.avg_cpu_temp }}°C</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-thermometer-half"></i> 평균 CPU 온도
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 구독 서비스 현황 -->
|
|
<div class="row mb-4">
|
|
<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-box text-success"></i> 구독 서비스 현황
|
|
</h5>
|
|
<a href="/subscriptions" class="btn btn-outline-success btn-sm">
|
|
<i class="fas fa-cog"></i> 관리하기
|
|
</a>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row" id="subscription-stats">
|
|
<!-- 구독 통계가 여기에 로드됩니다 -->
|
|
<div class="col-12 text-center">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 서비스별 구독 현황 -->
|
|
<div class="row mt-4" id="service-breakdown">
|
|
<!-- 서비스별 상세 정보가 여기에 로드됩니다 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- 실시간 알림 -->
|
|
<div class="col-lg-6 mb-4">
|
|
<div class="card h-100">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-exclamation-triangle text-warning"></i> 실시간 알림
|
|
</h5>
|
|
<span class="badge bg-primary">{{ stats.alerts|length }}</span>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if stats.alerts %}
|
|
{% for alert in stats.alerts %}
|
|
<div class="alert-item p-3 mb-2 bg-light {% if alert.type == 'warning' %}alert-warning{% elif alert.type == 'danger' %}alert-danger{% endif %}">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>
|
|
{% if alert.level == 'high_temperature' %}
|
|
<i class="fas fa-thermometer-full text-danger"></i>
|
|
{% elif alert.level == 'high_disk' %}
|
|
<i class="fas fa-hdd text-warning"></i>
|
|
{% else %}
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
{% endif %}
|
|
{{ alert.machine.hostname }}
|
|
</strong>
|
|
<div class="small text-muted">{{ alert.message }}</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<span class="badge bg-{{ alert.type }}">
|
|
{{ alert.value }}{% if alert.level == 'high_temperature' %}°C{% else %}%{% endif %}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
|
|
<p>모든 시스템이 정상 작동 중입니다.</p>
|
|
</div>
|
|
{% endif %}
|
|
</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-pie text-info"></i> 전체 성능 현황
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row text-center">
|
|
<div class="col-6 mb-3">
|
|
<div class="position-relative">
|
|
<canvas id="cpuChart" width="100" height="100"></canvas>
|
|
<div class="position-absolute top-50 start-50 translate-middle">
|
|
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
|
|
<div class="small text-muted">CPU</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 mb-3">
|
|
<div class="position-relative">
|
|
<canvas id="memoryChart" width="100" height="100"></canvas>
|
|
<div class="position-absolute top-50 start-50 translate-middle">
|
|
<div class="fw-bold">75.0%</div>
|
|
<div class="small text-muted">메모리</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="position-relative">
|
|
<canvas id="diskChart" width="100" height="100"></canvas>
|
|
<div class="position-absolute top-50 start-50 translate-middle">
|
|
<div class="fw-bold">60.0%</div>
|
|
<div class="small text-muted">디스크</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="text-center">
|
|
<div class="display-4">🌡️</div>
|
|
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
|
|
<div class="small text-muted">평균 온도</div>
|
|
</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-store text-primary"></i> 약국별 상태
|
|
</h5>
|
|
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-outline-primary btn-sm">
|
|
<i class="fas fa-list"></i> 전체 보기
|
|
</a>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if pharmacies %}
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>약국명</th>
|
|
<th>Headscale 사용자</th>
|
|
<th>사업자번호</th>
|
|
<th>연결된 머신</th>
|
|
<th>온라인 상태</th>
|
|
<th>액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for pharmacy_data in pharmacies %}
|
|
<tr>
|
|
<td>
|
|
<strong>{{ pharmacy_data.pharmacy_name }}</strong><br>
|
|
<small class="text-muted">{{ pharmacy_data.manager_name }}</small>
|
|
</td>
|
|
<td>
|
|
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
|
|
</td>
|
|
<td>{{ pharmacy_data.business_number }}</td>
|
|
<td>
|
|
<span class="badge bg-info">{{ pharmacy_data.machine_count }}대</span>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<div class="progress me-2" style="width: 100px; height: 8px;">
|
|
<div class="progress-bar bg-success"
|
|
style="width: {{ (pharmacy_data.online_count / pharmacy_data.machine_count * 100) if pharmacy_data.machine_count > 0 else 0 }}%"></div>
|
|
</div>
|
|
<small>{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }}</small>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy_data.id) }}"
|
|
class="btn btn-outline-primary">상세</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center text-muted py-4">
|
|
<i class="fas fa-store fa-3x mb-3"></i>
|
|
<p>등록된 약국이 없습니다.</p>
|
|
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-primary">
|
|
<i class="fas fa-plus"></i> 약국 등록하기
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// 성능 차트 생성
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
createDoughnutChart('cpuChart', {{ stats.avg_cpu_temp }}, '온도', '#3b82f6');
|
|
createDoughnutChart('memoryChart', 75, '메모리', '#10b981');
|
|
createDoughnutChart('diskChart', 60, '디스크', '#f59e0b');
|
|
});
|
|
|
|
// 실시간 통계 업데이트
|
|
function updateStats() {
|
|
fetch('/api/dashboard/stats')
|
|
.then(response => response.json())
|
|
.then(stats => {
|
|
// 머신 상태 업데이트
|
|
const onlineElement = document.querySelector('[data-stat="online"]');
|
|
const offlineElement = document.querySelector('[data-stat="offline"]');
|
|
const totalElement = document.querySelector('[data-stat="total"]');
|
|
|
|
if (onlineElement) onlineElement.textContent = stats.online_machines;
|
|
if (offlineElement) offlineElement.textContent = stats.offline_machines;
|
|
if (totalElement) totalElement.textContent = stats.total_machines;
|
|
|
|
// CPU 온도 차트 업데이트
|
|
updateChartValue('cpuChart', stats.avg_cpu_temp);
|
|
})
|
|
.catch(error => console.error('Stats update failed:', error));
|
|
}
|
|
|
|
// 실시간 알림 업데이트
|
|
function updateAlerts() {
|
|
fetch('/api/alerts')
|
|
.then(response => response.json())
|
|
.then(alerts => {
|
|
// 알림 개수 업데이트
|
|
const alertBadge = document.querySelector('.card-header .badge');
|
|
if (alertBadge) {
|
|
alertBadge.textContent = alerts.length;
|
|
}
|
|
|
|
// 새로운 알림이 있으면 토스트 표시
|
|
alerts.forEach(alert => {
|
|
if (!document.querySelector(`[data-machine-id="${alert.machine.id}"]`)) {
|
|
showToast(`${alert.machine.hostname}: ${alert.message}`, alert.type);
|
|
}
|
|
});
|
|
})
|
|
.catch(error => console.error('Alert update failed:', error));
|
|
}
|
|
|
|
// 구독 현황 로드
|
|
function loadSubscriptionStats() {
|
|
fetch('/api/subscriptions/stats')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
updateSubscriptionDisplay(result.data);
|
|
} else {
|
|
console.error('구독 통계 로드 실패:', result.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('구독 통계 API 오류:', error);
|
|
showSubscriptionError();
|
|
});
|
|
}
|
|
|
|
function updateSubscriptionDisplay(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').innerHTML = statsHtml;
|
|
|
|
// 서비스별 구독 현황
|
|
const serviceIconMap = {
|
|
'CLOUD_PC': '💻',
|
|
'AI_CCTV': '📷',
|
|
'CRM': '📊'
|
|
};
|
|
|
|
const serviceColorMap = {
|
|
'CLOUD_PC': '#4f46e5',
|
|
'AI_CCTV': '#0891b2',
|
|
'CRM': '#ca8a04'
|
|
};
|
|
|
|
let servicesHtml = '';
|
|
data.services.forEach(service => {
|
|
const icon = serviceIconMap[service.code] || '📦';
|
|
const color = serviceColorMap[service.code] || '#6b7280';
|
|
const percentage = data.total_pharmacies > 0 ? Math.round(service.count / data.total_pharmacies * 100) : 0;
|
|
|
|
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-2">
|
|
<div class="me-3" style="font-size: 2rem;">${icon}</div>
|
|
<div class="flex-grow-1">
|
|
<h6 class="mb-1">${service.name}</h6>
|
|
<div class="progress mb-1" style="height: 8px;">
|
|
<div class="progress-bar" style="background-color: ${color}; width: ${percentage}%"></div>
|
|
</div>
|
|
<small class="text-muted">${service.count}개 약국 구독 (${percentage}%)</small>
|
|
</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<strong style="color: ${color};">₩${service.revenue.toLocaleString()}/월</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
document.getElementById('service-breakdown').innerHTML = servicesHtml;
|
|
}
|
|
|
|
function showSubscriptionError() {
|
|
document.getElementById('subscription-stats').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>
|
|
`;
|
|
}
|
|
|
|
// 페이지 로드 시 구독 현황 로드
|
|
loadSubscriptionStats();
|
|
|
|
// 통계 업데이트 (10초마다 - 더 자주)
|
|
setInterval(updateStats, 10000);
|
|
// 알림 업데이트 (30초마다)
|
|
setInterval(updateAlerts, 30000);
|
|
// 구독 현황 업데이트 (60초마다)
|
|
setInterval(loadSubscriptionStats, 60000);
|
|
</script>
|
|
{% endblock %} |