headscale-tailscale-replace.../farmq-admin/templates/base.html
시골약사 ca61a89739 🏥 Add complete FARMQ Admin Flask application
## Features
- 한국어 Flask 관리 인터페이스 with Bootstrap 5
- Headscale과 분리된 독립 데이터베이스 구조
- 약국 관리 시스템 (pharmacy management)
- 머신 모니터링 및 상태 관리
- 실시간 대시보드 with 통계 및 알림
- Headscale 사용자명과 약국명 분리 관리

## Database Architecture
- 별도 FARMQ SQLite DB (farmq.sqlite)
- Headscale DB와 외래키 충돌 방지
- 느슨한 결합 설계 (ID 참조만 사용)

## UI Components
- 반응형 대시보드 with 실시간 통계
- 약국별 머신 상태 모니터링
- 한국어 지역화 및 사용자 친화적 인터페이스
- 머신 온라인/오프라인 상태 표시 (24시간 타임아웃)

## API Endpoints
- `/api/sync/machines` - Headscale 머신 동기화
- `/api/sync/users` - Headscale 사용자 동기화
- `/api/pharmacy/<id>/update` - 약국 정보 업데이트
- 대시보드 통계 및 알림 API

## Problem Resolution
- Fixed foreign key conflicts preventing Windows client connections
- Resolved machine online status detection with proper timeout handling
- Separated technical Headscale usernames from business pharmacy names

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 17:44:56 +09:00

341 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}팜큐 약국 관리 시스템{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom CSS -->
<style>
:root {
--farmq-primary: #2c5282;
--farmq-secondary: #4299e1;
--farmq-success: #48bb78;
--farmq-warning: #ed8936;
--farmq-danger: #f56565;
--farmq-light: #f7fafc;
--farmq-dark: #2d3748;
}
body {
background-color: var(--farmq-light);
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.navbar-brand {
font-weight: bold;
color: var(--farmq-primary) !important;
}
.sidebar {
min-height: calc(100vh - 56px);
background: linear-gradient(180deg, var(--farmq-primary) 0%, var(--farmq-secondary) 100%);
}
.sidebar .nav-link {
color: rgba(255, 255, 255, 0.8);
border-radius: 8px;
margin: 2px 0;
transition: all 0.3s ease;
}
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
background-color: rgba(255, 255, 255, 0.1);
color: white;
transform: translateX(5px);
}
.main-content {
padding: 2rem;
}
.card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 12px;
transition: transform 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.stat-card {
background: linear-gradient(135deg, var(--farmq-primary) 0%, var(--farmq-secondary) 100%);
color: white;
}
.stat-card .card-body {
padding: 2rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
line-height: 1;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.alert-item {
border-left: 4px solid;
border-radius: 0 8px 8px 0;
margin-bottom: 0.5rem;
}
.alert-warning {
border-left-color: var(--farmq-warning);
}
.alert-danger {
border-left-color: var(--farmq-danger);
}
.status-online {
color: var(--farmq-success);
}
.status-offline {
color: var(--farmq-danger);
}
.status-warning {
color: var(--farmq-warning);
}
.footer {
background-color: var(--farmq-dark);
color: white;
text-align: center;
padding: 1rem;
margin-top: 3rem;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.sidebar {
min-height: auto;
}
.main-content {
padding: 1rem;
}
.stat-number {
font-size: 1.8rem;
}
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 상단 네비게이션 -->
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard') }}">
<i class="fas fa-hospital"></i> 팜큐 약국 관리 시스템
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user-circle"></i> 관리자
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#"><i class="fas fa-cog"></i> 설정</a></li>
<li><a class="dropdown-item" href="#"><i class="fas fa-question-circle"></i> 도움말</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#"><i class="fas fa-sign-out-alt"></i> 로그아웃</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- 사이드바 -->
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
<div class="position-sticky pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
<i class="fas fa-tachometer-alt"></i> 대시보드
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'pharmacy' in request.endpoint %}active{% endif %}" href="{{ url_for('pharmacy_list') }}">
<i class="fas fa-store"></i> 약국 관리
</a>
</li>
<li class="nav-item">
<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> 머신 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i class="fas fa-users"></i> 사용자 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i class="fas fa-chart-line"></i> 모니터링
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i class="fas fa-cog"></i> 설정
</a>
</li>
</ul>
<hr class="my-3" style="border-color: rgba(255,255,255,0.2);">
<!-- 빠른 링크 -->
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="http://localhost:3000/admin/" target="_blank">
<i class="fas fa-external-link-alt"></i> Headplane UI
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="refreshData()">
<i class="fas fa-sync-alt"></i> 데이터 새로고침
</a>
</li>
</ul>
</div>
</nav>
<!-- 메인 콘텐츠 -->
<main class="col-md-9 ms-sm-auto col-lg-10 main-content">
{% block breadcrumb %}{% endblock %}
<!-- 알림 메시지 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- 푸터 -->
<footer class="footer">
<div class="container">
<span>&copy; 2025 팜큐(FARMQ). Powered by Flask + Headscale</span>
</div>
</footer>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- 공통 JavaScript -->
<script>
// 데이터 새로고침
function refreshData() {
location.reload();
}
// 실시간 업데이트 (5초마다)
setInterval(function() {
// 현재 페이지가 대시보드인 경우 실시간 업데이트
if (window.location.pathname === '/') {
updateDashboardStats();
}
}, 5000);
function updateDashboardStats() {
fetch('/api/dashboard/stats')
.then(response => response.json())
.then(data => {
// 통계 업데이트
document.getElementById('total-pharmacies').textContent = data.total_pharmacies;
document.getElementById('online-machines').textContent = data.online_machines;
document.getElementById('offline-machines').textContent = data.offline_machines;
document.getElementById('avg-temp').textContent = data.avg_cpu_temp + '°C';
})
.catch(error => console.error('Stats update failed:', error));
}
// 차트 생성 함수
function createDoughnutChart(elementId, value, label, color) {
const ctx = document.getElementById(elementId);
if (!ctx) return;
new Chart(ctx, {
type: 'doughnut',
data: {
datasets: [{
data: [value, 100 - value],
backgroundColor: [color, '#e2e8f0'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%',
plugins: {
legend: {
display: false
}
}
}
});
}
// 토스트 알림 표시
function showToast(message, type = 'info') {
const toastHtml = `
<div class="toast align-items-center text-bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
const toastContainer = document.getElementById('toast-container');
if (toastContainer) {
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toast = new bootstrap.Toast(toastContainer.lastElementChild);
toast.show();
}
}
</script>
{% block extra_js %}{% endblock %}
<!-- 토스트 컨테이너 -->
<div id="toast-container" class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1200;"></div>
</body>
</html>