feat: Flask 애플리케이션 모듈화 및 웹 대시보드 구현
- Flask Blueprint 아키텍처로 전환 (dashboard, upload, backup, status) - app.py 681줄 95줄로 축소 (86% 감소) - HTML 템플릿 모듈화 (base.html + 기능별 templates) - CSS/JS 파일 분리 (common + 기능별 파일) - 대시보드 기능 추가 (통계, 주간 예보, 방문객 추이) - 파일 업로드 웹 인터페이스 구현 - 백업/복구 관리 UI 구현 - Docker 배포 환경 개선 - .gitignore 업데이트 (uploads, backups, cache 등)
This commit is contained in:
867
app/templates/index.html
Normal file
867
app/templates/index.html
Normal file
@ -0,0 +1,867 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>First Garden - POS 데이터 대시보드</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--success-color: #198754;
|
||||
--danger-color: #dc3545;
|
||||
--warning-color: #ffc107;
|
||||
--info-color: #0dcaf0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.container-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 탭 네비게이션 */
|
||||
.nav-tabs {
|
||||
background: white;
|
||||
border: none;
|
||||
padding: 0 20px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #666;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
font-weight: 500;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 탭 콘텐츠 */
|
||||
.tab-content {
|
||||
background: white;
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.stat-card {
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.stat-card.okpos-product {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.stat-card.okpos-receipt {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.stat-card.upsolution {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.stat-card.weather {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-card .stat-date {
|
||||
font-size: 11px;
|
||||
margin-top: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 테이블 스타일 */
|
||||
.table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background: #f8f9fa;
|
||||
border-top: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: 12px 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn-custom {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
padding: 10px 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary.btn-custom {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary.btn-custom:hover {
|
||||
background: #0b5ed7;
|
||||
border-color: #0b5ed7;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 드롭존 */
|
||||
.drop-zone {
|
||||
border: 3px dashed var(--primary-color);
|
||||
border-radius: 10px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
background: #e7f1ff;
|
||||
border-color: #0b5ed7;
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
background: #cfe2ff;
|
||||
border-color: #0b5ed7;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 차트 컨테이너 */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 날짜 피커 */
|
||||
.date-range-picker {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.date-range-picker input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.date-range-picker button {
|
||||
border-radius: 8px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
/* 파일 아이템 */
|
||||
.file-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-item .file-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 백업 아이템 */
|
||||
.backup-item {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backup-filename {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.backup-size {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.stat-card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.date-range-picker {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-main">
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<h1>
|
||||
<i class="bi bi-graph-up"></i>
|
||||
First Garden POS 데이터 대시보드
|
||||
</h1>
|
||||
<p>실시간 데이터 모니터링 및 파일 관리 시스템</p>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard-panel" type="button" role="tab">
|
||||
<i class="bi bi-speedometer2"></i> 대시보드
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload-panel" type="button" role="tab">
|
||||
<i class="bi bi-cloud-upload"></i> 파일 업로드
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="backup-tab" data-bs-toggle="tab" data-bs-target="#backup-panel" type="button" role="tab">
|
||||
<i class="bi bi-cloud-check"></i> 백업 관리
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 탭 콘텐츠 -->
|
||||
<div class="tab-content">
|
||||
<!-- ===== 대시보드 탭 ===== -->
|
||||
<div class="tab-pane fade show active" id="dashboard-panel" role="tabpanel">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card okpos-product">
|
||||
<h3>OKPOS 상품별</h3>
|
||||
<div class="stat-value" id="okpos-product-count">-</div>
|
||||
<div class="stat-label">총 데이터</div>
|
||||
<div class="stat-label" id="okpos-product-days">-</div>
|
||||
<div class="stat-date" id="okpos-product-date">최종: -</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card okpos-receipt">
|
||||
<h3>OKPOS 영수증</h3>
|
||||
<div class="stat-value" id="okpos-receipt-count">-</div>
|
||||
<div class="stat-label">총 데이터</div>
|
||||
<div class="stat-label" id="okpos-receipt-days">-</div>
|
||||
<div class="stat-date" id="okpos-receipt-date">최종: -</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card upsolution">
|
||||
<h3>UPSolution</h3>
|
||||
<div class="stat-value" id="upsolution-count">-</div>
|
||||
<div class="stat-label">총 데이터</div>
|
||||
<div class="stat-label" id="upsolution-days">-</div>
|
||||
<div class="stat-date" id="upsolution-date">최종: -</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card weather">
|
||||
<h3>날씨 데이터</h3>
|
||||
<div class="stat-value" id="weather-count">-</div>
|
||||
<div class="stat-label">총 데이터</div>
|
||||
<div class="stat-label" id="weather-days">-</div>
|
||||
<div class="stat-date" id="weather-date">최종: -</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 주간 예보 테이블 -->
|
||||
<div style="margin-top: 30px;">
|
||||
<h5 style="margin-bottom: 20px; font-weight: 600; color: #333;">
|
||||
<i class="bi bi-calendar-event"></i> 이번주 예상 날씨 & 방문객
|
||||
</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>최저기온</th>
|
||||
<th>최고기온</th>
|
||||
<th>강수량</th>
|
||||
<th>습도</th>
|
||||
<th>예상 방문객</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="weekly-forecast-table">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">데이터 로딩 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 방문객 추이 그래프 -->
|
||||
<div style="margin-top: 30px;">
|
||||
<h5 style="margin-bottom: 20px; font-weight: 600; color: #333;">
|
||||
<i class="bi bi-graph-up"></i> 방문객 추이
|
||||
</h5>
|
||||
|
||||
<!-- 날짜 범위 선택 -->
|
||||
<div class="date-range-picker">
|
||||
<input type="date" id="trend-start-date" class="form-control" style="max-width: 150px;">
|
||||
<span style="display: flex; align-items: center;">~</span>
|
||||
<input type="date" id="trend-end-date" class="form-control" style="max-width: 150px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="loadVisitorTrend()">조회</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="resetTrendDate()">최근 1개월</button>
|
||||
</div>
|
||||
|
||||
<!-- 그래프 -->
|
||||
<div class="chart-container">
|
||||
<canvas id="visitor-trend-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 파일 업로드 탭 ===== -->
|
||||
<div class="tab-pane fade" id="upload-panel" role="tabpanel">
|
||||
<!-- 시스템 상태 -->
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>시스템 상태:</strong>
|
||||
데이터베이스: <span id="db-status" class="badge bg-danger">연결 중...</span>
|
||||
업로드 폴더: <span id="upload-folder-status" class="badge bg-danger">확인 중...</span>
|
||||
</div>
|
||||
|
||||
<!-- 드래그 앤 드롭 영역 -->
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<i class="bi bi-cloud-upload" style="font-size: 48px; color: var(--primary-color); margin-bottom: 15px;"></i>
|
||||
<h5 style="color: #333; margin: 10px 0;">파일을 여기에 드래그하세요</h5>
|
||||
<p style="color: #666; margin: 0;">또는</p>
|
||||
<button class="btn btn-primary btn-custom" style="margin-top: 10px;">
|
||||
파일 선택
|
||||
</button>
|
||||
<p style="color: #999; font-size: 12px; margin-top: 15px;">
|
||||
지원 형식: OKPOS (일자별 상품별, 영수증별매출상세현황), UPSOLUTION<br>
|
||||
최대 파일 크기: 100MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 선택된 파일 목록 -->
|
||||
<div class="file-list" id="file-list"></div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||
<button class="btn btn-success btn-custom" id="upload-btn" onclick="uploadFiles()">
|
||||
<i class="bi bi-check-circle"></i> 업로드
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-custom" id="clear-btn" onclick="clearFileList()">
|
||||
<i class="bi bi-x-circle"></i> 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 진행 표시 -->
|
||||
<div id="upload-progress" style="margin-top: 20px; display: none;">
|
||||
<div class="progress" style="height: 25px;">
|
||||
<div class="progress-bar bg-success" id="progress-bar" style="width: 0%;">
|
||||
<span id="progress-text">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p id="progress-message" style="margin-top: 10px; color: #666;"></p>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 결과 -->
|
||||
<div id="upload-result" style="margin-top: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 백업 관리 탭 ===== -->
|
||||
<div class="tab-pane fade" id="backup-panel" role="tabpanel">
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 20px;">
|
||||
<button class="btn btn-success btn-custom" onclick="createBackup()">
|
||||
<i class="bi bi-plus-circle"></i> 새 백업 생성
|
||||
</button>
|
||||
<button class="btn btn-info btn-custom" onclick="loadBackupList()">
|
||||
<i class="bi bi-arrow-clockwise"></i> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 백업 목록 -->
|
||||
<div id="backup-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 영역 -->
|
||||
<div id="alert-container" style="position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px;"></div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 글로벌 변수
|
||||
const FILE_LIST = [];
|
||||
let visitorTrendChart = null;
|
||||
|
||||
// ===== 초기화 =====
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeDatePickers();
|
||||
loadDashboard();
|
||||
loadFileUploadUI();
|
||||
setInterval(loadDashboard, 30000); // 30초마다 대시보드 새로고침
|
||||
});
|
||||
|
||||
// 날짜 피커 초기화
|
||||
function initializeDatePickers() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
document.getElementById('trend-start-date').valueAsDate = thirtyDaysAgo;
|
||||
document.getElementById('trend-end-date').valueAsDate = today;
|
||||
}
|
||||
|
||||
// ===== 대시보드 로드 =====
|
||||
async function loadDashboard() {
|
||||
await Promise.all([
|
||||
loadOKPOSProductStats(),
|
||||
loadOKPOSReceiptStats(),
|
||||
loadUPSolutionStats(),
|
||||
loadWeatherStats(),
|
||||
loadWeeklyForecast(),
|
||||
loadVisitorTrend()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadOKPOSProductStats() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/okpos-product');
|
||||
const data = await response.json();
|
||||
document.getElementById('okpos-product-count').textContent = data.total_records.toLocaleString();
|
||||
document.getElementById('okpos-product-days').textContent = `${data.total_days}일`;
|
||||
document.getElementById('okpos-product-date').textContent = `최종: ${data.last_date || '-'}`;
|
||||
} catch (e) {
|
||||
console.error('OKPOS 상품별 통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOKPOSReceiptStats() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/okpos-receipt');
|
||||
const data = await response.json();
|
||||
document.getElementById('okpos-receipt-count').textContent = data.total_records.toLocaleString();
|
||||
document.getElementById('okpos-receipt-days').textContent = `${data.total_days}일`;
|
||||
document.getElementById('okpos-receipt-date').textContent = `최종: ${data.last_date || '-'}`;
|
||||
} catch (e) {
|
||||
console.error('OKPOS 영수증 통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUPSolutionStats() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/upsolution');
|
||||
const data = await response.json();
|
||||
document.getElementById('upsolution-count').textContent = data.total_records.toLocaleString();
|
||||
document.getElementById('upsolution-days').textContent = `${data.total_days}일`;
|
||||
document.getElementById('upsolution-date').textContent = `최종: ${data.last_date || '-'}`;
|
||||
} catch (e) {
|
||||
console.error('UPSOLUTION 통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWeatherStats() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/weather');
|
||||
const data = await response.json();
|
||||
document.getElementById('weather-count').textContent = data.total_records.toLocaleString();
|
||||
document.getElementById('weather-days').textContent = `${data.total_days}일`;
|
||||
document.getElementById('weather-date').textContent = `최종: ${data.last_date || '-'}`;
|
||||
} catch (e) {
|
||||
console.error('날씨 통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWeeklyForecast() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/weekly-forecast');
|
||||
const data = await response.json();
|
||||
let html = '';
|
||||
data.forecast_data.forEach(day => {
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${day.date} (${day.day})</strong></td>
|
||||
<td>${day.min_temp !== null ? day.min_temp + '°C' : '-'}</td>
|
||||
<td>${day.max_temp !== null ? day.max_temp + '°C' : '-'}</td>
|
||||
<td>${day.precipitation.toFixed(1)}mm</td>
|
||||
<td>${day.humidity !== null ? day.humidity + '%' : '-'}</td>
|
||||
<td><strong>${day.expected_visitors.toLocaleString()}명</strong></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
document.getElementById('weekly-forecast-table').innerHTML = html;
|
||||
} catch (e) {
|
||||
console.error('주간 예보 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVisitorTrend() {
|
||||
try {
|
||||
const startDate = document.getElementById('trend-start-date').value;
|
||||
const endDate = document.getElementById('trend-end-date').value;
|
||||
const response = await fetch(`/api/dashboard/visitor-trend?start_date=${startDate}&end_date=${endDate}`);
|
||||
const data = await response.json();
|
||||
const ctx = document.getElementById('visitor-trend-chart');
|
||||
|
||||
if (visitorTrendChart) {
|
||||
visitorTrendChart.destroy();
|
||||
}
|
||||
|
||||
visitorTrendChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.dates,
|
||||
datasets: [{
|
||||
label: '방문객',
|
||||
data: data.visitors,
|
||||
borderColor: 'var(--primary-color)',
|
||||
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: 'var(--primary-color)',
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('방문객 추이 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function resetTrendDate() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
document.getElementById('trend-start-date').valueAsDate = thirtyDaysAgo;
|
||||
document.getElementById('trend-end-date').valueAsDate = today;
|
||||
loadVisitorTrend();
|
||||
}
|
||||
|
||||
// ===== 파일 업로드 =====
|
||||
function loadFileUploadUI() {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
dropZone.querySelector('button').addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = '.xlsx,.xls,.csv';
|
||||
input.addEventListener('change', (e) => {
|
||||
handleFiles(e.target.files);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
checkSystemStatus();
|
||||
loadBackupList();
|
||||
}
|
||||
|
||||
function handleFiles(files) {
|
||||
for (let file of files) {
|
||||
FILE_LIST.push(file);
|
||||
}
|
||||
updateFileList();
|
||||
}
|
||||
|
||||
function updateFileList() {
|
||||
const fileListDiv = document.getElementById('file-list');
|
||||
let html = '';
|
||||
FILE_LIST.forEach((file, index) => {
|
||||
html += `
|
||||
<div class="file-item">
|
||||
<div>
|
||||
<div class="file-name">
|
||||
<i class="bi bi-file-earmark"></i> ${file.name}
|
||||
</div>
|
||||
<small style="color: #999;">${(file.size / 1024 / 1024).toFixed(2)} MB</small>
|
||||
</div>
|
||||
<i class="bi bi-x-circle file-remove" style="cursor: pointer; color: var(--danger-color);" onclick="removeFile(${index})"></i>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
fileListDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
FILE_LIST.splice(index, 1);
|
||||
updateFileList();
|
||||
}
|
||||
|
||||
function clearFileList() {
|
||||
FILE_LIST.length = 0;
|
||||
updateFileList();
|
||||
document.getElementById('upload-result').innerHTML = '';
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
if (FILE_LIST.length === 0) {
|
||||
showAlert('업로드할 파일을 선택하세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
FILE_LIST.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
document.getElementById('upload-progress').style.display = 'block';
|
||||
document.getElementById('upload-btn').disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
let resultHtml = data.success ? '<div class="alert alert-success">' : '<div class="alert alert-warning">';
|
||||
resultHtml += '<strong>업로드 완료!</strong><br>';
|
||||
data.files.forEach(file => {
|
||||
const icon = file.status === 'success' ? '✓' : '✗';
|
||||
resultHtml += `${icon} ${file.filename}: ${file.message}<br>`;
|
||||
});
|
||||
resultHtml += '</div>';
|
||||
document.getElementById('upload-result').innerHTML = resultHtml;
|
||||
setTimeout(() => {
|
||||
clearFileList();
|
||||
loadDashboard();
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
showAlert('업로드 실패: ' + e.message, 'danger');
|
||||
} finally {
|
||||
document.getElementById('upload-progress').style.display = 'none';
|
||||
document.getElementById('upload-btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 백업 관리 =====
|
||||
async function createBackup() {
|
||||
try {
|
||||
const response = await fetch('/api/backup', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showAlert('백업이 생성되었습니다.', 'success');
|
||||
loadBackupList();
|
||||
} else {
|
||||
showAlert('백업 생성 실패: ' + data.message, 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('백업 생성 오류: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackupList() {
|
||||
try {
|
||||
const response = await fetch('/api/backups');
|
||||
const data = await response.json();
|
||||
let html = '';
|
||||
if (data.backups.length === 0) {
|
||||
html = '<div class="alert alert-info">생성된 백업이 없습니다.</div>';
|
||||
} else {
|
||||
data.backups.forEach(backup => {
|
||||
const sizeInMB = (backup.size / 1024 / 1024).toFixed(2);
|
||||
html += `
|
||||
<div class="backup-item">
|
||||
<div class="backup-info">
|
||||
<div class="backup-filename">
|
||||
<i class="bi bi-file-earmark-zip"></i> ${backup.filename}
|
||||
</div>
|
||||
<div class="backup-size">크기: ${sizeInMB}MB | 생성: ${backup.created}</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="restoreBackup('${backup.filename}')">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> 복구
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
document.getElementById('backup-list').innerHTML = html;
|
||||
} catch (e) {
|
||||
console.error('백업 목록 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreBackup(filename) {
|
||||
if (confirm(`${filename}에서 데이터베이스를 복구하시겠습니까?`)) {
|
||||
fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename })
|
||||
}).then(r => r.json()).then(result => {
|
||||
if (result.success) {
|
||||
showAlert('데이터베이스가 복구되었습니다.', 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
showAlert('복구 실패: ' + result.message, 'danger');
|
||||
}
|
||||
}).catch(e => showAlert('복구 오류: ' + e.message, 'danger'));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 시스템 상태 =====
|
||||
async function checkSystemStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
document.getElementById('db-status').textContent = data.database ? '연결됨' : '연결 안됨';
|
||||
document.getElementById('db-status').className = `badge ${data.database ? 'bg-success' : 'bg-danger'}`;
|
||||
document.getElementById('upload-folder-status').textContent = data.upload_folder ? '정상' : '오류';
|
||||
document.getElementById('upload-folder-status').className = `badge ${data.upload_folder ? 'bg-success' : 'bg-danger'}`;
|
||||
} catch (e) {
|
||||
console.error('시스템 상태 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 알림 =====
|
||||
function showAlert(message, type = 'info') {
|
||||
const alertContainer = document.getElementById('alert-container');
|
||||
const alertId = 'alert-' + Date.now();
|
||||
const alertHtml = `
|
||||
<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert" style="animation: slideIn 0.3s ease;">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
|
||||
setTimeout(() => {
|
||||
const alertElement = document.getElementById(alertId);
|
||||
if (alertElement) alertElement.remove();
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user