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>
This commit is contained in:
2025-09-11 19:48:12 +09:00
parent c37cf023c1
commit 35ecd4748e
15 changed files with 3967 additions and 0 deletions

View File

@@ -70,6 +70,37 @@
</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">
@@ -291,9 +322,132 @@ function updateAlerts() {
.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 %}

View File

@@ -162,6 +162,32 @@
</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">
@@ -315,5 +341,233 @@ function deleteMachine(machineId, machineName) {
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 %}

View File

@@ -40,6 +40,7 @@
<tr>
<th>약국 정보</th>
<th>담당자</th>
<th>구독 서비스</th>
<th>연결된 머신</th>
<th>네트워크 상태</th>
<th>액션</th>
@@ -68,6 +69,15 @@
<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>
@@ -200,6 +210,9 @@ let pharmacyModal;
document.addEventListener('DOMContentLoaded', function() {
pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal'));
// 구독 상태 로드
setTimeout(loadSubscriptionStatuses, 500); // 페이지 로드 후 약간의 지연
});
function showAddModal() {
@@ -316,6 +329,94 @@ document.getElementById('pharmacyForm').addEventListener('submit', function(e) {
}
});
// 구독 서비스 상태 로드
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 %}