📋 기획 및 설계: - 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>
573 lines
24 KiB
HTML
573 lines
24 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"><a href="{{ url_for('pharmacy_list') }}">약국 관리</a></li>
|
|
<li class="breadcrumb-item active">{{ pharmacy.pharmacy_name }}</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 class="d-flex align-items-center">
|
|
<div class="me-3">
|
|
{% if pharmacy.online_machines > 0 %}
|
|
<i class="fas fa-store fa-3x text-success"></i>
|
|
{% else %}
|
|
<i class="fas fa-store fa-3x text-muted"></i>
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
<h1 class="h2 mb-0">{{ pharmacy.pharmacy_name }}</h1>
|
|
<p class="text-muted mb-1">{{ pharmacy.manager_name or '담당자 미등록' }}</p>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
{% if pharmacy.online_machines == pharmacy.total_machines and pharmacy.total_machines > 0 %}
|
|
<span class="badge bg-success">
|
|
<i class="fas fa-check-circle"></i> 모든 머신 온라인
|
|
</span>
|
|
{% elif pharmacy.online_machines > 0 %}
|
|
<span class="badge bg-warning">
|
|
<i class="fas fa-exclamation-triangle"></i> 부분적 연결
|
|
</span>
|
|
{% elif pharmacy.total_machines > 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 %}
|
|
<small class="text-muted">
|
|
머신: {{ pharmacy.online_machines }}/{{ pharmacy.total_machines }} 온라인
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" onclick="refreshPharmacyDetail()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
<button class="btn btn-outline-primary">
|
|
<i class="fas fa-edit"></i> 수정
|
|
</button>
|
|
<button class="btn btn-outline-info">
|
|
<i class="fas fa-chart-line"></i> 모니터링
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 기본 정보 -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-info-circle"></i> 기본 정보</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<table class="table table-borderless">
|
|
<tr>
|
|
<th width="30%">약국명</th>
|
|
<td><strong>{{ pharmacy.pharmacy_name }}</strong></td>
|
|
</tr>
|
|
<tr>
|
|
<th>사업자번호</th>
|
|
<td>{{ pharmacy.business_number or '미등록' }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>담당자</th>
|
|
<td>{{ pharmacy.manager_name or '미등록' }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>연락처</th>
|
|
<td>{{ pharmacy.phone or '미등록' }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>주소</th>
|
|
<td>{{ pharmacy.address or '주소 미등록' }}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-network-wired"></i> 네트워크 정보</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<table class="table table-borderless">
|
|
<tr>
|
|
<th width="30%">Proxmox 호스트</th>
|
|
<td>
|
|
{% if pharmacy.proxmox_host %}
|
|
<code>{{ pharmacy.proxmox_host }}</code>
|
|
{% else %}
|
|
<span class="text-muted">미설정</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Headscale 사용자</th>
|
|
<td>
|
|
{% if pharmacy.headscale_user_name %}
|
|
<span class="badge bg-primary">{{ pharmacy.headscale_user_name }}</span>
|
|
{% else %}
|
|
<span class="text-muted">연결되지 않음</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>등록일</th>
|
|
<td>
|
|
{% if pharmacy.created_at %}
|
|
{% if pharmacy.created_at.__class__.__name__ == 'datetime' %}
|
|
{{ pharmacy.created_at.strftime('%Y년 %m월 %d일 %H:%M') }}
|
|
{% else %}
|
|
{{ pharmacy.created_at }}
|
|
{% endif %}
|
|
{% else %}
|
|
알 수 없음
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>최종 업데이트</th>
|
|
<td>
|
|
{% if pharmacy.updated_at %}
|
|
{% if pharmacy.updated_at.__class__.__name__ == 'datetime' %}
|
|
{{ pharmacy.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
|
{% else %}
|
|
{{ pharmacy.updated_at }}
|
|
{% endif %}
|
|
{% else %}
|
|
알 수 없음
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</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>
|
|
<button class="btn btn-outline-success btn-sm" onclick="showSubscriptionModal()">
|
|
<i class="fas fa-plus"></i> 새 서비스 구독
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="subscription-content">
|
|
<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>
|
|
|
|
<!-- 연결된 머신 목록 -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-desktop"></i>
|
|
연결된 머신 목록
|
|
<span class="badge bg-info ms-2">{{ pharmacy.total_machines }}대</span>
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if machines %}
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>머신명</th>
|
|
<th>IP 주소</th>
|
|
<th>상태</th>
|
|
<th>등록 방식</th>
|
|
<th>마지막 접속</th>
|
|
<th>액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for machine in machines %}
|
|
<tr>
|
|
<td>
|
|
<div>
|
|
<strong>{{ machine.given_name or machine.hostname }}</strong>
|
|
{% if machine.given_name and machine.hostname and machine.given_name != machine.hostname %}
|
|
<br><small class="text-muted">{{ machine.hostname }}</small>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{% if machine.ipv4 %}
|
|
<code>{{ machine.ipv4 }}</code>
|
|
{% if machine.ipv6 %}
|
|
<br><code class="small">{{ machine.ipv6 }}</code>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="text-muted">IP 미할당</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if machine.online %}
|
|
<span class="badge bg-success">
|
|
<i class="fas fa-circle"></i> 온라인
|
|
</span>
|
|
{% else %}
|
|
<span class="badge bg-danger">
|
|
<i class="fas fa-circle"></i> 오프라인
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-secondary">{{ machine.register_method }}</span>
|
|
</td>
|
|
<td>
|
|
{% if machine.last_seen %}
|
|
{% if machine.last_seen.__class__.__name__ == 'datetime' %}
|
|
{{ machine.last_seen.strftime('%m/%d %H:%M') }}
|
|
{% else %}
|
|
{{ machine.last_seen }}
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="text-muted">알 수 없음</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<a href="{{ url_for('machine_detail', machine_id=machine.id) }}"
|
|
class="btn btn-outline-primary" title="상세 정보">
|
|
<i class="fas fa-eye"></i>
|
|
</a>
|
|
<button class="btn btn-outline-warning" title="재연결">
|
|
<i class="fas fa-sync"></i>
|
|
</button>
|
|
<button class="btn btn-outline-danger"
|
|
onclick="confirmDeleteMachine({{ machine.id }}, '{{ machine.given_name or machine.hostname }}')"
|
|
title="연결 해제">
|
|
<i class="fas fa-unlink"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center text-muted py-5">
|
|
<i class="fas fa-desktop fa-4x mb-4 text-secondary"></i>
|
|
<h4>연결된 머신이 없습니다</h4>
|
|
<p class="mb-4">
|
|
{% if pharmacy.headscale_user_name %}
|
|
"{{ pharmacy.headscale_user_name }}" 사용자에 연결된 머신이 없습니다.
|
|
{% else %}
|
|
이 약국은 Headscale 사용자와 연결되지 않았습니다.<br>
|
|
사용자 관리에서 사용자를 이 약국에 연결해주세요.
|
|
{% endif %}
|
|
</p>
|
|
{% if not pharmacy.headscale_user_name %}
|
|
<a href="{{ url_for('user_list') }}" class="btn btn-primary">
|
|
<i class="fas fa-users"></i> 사용자 관리로 이동
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
function refreshPharmacyDetail() {
|
|
showToast('약국 정보를 새로고침 중...', 'info');
|
|
setTimeout(() => {
|
|
location.reload();
|
|
}, 1000);
|
|
}
|
|
|
|
function confirmDeleteMachine(machineId, machineName) {
|
|
if (confirm(`정말로 머신 "${machineName}"의 연결을 해제하시겠습니까?`)) {
|
|
deleteMachine(machineId, machineName);
|
|
}
|
|
}
|
|
|
|
function deleteMachine(machineId, machineName) {
|
|
fetch(`/api/nodes/${machineId}/delete`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast(data.message, 'success');
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
showToast(data.error, 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('머신 삭제 오류:', error);
|
|
showToast('머신 삭제 중 오류가 발생했습니다.', 'error');
|
|
});
|
|
}
|
|
|
|
// 구독 정보 로드
|
|
function loadSubscriptionInfo() {
|
|
const pharmacyId = {{ pharmacy.id }};
|
|
|
|
fetch(`/api/pharmacy/${pharmacyId}/subscriptions`)
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
displaySubscriptionInfo(result.data);
|
|
} else {
|
|
showSubscriptionError();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('구독 정보 로드 오류:', error);
|
|
showSubscriptionError();
|
|
});
|
|
}
|
|
|
|
function displaySubscriptionInfo(data) {
|
|
const activeSubscriptions = data.active_subscriptions;
|
|
const availableServices = data.available_services;
|
|
|
|
let html = '';
|
|
|
|
// 활성 구독 서비스
|
|
if (activeSubscriptions.length > 0) {
|
|
html += `
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h6 class="mb-3"><i class="fas fa-check-circle text-success"></i> 구독 중인 서비스</h6>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
`;
|
|
|
|
let totalFee = 0;
|
|
activeSubscriptions.forEach(sub => {
|
|
totalFee += sub.monthly_fee;
|
|
|
|
const serviceIcons = {
|
|
'CLOUD_PC': { icon: '💻', color: 'primary' },
|
|
'AI_CCTV': { icon: '📷', color: 'info' },
|
|
'CRM': { icon: '📊', color: 'warning' }
|
|
};
|
|
|
|
const service = serviceIcons[sub.code] || { icon: '📦', color: 'secondary' };
|
|
|
|
html += `
|
|
<div class="col-lg-4 col-md-6 mb-3">
|
|
<div class="card border-${service.color}">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<div class="me-3" style="font-size: 2rem;">${service.icon}</div>
|
|
<div class="flex-grow-1">
|
|
<h6 class="mb-1">${sub.name}</h6>
|
|
<div class="small text-muted">
|
|
시작일: ${sub.start_date}<br>
|
|
다음결제: ${sub.next_billing_date}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong class="text-${service.color}">₩${sub.monthly_fee.toLocaleString()}/월</strong>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-outline-danger btn-sm" onclick="cancelSubscription(${sub.id}, '${sub.name}')">
|
|
<i class="fas fa-times"></i> 해지
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="alert alert-info">
|
|
<strong><i class="fas fa-calculator"></i> 총 월 구독료: ₩${totalFee.toLocaleString()}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 구독 가능한 서비스
|
|
if (availableServices.length > 0) {
|
|
html += `
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h6 class="mb-3"><i class="fas fa-plus-circle text-primary"></i> 구독 가능한 서비스</h6>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
`;
|
|
|
|
availableServices.forEach(service => {
|
|
const serviceIcons = {
|
|
'CLOUD_PC': { icon: '💻', color: 'primary' },
|
|
'AI_CCTV': { icon: '📷', color: 'info' },
|
|
'CRM': { icon: '📊', color: 'warning' }
|
|
};
|
|
|
|
const serviceInfo = serviceIcons[service.code] || { icon: '📦', color: 'secondary' };
|
|
|
|
html += `
|
|
<div class="col-lg-4 col-md-6 mb-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<div class="me-3" style="font-size: 2rem;">${serviceInfo.icon}</div>
|
|
<div class="flex-grow-1">
|
|
<h6 class="mb-1">${service.name}</h6>
|
|
<p class="small text-muted mb-2">${service.description}</p>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong class="text-${serviceInfo.color}">₩${service.monthly_price.toLocaleString()}/월</strong>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-${serviceInfo.color} btn-sm" onclick="subscribeService(${service.id}, '${service.name}', ${service.monthly_price})">
|
|
<i class="fas fa-plus"></i> 구독
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>';
|
|
}
|
|
|
|
if (activeSubscriptions.length === 0 && availableServices.length === 0) {
|
|
html = `
|
|
<div class="text-center py-4">
|
|
<i class="fas fa-box-open fa-3x text-muted mb-3"></i>
|
|
<p class="text-muted">구독 가능한 서비스가 없습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
document.getElementById('subscription-content').innerHTML = html;
|
|
}
|
|
|
|
function showSubscriptionError() {
|
|
document.getElementById('subscription-content').innerHTML = `
|
|
<div class="text-center py-4">
|
|
<i class="fas fa-exclamation-triangle fa-3x text-warning mb-3"></i>
|
|
<p class="text-muted">구독 정보를 불러오는 중 오류가 발생했습니다.</p>
|
|
<button class="btn btn-outline-primary btn-sm" onclick="loadSubscriptionInfo()">
|
|
<i class="fas fa-redo"></i> 다시 시도
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function subscribeService(serviceId, serviceName, monthlyPrice) {
|
|
if (!confirm(`"${serviceName}" 서비스를 월 ₩${monthlyPrice.toLocaleString()}에 구독하시겠습니까?`)) {
|
|
return;
|
|
}
|
|
|
|
const pharmacyId = {{ pharmacy.id }};
|
|
|
|
fetch('/api/subscriptions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
pharmacy_id: pharmacyId,
|
|
product_id: serviceId,
|
|
monthly_fee: monthlyPrice
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
showToast(result.message, 'success');
|
|
setTimeout(() => loadSubscriptionInfo(), 1000);
|
|
} else {
|
|
showToast(result.error, 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('구독 생성 오류:', error);
|
|
showToast('구독 생성 중 오류가 발생했습니다.', 'error');
|
|
});
|
|
}
|
|
|
|
function cancelSubscription(subscriptionId, serviceName) {
|
|
if (!confirm(`"${serviceName}" 서비스 구독을 해지하시겠습니까?`)) {
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/subscriptions/${subscriptionId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
showToast(result.message, 'success');
|
|
setTimeout(() => loadSubscriptionInfo(), 1000);
|
|
} else {
|
|
showToast(result.error, 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('구독 해지 오류:', error);
|
|
showToast('구독 해지 중 오류가 발생했습니다.', 'error');
|
|
});
|
|
}
|
|
|
|
// 페이지 로드 시 구독 정보 로드
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
setTimeout(() => loadSubscriptionInfo(), 500);
|
|
});
|
|
</script>
|
|
{% endblock %} |