headscale-tailscale-replace.../farmq-admin/templates/pharmacy/list.html
시골약사 35ecd4748e PharmQ SaaS 구독 서비스 관리 시스템 완전 구현
📋 기획 및 설계:
- 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>
2025-09-11 19:48:12 +09:00

422 lines
19 KiB
HTML

{% extends "base.html" %}
{% block title %}약국 관리 - 팜큐 약국 관리 시스템{% 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-store text-primary"></i>
약국 관리
</h1>
<p class="text-muted">등록된 약국 정보 및 연결 상태 관리</p>
</div>
<button class="btn btn-primary" onclick="showAddModal()">
<i class="fas fa-plus"></i> 새 약국 등록
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
{% if pharmacies %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>약국 정보</th>
<th>담당자</th>
<th>구독 서비스</th>
<th>연결된 머신</th>
<th>네트워크 상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for pharmacy_data in pharmacies %}
<tr>
<td>
<div>
<strong class="d-block">{{ pharmacy_data.pharmacy_name }}</strong>
<small class="text-muted">{{ pharmacy_data.business_number }}</small>
<div class="small mt-1">
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
</div>
</div>
<div class="small text-muted mt-1">
<i class="fas fa-map-marker-alt"></i> {{ pharmacy_data.address or '주소 미등록' }}
</div>
</td>
<td>
<div>
<strong>{{ pharmacy_data.manager_name or '미등록' }}</strong>
</div>
<div class="small text-muted">
<i class="fas fa-phone"></i> {{ pharmacy_data.phone or '연락처 미등록' }}
</div>
</td>
<td>
<div class="subscription-services" data-pharmacy-id="{{ pharmacy_data.id }}">
<div class="d-flex justify-content-center">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">로딩...</span>
</div>
</div>
</div>
</td>
<td>
<div class="d-flex align-items-center">
<span class="badge bg-info me-2">{{ pharmacy_data.machine_count }}대</span>
<div class="progress" style="width: 60px; 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 }}%"
title="{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }} 온라인"></div>
</div>
</div>
<div class="small text-muted">
온라인: {{ pharmacy_data.online_count }} / 오프라인: {{ pharmacy_data.offline_count }}
</div>
</td>
<td>
{% if pharmacy_data.online_count == pharmacy_data.machine_count and pharmacy_data.machine_count > 0 %}
<span class="badge bg-success">
<i class="fas fa-check-circle"></i> 모든 머신 온라인
</span>
{% elif pharmacy_data.online_count > 0 %}
<span class="badge bg-warning">
<i class="fas fa-exclamation-triangle"></i> 부분적 연결
</span>
{% elif pharmacy_data.machine_count > 0 %}
<span class="badge bg-danger">
<i class="fas fa-times-circle"></i> 전체 오프라인
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-question-circle"></i> 머신 없음
</span>
{% endif %}
</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" title="상세 정보">
<i class="fas fa-eye"></i>
</a>
<button class="btn btn-outline-warning"
onclick="showEditModal({{ pharmacy_data.id }})" title="수정">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-info" title="모니터링">
<i class="fas fa-chart-line"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-store fa-4x mb-4 text-secondary"></i>
<h4>등록된 약국이 없습니다</h4>
<p class="mb-4">첫 번째 약국을 등록하여 시작해보세요.</p>
<button class="btn btn-primary btn-lg" onclick="showAddModal()">
<i class="fas fa-plus"></i> 첫 번째 약국 등록
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 약국 등록/수정 모달 -->
<div class="modal fade" id="pharmacyModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pharmacyModalTitle">
<i class="fas fa-store"></i> 약국 정보
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="pharmacyForm">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="pharmacy_name" class="form-label">약국명 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="pharmacy_name" required>
</div>
<div class="col-md-6 mb-3">
<label for="business_number" class="form-label">사업자번호</label>
<input type="text" class="form-control" id="business_number" placeholder="000-00-00000">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="manager_name" class="form-label">담당자명</label>
<input type="text" class="form-control" id="manager_name">
</div>
<div class="col-md-6 mb-3">
<label for="phone" class="form-label">전화번호</label>
<input type="tel" class="form-control" id="phone" placeholder="000-0000-0000">
</div>
</div>
<div class="mb-3">
<label for="address" class="form-label">주소</label>
<textarea class="form-control" id="address" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="proxmox_host" class="form-label">Proxmox 호스트 IP</label>
<input type="text" class="form-control" id="proxmox_host" placeholder="192.168.1.100">
</div>
<div class="col-md-6 mb-3">
<label for="headscale_user_name" class="form-label">Headscale 사용자명</label>
<input type="text" class="form-control" id="headscale_user_name" placeholder="myuser">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 저장
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let pharmacyModal;
document.addEventListener('DOMContentLoaded', function() {
pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal'));
// 구독 상태 로드
setTimeout(loadSubscriptionStatuses, 500); // 페이지 로드 후 약간의 지연
});
function showAddModal() {
document.getElementById('pharmacyModalTitle').innerHTML =
'<i class="fas fa-plus"></i> 새 약국 등록';
// 폼 초기화
document.getElementById('pharmacyForm').reset();
// 새 등록 모드임을 표시
document.getElementById('pharmacyForm').dataset.pharmacyId = '';
document.getElementById('pharmacyForm').dataset.mode = 'add';
pharmacyModal.show();
}
function showEditModal(pharmacyId) {
document.getElementById('pharmacyModalTitle').innerHTML =
'<i class="fas fa-edit"></i> 약국 정보 수정';
// 기존 데이터를 로드하여 폼에 채우기
fetch(`/api/pharmacy/${pharmacyId}`)
.then(response => response.json())
.then(data => {
if (data.pharmacy) {
const pharmacy = data.pharmacy;
// 폼 필드에 기존 값들을 채우기 (value로 설정하여 수정 가능하게)
document.getElementById('pharmacy_name').value = pharmacy.pharmacy_name || '';
document.getElementById('business_number').value = pharmacy.business_number || '';
document.getElementById('manager_name').value = pharmacy.manager_name || '';
document.getElementById('phone').value = pharmacy.phone || '';
document.getElementById('address').value = pharmacy.address || '';
document.getElementById('proxmox_host').value = pharmacy.proxmox_host || '';
document.getElementById('headscale_user_name').value = pharmacy.headscale_user_name || '';
// 수정 모드임을 표시하기 위해 pharmacy ID를 form에 저장
document.getElementById('pharmacyForm').dataset.pharmacyId = pharmacyId;
document.getElementById('pharmacyForm').dataset.mode = 'edit';
}
})
.catch(error => {
console.error('약국 정보 로드 실패:', error);
showToast('약국 정보를 불러오는데 실패했습니다.', 'error');
});
pharmacyModal.show();
}
document.getElementById('pharmacyForm').addEventListener('submit', function(e) {
e.preventDefault();
const form = e.target;
const mode = form.dataset.mode;
const pharmacyId = form.dataset.pharmacyId;
// 폼 데이터 수집
const data = {
pharmacy_name: document.getElementById('pharmacy_name').value,
business_number: document.getElementById('business_number').value,
manager_name: document.getElementById('manager_name').value,
phone: document.getElementById('phone').value,
address: document.getElementById('address').value,
proxmox_host: document.getElementById('proxmox_host').value,
headscale_user_name: document.getElementById('headscale_user_name').value
};
if (mode === 'edit' && pharmacyId) {
// 수정 모드: PUT 요청
fetch(`/api/pharmacy/${pharmacyId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.message, 'success');
pharmacyModal.hide();
setTimeout(() => location.reload(), 1000);
} else {
showToast(result.error, 'error');
}
})
.catch(error => {
console.error('약국 정보 수정 실패:', error);
showToast('약국 정보 수정에 실패했습니다.', 'error');
});
} else {
// 새 등록 모드: POST 요청
fetch('/api/pharmacy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.message, 'success');
pharmacyModal.hide();
setTimeout(() => location.reload(), 1000);
} else {
showToast(result.error, 'error');
}
})
.catch(error => {
console.error('약국 생성 실패:', error);
showToast('약국 생성에 실패했습니다.', 'error');
});
}
});
// 구독 서비스 상태 로드
function loadSubscriptionStatuses() {
const subscriptionContainers = document.querySelectorAll('.subscription-services');
subscriptionContainers.forEach(container => {
const pharmacyId = container.dataset.pharmacyId;
fetch(`/api/pharmacy/${pharmacyId}/subscriptions`)
.then(response => response.json())
.then(result => {
if (result.success) {
displaySubscriptionStatus(container, result.data);
} else {
showSubscriptionError(container);
}
})
.catch(error => {
console.error(`약국 ${pharmacyId} 구독 정보 로드 실패:`, error);
showSubscriptionError(container);
});
});
}
function displaySubscriptionStatus(container, data) {
const subscriptions = data.active_subscriptions;
if (subscriptions.length === 0) {
container.innerHTML = `
<div class="text-center">
<span class="badge bg-light text-muted">
<i class="fas fa-minus"></i> 구독 없음
</span>
</div>
`;
return;
}
// 서비스 아이콘 맵핑
const serviceIcons = {
'CLOUD_PC': { icon: '💻', color: 'primary', name: '클라우드PC' },
'AI_CCTV': { icon: '📷', color: 'info', name: 'AI CCTV' },
'CRM': { icon: '📊', color: 'warning', name: 'CRM' }
};
let html = '<div class="d-flex flex-wrap gap-1">';
let totalFee = 0;
subscriptions.forEach(sub => {
const service = serviceIcons[sub.code] || { icon: '📦', color: 'secondary', name: sub.name };
totalFee += sub.monthly_fee;
html += `
<span class="badge bg-${service.color}" title="${service.name} - ₩${sub.monthly_fee.toLocaleString()}/월" data-bs-toggle="tooltip">
${service.icon}
</span>
`;
});
html += '</div>';
// 총 월 구독료 표시
html += `
<div class="small text-muted text-center mt-1">
<strong>₩${totalFee.toLocaleString()}/월</strong>
</div>
`;
container.innerHTML = html;
// 툴팁 초기화
const tooltipTriggerList = container.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(tooltipTriggerEl => {
new bootstrap.Tooltip(tooltipTriggerEl);
});
}
function showSubscriptionError(container) {
container.innerHTML = `
<div class="text-center">
<span class="badge bg-danger">
<i class="fas fa-exclamation-triangle"></i> 오류
</span>
</div>
`;
}
// 구독 상태 로드 함수들은 위의 DOMContentLoaded에서 호출됨
// 테이블 정렬 및 검색 기능 추가 (향후)
</script>
{% endblock %}