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:
2025-12-26 17:31:37 +09:00
parent 9dab27529d
commit 7121f250bc
46 changed files with 6345 additions and 191 deletions

867
app/templates/index.html Normal file
View 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>