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

2
app/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# app/__init__.py
"""Flask 애플리케이션 패키지"""

69
app/app.py Normal file
View File

@ -0,0 +1,69 @@
# app.py
"""
POS 데이터 웹 애플리케이션
기능:
- 파일 업로드 및 처리
- 대시보드 통계 및 예측
- 데이터베이스 백업/복구
"""
import os
import sys
import logging
from flask import Flask
# 프로젝트 루트 경로 추가
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from lib.common import setup_logging
from app.blueprints import dashboard_bp, upload_bp, backup_bp, status_bp
# 로거 설정
logger = setup_logging('pos_web_app', 'INFO')
def create_app():
"""Flask 애플리케이션 팩토리"""
# Flask 앱 초기화
app = Flask(__name__, template_folder='templates', static_folder='static')
# 설정
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), '..', 'uploads')
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB 최대 파일 크기
app.config['JSON_AS_ASCII'] = False # 한글 JSON 지원
# 업로드 폴더 생성
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# Blueprint 등록
app.register_blueprint(dashboard_bp)
app.register_blueprint(upload_bp)
app.register_blueprint(backup_bp)
app.register_blueprint(status_bp)
# 에러 핸들러
@app.errorhandler(413)
def handle_large_file(e):
"""파일 크기 초과"""
return {'error': '파일이 너무 큽니다 (최대 100MB)'}, 413
@app.errorhandler(500)
def handle_internal_error(e):
"""내부 서버 오류"""
logger.error(f"Internal server error: {e}")
return {'error': '서버 오류가 발생했습니다'}, 500
def run_app(host='0.0.0.0', port=8889, debug=False):
"""애플리케이션 실행"""
app = create_app()
logger.info(f"애플리케이션 시작: {host}:{port}")
app.run(host=host, port=port, debug=debug)
if __name__ == '__main__':
run_app()

View File

@ -0,0 +1,13 @@
# app/blueprints/__init__.py
"""
Flask Blueprints 모듈
각 기능별 Blueprint를 정의합니다.
"""
from .dashboard import dashboard_bp
from .upload import upload_bp
from .backup import backup_bp
from .status import status_bp
__all__ = ['dashboard_bp', 'upload_bp', 'backup_bp', 'status_bp']

129
app/blueprints/backup.py Normal file
View File

@ -0,0 +1,129 @@
# app/blueprints/backup.py
"""
백업 관리 블루프린트
역할:
- 데이터베이스 백업 생성
- 백업 복구
- 백업 목록 조회
"""
from flask import Blueprint, render_template, request, jsonify
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from app.file_processor import FileProcessor
backup_bp = Blueprint('backup', __name__, url_prefix='/api')
def get_file_processor(upload_folder):
"""파일 프로세서 인스턴스 반환"""
return FileProcessor(upload_folder)
@backup_bp.route('/backup-page')
def index():
"""백업 관리 페이지"""
return render_template('backup.html')
@backup_bp.route('/backup', methods=['POST'])
def create_backup():
"""
새 백업 생성
응답:
{
'success': bool,
'message': str,
'filename': str (선택사항)
}
"""
try:
backup_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'backups')
os.makedirs(backup_folder, exist_ok=True)
file_processor = get_file_processor(backup_folder)
backup_info = file_processor.create_database_backup()
return jsonify({
'success': True,
'message': '백업이 생성되었습니다.',
'filename': backup_info.get('filename')
})
except Exception as e:
return jsonify({
'success': False,
'message': f'백업 생성 실패: {str(e)}'
}), 500
@backup_bp.route('/backups', methods=['GET'])
def get_backups():
"""
백업 목록 조회
응답:
{
'backups': List[dict] - [{'filename': str, 'size': int, 'created': str}, ...]
}
"""
try:
backup_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'backups')
os.makedirs(backup_folder, exist_ok=True)
file_processor = get_file_processor(backup_folder)
backups = file_processor.list_database_backups()
return jsonify({'backups': backups})
except Exception as e:
return jsonify({
'error': str(e)
}), 500
@backup_bp.route('/restore', methods=['POST'])
def restore_backup():
"""
백업 복구
요청:
{
'filename': str - 복구할 백업 파일명
}
응답:
{
'success': bool,
'message': str
}
"""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({
'success': False,
'message': '파일명을 지정하세요.'
}), 400
backup_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'backups')
file_processor = get_file_processor(backup_folder)
file_processor.restore_database_backup(filename)
return jsonify({
'success': True,
'message': '데이터베이스가 복구되었습니다.'
})
except Exception as e:
return jsonify({
'success': False,
'message': f'복구 실패: {str(e)}'
}), 500

225
app/blueprints/dashboard.py Normal file
View File

@ -0,0 +1,225 @@
# app/blueprints/dashboard.py
"""
대시보드 블루프린트
역할:
- 데이터 통계 조회 (OKPOS, UPSolution, 날씨)
- 주간 예보 조회
- 방문객 추이 차트 데이터 제공
"""
from flask import Blueprint, render_template, jsonify, request
from datetime import datetime, timedelta
from sqlalchemy import select, func, desc
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from conf import db, db_schema
dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/api/dashboard')
@dashboard_bp.route('/')
def index():
"""대시보드 페이지"""
return render_template('dashboard.html')
@dashboard_bp.route('/okpos-product', methods=['GET'])
def get_okpos_product_stats():
"""OKPOS 상품별 통계"""
try:
session = db.get_session()
# 데이터 개수
total_records = session.query(db_schema.OkposProduct).count()
# 데이터 보유 일수
earliest = session.query(func.min(db_schema.OkposProduct.data_date)).scalar()
latest = session.query(func.max(db_schema.OkposProduct.data_date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/okpos-receipt', methods=['GET'])
def get_okpos_receipt_stats():
"""OKPOS 영수증 통계"""
try:
session = db.get_session()
total_records = session.query(db_schema.OkposReceipt).count()
earliest = session.query(func.min(db_schema.OkposReceipt.receipt_date)).scalar()
latest = session.query(func.max(db_schema.OkposReceipt.receipt_date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/upsolution', methods=['GET'])
def get_upsolution_stats():
"""UPSolution 통계"""
try:
session = db.get_session()
total_records = session.query(db_schema.Upsolution).count()
earliest = session.query(func.min(db_schema.Upsolution.sales_date)).scalar()
latest = session.query(func.max(db_schema.Upsolution.sales_date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/weather', methods=['GET'])
def get_weather_stats():
"""날씨 데이터 통계"""
try:
session = db.get_session()
total_records = session.query(db_schema.Weather).count()
earliest = session.query(func.min(db_schema.Weather.date)).scalar()
latest = session.query(func.max(db_schema.Weather.date)).scalar()
if earliest and latest:
total_days = (latest - earliest).days + 1
last_date = latest.strftime('%Y-%m-%d')
else:
total_days = 0
last_date = None
session.close()
return jsonify({
'total_records': total_records,
'total_days': total_days,
'last_date': last_date
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/weekly-forecast', methods=['GET'])
def get_weekly_forecast():
"""이번주 예상 날씨 & 방문객"""
try:
session = db.get_session()
# 오늘부터 7일간 데이터 조회
today = datetime.now().date()
end_date = today + timedelta(days=6)
forecast_data = []
for i in range(7):
current_date = today + timedelta(days=i)
day_name = ['', '', '', '', '', '', ''][current_date.weekday()]
# 날씨 데이터
weather = session.query(db_schema.Weather).filter(
db_schema.Weather.date == current_date
).first()
# 예상 방문객 (동일한 요일의 평균)
same_day_visitors = session.query(
func.avg(db_schema.DailyVisitor.visitors)
).filter(
func.dayofweek(db_schema.DailyVisitor.visit_date) == (current_date.weekday() + 2) % 7 + 1
).scalar()
forecast_data.append({
'date': current_date.strftime('%Y-%m-%d'),
'day': day_name,
'min_temp': weather.min_temp if weather else None,
'max_temp': weather.max_temp if weather else None,
'precipitation': weather.precipitation if weather else 0.0,
'humidity': weather.humidity if weather else None,
'expected_visitors': int(same_day_visitors) if same_day_visitors else 0
})
session.close()
return jsonify({'forecast_data': forecast_data})
except Exception as e:
return jsonify({'error': str(e)}), 500
@dashboard_bp.route('/visitor-trend', methods=['GET'])
def get_visitor_trend():
"""방문객 추이 데이터"""
try:
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
if not start_date_str or not end_date_str:
return jsonify({'error': '날짜 범위를 지정하세요.'}), 400
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
session = db.get_session()
visitors = session.query(
db_schema.DailyVisitor.visit_date,
db_schema.DailyVisitor.visitors
).filter(
db_schema.DailyVisitor.visit_date.between(start_date, end_date)
).order_by(db_schema.DailyVisitor.visit_date).all()
session.close()
dates = [v[0].strftime('%Y-%m-%d') for v in visitors]
visitor_counts = [v[1] for v in visitors]
return jsonify({
'dates': dates,
'visitors': visitor_counts
})
except Exception as e:
return jsonify({'error': str(e)}), 500

57
app/blueprints/status.py Normal file
View File

@ -0,0 +1,57 @@
# app/blueprints/status.py
"""
시스템 상태 블루프린트
역할:
- 데이터베이스 연결 상태 확인
- 업로드 폴더 상태 확인
"""
from flask import Blueprint, jsonify
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from conf import db
status_bp = Blueprint('status', __name__, url_prefix='/api')
@status_bp.route('/status', methods=['GET'])
def get_status():
"""
시스템 상태 확인
응답:
{
'database': bool - 데이터베이스 연결 여부,
'upload_folder': bool - 업로드 폴더 접근 여부
}
"""
try:
# 데이터베이스 연결 확인
database_ok = False
try:
session = db.get_session()
session.execute('SELECT 1')
session.close()
database_ok = True
except Exception as e:
print(f"Database connection error: {e}")
# 업로드 폴더 확인
upload_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'uploads')
upload_folder_ok = os.path.isdir(upload_folder) and os.access(upload_folder, os.W_OK)
return jsonify({
'database': database_ok,
'upload_folder': upload_folder_ok
})
except Exception as e:
return jsonify({
'error': str(e),
'database': False,
'upload_folder': False
}), 500

83
app/blueprints/upload.py Normal file
View File

@ -0,0 +1,83 @@
# app/blueprints/upload.py
"""
파일 업로드 블루프린트
역할:
- 파일 업로드 처리
- 업로드 UI 제공
"""
from flask import Blueprint, render_template, request, jsonify
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from app.file_processor import FileProcessor
upload_bp = Blueprint('upload', __name__, url_prefix='/api/upload')
def get_file_processor(upload_folder):
"""파일 프로세서 인스턴스 반환"""
return FileProcessor(upload_folder)
@upload_bp.route('/')
def index():
"""파일 업로드 페이지"""
return render_template('upload.html')
@upload_bp.route('', methods=['POST'])
def upload_files():
"""
파일 업로드 처리
요청:
files: MultiDict[FileStorage] - 업로드된 파일들
응답:
{
'success': bool,
'message': str,
'files': List[dict],
'errors': List[dict]
}
"""
try:
if 'files' not in request.files:
return jsonify({
'success': False,
'message': '업로드된 파일이 없습니다.',
'files': [],
'errors': []
}), 400
uploaded_files = request.files.getlist('files')
if len(uploaded_files) == 0:
return jsonify({
'success': False,
'message': '파일을 선택하세요.',
'files': [],
'errors': []
}), 400
# 업로드 폴더 경로
upload_folder = os.path.join(os.path.dirname(__file__), '..', '..', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
# 파일 처리
file_processor = get_file_processor(upload_folder)
results = file_processor.process_uploads(uploaded_files)
return jsonify(results)
except Exception as e:
return jsonify({
'success': False,
'message': f'업로드 처리 중 오류: {str(e)}',
'files': [],
'errors': [str(e)]
}), 500

477
app/file_processor.py Normal file
View File

@ -0,0 +1,477 @@
# app/file_processor.py
"""
POS 데이터 파일 처리 및 검증 모듈
지원 형식:
- UPSOLUTION: POS 데이터 (pos_update_upsolution.py에서 처리)
- OKPOS: 일자별 상품별 파일, 영수증별매출상세현황 파일
"""
import os
import sys
import logging
import pandas as pd
from datetime import datetime
from pathlib import Path
import subprocess
# 프로젝트 루트 경로 추가
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
from lib.common import setup_logging
from lib.pos_update_daily_product import process_okpos_file
from lib.pos_update_upsolution import process_upsolution_file
logger = setup_logging('file_processor', 'INFO')
class FileProcessor:
"""POS 데이터 파일 처리 클래스"""
# 지원하는 파일 확장자
ALLOWED_EXTENSIONS = {'.xlsx', '.xls', '.csv'}
# OKPOS 파일 패턴
OKPOS_PATTERNS = {
'일자별': '일자별.*상품별.*파일',
'영수증별': '영수증별매출상세현황'
}
# UPSOLUTION 파일 패턴
UPSOLUTION_PATTERNS = {'UPSOLUTION'}
def __init__(self, upload_folder):
"""
초기화
Args:
upload_folder (str): 파일 업로드 폴더 경로
"""
self.upload_folder = upload_folder
self.backup_folder = os.path.join(os.path.dirname(__file__), '..', 'dbbackup')
os.makedirs(self.backup_folder, exist_ok=True)
logger.info(f"파일 프로세서 초기화 - 업로드폴더: {upload_folder}")
def get_file_type(self, filename):
"""
파일 타입 판정
Args:
filename (str): 파일명
Returns:
str: 파일 타입 ('upsolution', 'okpos', 'unknown')
"""
filename_upper = filename.upper()
# UPSOLUTION 파일 확인
if 'UPSOLUTION' in filename_upper:
logger.debug(f"UPSOLUTION 파일 감지: {filename}")
return 'upsolution'
# OKPOS 파일 확인
if '일자별' in filename and '상품별' in filename:
logger.debug(f"OKPOS 파일(일자별) 감지: {filename}")
return 'okpos'
if '영수증별매출상세현황' in filename:
logger.debug(f"OKPOS 파일(영수증별) 감지: {filename}")
return 'okpos'
logger.warning(f"알 수 없는 파일 타입: {filename}")
return 'unknown'
def validate_file(self, filename):
"""
파일 검증
Args:
filename (str): 파일명
Returns:
tuple[bool, str]: (성공 여부, 메시지)
"""
logger.info(f"파일 검증 시작: {filename}")
# 파일 확장자 확인
_, ext = os.path.splitext(filename)
if ext.lower() not in self.ALLOWED_EXTENSIONS:
msg = f"지원하지 않는 파일 형식: {ext}"
logger.warning(msg)
return False, msg
# 파일 타입 확인
file_type = self.get_file_type(filename)
if file_type == 'unknown':
msg = f"파일명을 인식할 수 없습니다. (UPSOLUTION 또는 일자별 상품별 파일이어야 함)"
logger.warning(msg)
return False, msg
logger.info(f"파일 검증 완료: {filename} ({file_type})")
return True, file_type
def process_uploads(self, uploaded_files):
"""
여러 파일 처리
Args:
uploaded_files (list): 업로드된 파일 객체 리스트
Returns:
dict: 처리 결과
{
'files': List[dict], # 처리된 파일
'errors': List[dict] # 에러 정보
}
"""
logger.info(f"파일 처리 시작 - {len(uploaded_files)}개 파일")
files_result = []
errors_result = []
for file_obj in uploaded_files:
try:
filename = secure_filename(file_obj.filename)
logger.info(f"파일 처리: {filename}")
# 파일 검증
is_valid, file_type_or_msg = self.validate_file(filename)
if not is_valid:
logger.error(f"파일 검증 실패: {filename} - {file_type_or_msg}")
errors_result.append({
'filename': filename,
'message': file_type_or_msg,
'type': 'validation'
})
files_result.append({
'filename': filename,
'status': 'failed',
'message': file_type_or_msg
})
continue
file_type = file_type_or_msg
# 파일 저장
filepath = os.path.join(self.upload_folder, filename)
file_obj.save(filepath)
logger.info(f"파일 저장 완료: {filepath}")
# 파일 처리
result = self.process_file(filepath, file_type)
if result['success']:
logger.info(f"파일 처리 완료: {filename}")
files_result.append({
'filename': filename,
'status': 'success',
'message': result['message'],
'rows_inserted': result.get('rows_inserted', 0)
})
# 파일 삭제
try:
os.remove(filepath)
logger.info(f"임시 파일 삭제: {filepath}")
except Exception as e:
logger.warning(f"임시 파일 삭제 실패: {filepath} - {e}")
else:
logger.error(f"파일 처리 실패: {filename} - {result['message']}")
files_result.append({
'filename': filename,
'status': 'failed',
'message': result['message']
})
errors_result.append({
'filename': filename,
'message': result['message'],
'type': 'processing'
})
except Exception as e:
logger.error(f"파일 처리 중 예외 발생: {file_obj.filename} - {e}", exc_info=True)
errors_result.append({
'filename': file_obj.filename,
'message': f'처리 중 오류: {str(e)}',
'type': 'exception'
})
files_result.append({
'filename': file_obj.filename,
'status': 'failed',
'message': str(e)
})
logger.info(f"파일 처리 완료 - 성공: {len([f for f in files_result if f['status'] == 'success'])}, 실패: {len(errors_result)}")
return {
'files': files_result,
'errors': errors_result
}
def process_file(self, filepath, file_type):
"""
개별 파일 처리
Args:
filepath (str): 파일 경로
file_type (str): 파일 타입
Returns:
dict: 처리 결과
{
'success': bool,
'message': str,
'rows_inserted': int
}
"""
try:
if file_type == 'okpos':
logger.info(f"OKPOS 파일 처리: {filepath}")
return self._process_okpos_file(filepath)
elif file_type == 'upsolution':
logger.info(f"UPSOLUTION 파일 처리: {filepath}")
return self._process_upsolution_file(filepath)
else:
msg = f"지원하지 않는 파일 타입: {file_type}"
logger.error(msg)
return {
'success': False,
'message': msg
}
except Exception as e:
logger.error(f"파일 처리 중 예외 발생: {filepath} - {e}", exc_info=True)
return {
'success': False,
'message': f'파일 처리 중 오류: {str(e)}'
}
def _process_okpos_file(self, filepath):
"""
OKPOS 파일 처리
Args:
filepath (str): 파일 경로
Returns:
dict: 처리 결과
"""
try:
logger.info(f"OKPOS 파일 처리 시작: {filepath}")
# process_okpos_file 함수 호출
result = process_okpos_file(filepath)
logger.info(f"OKPOS 파일 처리 완료: {filepath} - {result.get('rows_inserted', 0)}")
return {
'success': True,
'message': f"{result.get('rows_inserted', 0)} 행이 저장되었습니다.",
'rows_inserted': result.get('rows_inserted', 0)
}
except Exception as e:
logger.error(f"OKPOS 파일 처리 오류: {filepath} - {e}", exc_info=True)
return {
'success': False,
'message': f'OKPOS 파일 처리 오류: {str(e)}'
}
def _process_upsolution_file(self, filepath):
"""
UPSOLUTION 파일 처리
Args:
filepath (str): 파일 경로
Returns:
dict: 처리 결과
"""
try:
logger.info(f"UPSOLUTION 파일 처리 시작: {filepath}")
# process_upsolution_file 함수 호출
result = process_upsolution_file(filepath)
logger.info(f"UPSOLUTION 파일 처리 완료: {filepath} - {result.get('rows_inserted', 0)}")
return {
'success': True,
'message': f"{result.get('rows_inserted', 0)} 행이 저장되었습니다.",
'rows_inserted': result.get('rows_inserted', 0)
}
except Exception as e:
logger.error(f"UPSOLUTION 파일 처리 오류: {filepath} - {e}", exc_info=True)
return {
'success': False,
'message': f'UPSOLUTION 파일 처리 오류: {str(e)}'
}
def create_database_backup(self):
"""
데이터베이스 백업 생성
Returns:
str: 백업 파일 경로
"""
try:
logger.info("데이터베이스 백업 시작")
# 백업 파일명: backup_YYYYMMDD_HHMMSS.sql
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_filename = f"backup_{timestamp}.sql"
backup_path = os.path.join(self.backup_folder, backup_filename)
# 데이터베이스 설정 로드
config = db.load_config()
db_cfg = config['database']
# mysqldump 명령 실행
cmd = [
'mysqldump',
'-h', db_cfg['host'],
'-u', db_cfg['user'],
f'-p{db_cfg["password"]}',
db_cfg['name'],
'-v'
]
logger.info(f"백업 명령 실행: mysqldump ...")
with open(backup_path, 'w', encoding='utf-8') as f:
process = subprocess.run(
cmd,
stdout=f,
stderr=subprocess.PIPE,
text=True
)
if process.returncode != 0:
error_msg = process.stderr
logger.error(f"백업 실패: {error_msg}")
raise Exception(f"백업 실패: {error_msg}")
file_size = os.path.getsize(backup_path)
logger.info(f"데이터베이스 백업 완료: {backup_path} ({file_size} bytes)")
return backup_path
except Exception as e:
logger.error(f"데이터베이스 백업 오류: {e}", exc_info=True)
raise
def restore_database_backup(self, filename):
"""
데이터베이스 복구
Args:
filename (str): 백업 파일명
Returns:
bool: 복구 성공 여부
"""
try:
backup_path = os.path.join(self.backup_folder, filename)
if not os.path.exists(backup_path):
logger.error(f"백업 파일 없음: {backup_path}")
return False
logger.info(f"데이터베이스 복구 시작: {backup_path}")
# 데이터베이스 설정 로드
config = db.load_config()
db_cfg = config['database']
# mysql 명령 실행
cmd = [
'mysql',
'-h', db_cfg['host'],
'-u', db_cfg['user'],
f'-p{db_cfg["password"]}',
db_cfg['name']
]
logger.info(f"복구 명령 실행: mysql ...")
with open(backup_path, 'r', encoding='utf-8') as f:
process = subprocess.run(
cmd,
stdin=f,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if process.returncode != 0:
error_msg = process.stderr
logger.error(f"복구 실패: {error_msg}")
return False
logger.info(f"데이터베이스 복구 완료: {filename}")
return True
except Exception as e:
logger.error(f"데이터베이스 복구 오류: {e}", exc_info=True)
return False
def list_database_backups(self):
"""
사용 가능한 백업 목록 조회
Returns:
list: 백업 파일 정보 리스트
[
{
'filename': str,
'size': int,
'created': str
}
]
"""
try:
logger.debug("백업 목록 조회")
backups = []
for filename in os.listdir(self.backup_folder):
filepath = os.path.join(self.backup_folder, filename)
if os.path.isfile(filepath) and filename.endswith('.sql'):
stat = os.stat(filepath)
backups.append({
'filename': filename,
'size': stat.st_size,
'created': datetime.fromtimestamp(stat.st_mtime).isoformat()
})
# 최신순 정렬
backups.sort(key=lambda x: x['created'], reverse=True)
logger.debug(f"백업 목록: {len(backups)}")
return backups
except Exception as e:
logger.error(f"백업 목록 조회 오류: {e}", exc_info=True)
return []
def secure_filename(filename):
"""
파일명 보안 처리
Args:
filename (str): 원본 파일명
Returns:
str: 보안 처리된 파일명
"""
# 경로 트래버설 방지
from werkzeug.utils import secure_filename as werkzeug_secure
return werkzeug_secure(filename)

2
app/static/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# app/static/.gitkeep
# Flask 정적 파일 디렉토리

75
app/static/css/backup.css Normal file
View File

@ -0,0 +1,75 @@
/* ===== 백업 관리 전용 스타일 ===== */
/* 백업 아이템 */
.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;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.backup-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.backup-info {
flex: 1;
}
.backup-filename {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.backup-size {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.backup-actions {
display: flex;
gap: 10px;
}
.backup-actions button {
border-radius: 8px;
padding: 6px 12px;
font-size: 12px;
}
/* 백업 목록 */
#backup-list {
margin-top: 20px;
}
/* 빈 백업 메시지 */
.alert-info {
text-align: center;
}
/* 반응형 */
@media (max-width: 768px) {
.backup-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.backup-actions {
width: 100%;
flex-direction: column;
}
.backup-actions button {
width: 100%;
}
}

189
app/static/css/common.css Normal file
View File

@ -0,0 +1,189 @@
/* ===== CSS 변수 및 기본 스타일 ===== */
: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;
text-decoration: none;
}
.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);
}
/* ===== 버튼 스타일 ===== */
.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);
}
/* ===== 테이블 스타일 ===== */
.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;
}
/* ===== 알림 스타일 ===== */
.alert {
border-radius: 8px;
border: none;
}
.alert-info {
background: #cfe2ff;
color: #084298;
}
.alert-success {
background: #d1e7dd;
color: #0f5132;
}
.alert-danger {
background: #f8d7da;
color: #842029;
}
.alert-warning {
background: #fff3cd;
color: #664d03;
}
/* ===== 반응형 ===== */
@media (max-width: 768px) {
.header {
padding: 20px;
}
.header h1 {
font-size: 20px;
}
.tab-content {
padding: 15px;
}
.nav-tabs {
padding: 0 10px;
}
.nav-tabs .nav-link {
font-size: 12px;
margin-right: 5px;
}
}
/* ===== 애니메이션 ===== */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.alert {
animation: slideIn 0.3s ease;
}

View File

@ -0,0 +1,101 @@
/* ===== 대시보드 전용 스타일 ===== */
/* 통계 카드 */
.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;
}
/* 차트 컨테이너 */
.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;
}
/* 반응형 */
@media (max-width: 768px) {
.stat-card {
margin-bottom: 15px;
}
.stat-card .stat-value {
font-size: 24px;
}
.date-range-picker {
flex-direction: column;
}
.chart-container {
height: 300px;
}
}

86
app/static/css/upload.css Normal file
View File

@ -0,0 +1,86 @@
/* ===== 파일 업로드 전용 스타일 ===== */
/* 드롭존 */
.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);
}
/* 파일 아이템 */
.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;
}
.file-item-remove {
cursor: pointer;
color: var(--danger-color);
transition: all 0.3s ease;
}
.file-item-remove:hover {
color: darkred;
font-size: 20px;
}
/* 파일 목록 */
.file-list {
margin: 20px 0;
}
/* 업로드 진행바 */
.progress {
border-radius: 8px;
overflow: hidden;
}
.progress-bar {
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
/* 반응형 */
@media (max-width: 768px) {
.drop-zone {
padding: 20px;
}
.drop-zone i {
font-size: 32px !important;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}

87
app/static/js/backup.js Normal file
View File

@ -0,0 +1,87 @@
/* ===== 백업 관리 JavaScript ===== */
/**
* 새로운 백업을 생성합니다.
*/
async function createBackup() {
try {
const data = await apiCall('/api/backup', { method: 'POST' });
if (data.success) {
showAlert('백업이 생성되었습니다.', 'success');
loadBackupList();
} else {
showAlert('백업 생성 실패: ' + data.message, 'danger');
}
} catch (error) {
showAlert('백업 생성 오류: ' + error.message, 'danger');
}
}
/**
* 백업 목록을 로드합니다.
*/
async function loadBackupList() {
try {
const data = await apiCall('/api/backups');
let html = '';
if (data.backups.length === 0) {
html = '<div class="alert alert-info">생성된 백업이 없습니다.</div>';
} else {
data.backups.forEach(backup => {
const sizeInMB = formatFileSize(backup.size);
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 (error) {
console.error('백업 목록 로드 실패:', error);
}
}
/**
* 백업을 복구합니다.
* @param {string} filename - 복구할 백업 파일명
*/
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');
loadBackupList();
// 대시보드 새로고침
if (typeof loadDashboard === 'function') {
loadDashboard();
}
} else {
showAlert('복구 실패: ' + result.message, 'danger');
}
})
.catch(e => showAlert('복구 오류: ' + e.message, 'danger'));
}
}

84
app/static/js/common.js Normal file
View File

@ -0,0 +1,84 @@
/* ===== 공통 JavaScript 함수 ===== */
/**
* API를 호출하고 JSON 응답을 반환합니다.
* @param {string} url - API URL
* @param {object} options - fetch 옵션 (선택사항)
* @returns {Promise}
*/
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API 호출 실패:', error);
throw error;
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환합니다.
* @param {number} num - 변환할 숫자
* @returns {string}
*/
function formatNumber(num) {
return num.toLocaleString('ko-KR');
}
/**
* 파일 크기를 MB 단위로 변환합니다.
* @param {number} bytes - 바이트 크기
* @returns {string}
*/
function formatFileSize(bytes) {
return (bytes / 1024 / 1024).toFixed(2);
}
/**
* 알림 메시지를 표시합니다.
* @param {string} message - 표시할 메시지
* @param {string} type - 알림 타입 (info, success, warning, danger)
*/
function showAlert(message, type = 'info') {
const alertContainer = document.getElementById('alert-container');
if (!alertContainer) return;
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);
// 5초 후 자동 제거
setTimeout(() => {
const alertElement = document.getElementById(alertId);
if (alertElement) alertElement.remove();
}, 5000);
}
/**
* 날짜 객체를 YYYY-MM-DD 형식의 문자열로 변환합니다.
* @param {Date} date - 변환할 날짜 객체
* @returns {string}
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜 문자열을 Date 객체로 변환합니다.
* @param {string} dateString - YYYY-MM-DD 형식의 날짜 문자열
* @returns {Date}
*/
function parseDate(dateString) {
return new Date(dateString + 'T00:00:00');
}

192
app/static/js/dashboard.js Normal file
View File

@ -0,0 +1,192 @@
/* ===== 대시보드 JavaScript ===== */
let visitorTrendChart = null;
/**
* 대시보드를 초기화합니다.
*/
function initializeDashboard() {
initializeDatePickers();
loadDashboard();
// 30초마다 대시보드 새로고침
setInterval(loadDashboard, 30000);
}
/**
* 날짜 피커를 초기화합니다 (기본값: 최근 1개월).
*/
function initializeDatePickers() {
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const startDateInput = document.getElementById('trend-start-date');
const endDateInput = document.getElementById('trend-end-date');
if (startDateInput) startDateInput.valueAsDate = thirtyDaysAgo;
if (endDateInput) endDateInput.valueAsDate = today;
}
/**
* 모든 대시보드 데이터를 로드합니다.
*/
async function loadDashboard() {
await Promise.all([
loadOKPOSProductStats(),
loadOKPOSReceiptStats(),
loadUPSolutionStats(),
loadWeatherStats(),
loadWeeklyForecast(),
loadVisitorTrend()
]);
}
/**
* OKPOS 상품별 통계를 로드합니다.
*/
async function loadOKPOSProductStats() {
try {
const data = await apiCall('/api/dashboard/okpos-product');
document.getElementById('okpos-product-count').textContent = formatNumber(data.total_records);
document.getElementById('okpos-product-days').textContent = `${data.total_days}`;
document.getElementById('okpos-product-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('OKPOS 상품별 통계 로드 실패:', error);
}
}
/**
* OKPOS 영수증 통계를 로드합니다.
*/
async function loadOKPOSReceiptStats() {
try {
const data = await apiCall('/api/dashboard/okpos-receipt');
document.getElementById('okpos-receipt-count').textContent = formatNumber(data.total_records);
document.getElementById('okpos-receipt-days').textContent = `${data.total_days}`;
document.getElementById('okpos-receipt-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('OKPOS 영수증 통계 로드 실패:', error);
}
}
/**
* UPSolution 통계를 로드합니다.
*/
async function loadUPSolutionStats() {
try {
const data = await apiCall('/api/dashboard/upsolution');
document.getElementById('upsolution-count').textContent = formatNumber(data.total_records);
document.getElementById('upsolution-days').textContent = `${data.total_days}`;
document.getElementById('upsolution-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('UPSolution 통계 로드 실패:', error);
}
}
/**
* 날씨 데이터 통계를 로드합니다.
*/
async function loadWeatherStats() {
try {
const data = await apiCall('/api/dashboard/weather');
document.getElementById('weather-count').textContent = formatNumber(data.total_records);
document.getElementById('weather-days').textContent = `${data.total_days}`;
document.getElementById('weather-date').textContent = `최종: ${data.last_date || '-'}`;
} catch (error) {
console.error('날씨 통계 로드 실패:', error);
}
}
/**
* 주간 예보를 로드합니다.
*/
async function loadWeeklyForecast() {
try {
const data = await apiCall('/api/dashboard/weekly-forecast');
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>${formatNumber(day.expected_visitors)}명</strong></td>
</tr>
`;
});
document.getElementById('weekly-forecast-table').innerHTML = html;
} catch (error) {
console.error('주간 예보 로드 실패:', error);
}
}
/**
* 방문객 추이를 로드하고 그래프를 업데이트합니다.
*/
async function loadVisitorTrend() {
try {
const startDate = document.getElementById('trend-start-date').value;
const endDate = document.getElementById('trend-end-date').value;
if (!startDate || !endDate) {
console.log('날짜 범위가 설정되지 않았습니다.');
return;
}
const data = await apiCall(`/api/dashboard/visitor-trend?start_date=${startDate}&end_date=${endDate}`);
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 (error) {
console.error('방문객 추이 로드 실패:', error);
}
}
/**
* 날짜 범위를 기본값(최근 1개월)으로 리셋하고 그래프를 새로고침합니다.
*/
function resetTrendDate() {
initializeDatePickers();
loadVisitorTrend();
}

187
app/static/js/upload.js Normal file
View File

@ -0,0 +1,187 @@
/* ===== 파일 업로드 JavaScript ===== */
const FILE_LIST = [];
/**
* 파일 업로드 UI를 초기화합니다.
*/
function initializeUploadUI() {
setupDropZone();
setupFileButton();
checkSystemStatus();
}
/**
* 드롭존을 설정합니다.
*/
function setupDropZone() {
const dropZone = document.getElementById('drop-zone');
if (!dropZone) return;
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);
});
}
/**
* 파일 선택 버튼을 설정합니다.
*/
function setupFileButton() {
const fileSelectBtn = document.getElementById('file-select-btn');
if (!fileSelectBtn) return;
fileSelectBtn.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();
});
}
/**
* 파일들을 처리합니다.
* @param {FileList} files - 선택된 파일들
*/
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;">${formatFileSize(file.size)} MB</small>
</div>
<i class="bi bi-x-circle file-item-remove" onclick="removeFile(${index})"></i>
</div>
`;
});
fileListDiv.innerHTML = html;
}
/**
* 파일을 목록에서 제거합니다.
* @param {number} index - 제거할 파일의 인덱스
*/
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;
showAlert('업로드가 완료되었습니다.', 'success');
setTimeout(() => {
clearFileList();
// 대시보드 새로고침
if (typeof loadDashboard === 'function') {
loadDashboard();
}
}, 2000);
} catch (error) {
showAlert('업로드 실패: ' + error.message, 'danger');
} finally {
document.getElementById('upload-progress').style.display = 'none';
document.getElementById('upload-btn').disabled = false;
}
}
/**
* 시스템 상태를 확인합니다.
*/
async function checkSystemStatus() {
try {
const data = await apiCall('/api/status');
const dbStatus = document.getElementById('db-status');
const uploadStatus = document.getElementById('upload-folder-status');
if (dbStatus) {
dbStatus.textContent = data.database ? '연결됨' : '연결 안됨';
dbStatus.className = `badge ${data.database ? 'bg-success' : 'bg-danger'}`;
}
if (uploadStatus) {
uploadStatus.textContent = data.upload_folder ? '정상' : '오류';
uploadStatus.className = `badge ${data.upload_folder ? 'bg-success' : 'bg-danger'}`;
}
} catch (error) {
console.error('시스템 상태 확인 실패:', error);
}
}

33
app/templates/backup.html Normal file
View File

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}백업 관리 - First Garden POS{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/backup.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="tab-pane fade show active" 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>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/backup.js') }}"></script>
<script>
// 백업 관리 UI 초기화
document.addEventListener('DOMContentLoaded', function() {
loadBackupList();
});
</script>
{% endblock %}

72
app/templates/base.html Normal file
View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}First Garden - POS 데이터 관리{% endblock %}</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>
<!-- 공통 CSS -->
<link href="{{ url_for('static', filename='css/common.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</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">
<a class="nav-link {% if request.endpoint == 'dashboard.index' or not request.endpoint %}active{% endif %}"
href="{{ url_for('dashboard.index') }}" role="tab">
<i class="bi bi-speedometer2"></i> 대시보드
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if request.endpoint == 'upload.index' %}active{% endif %}"
href="{{ url_for('upload.index') }}" role="tab">
<i class="bi bi-cloud-upload"></i> 파일 업로드
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if request.endpoint == 'backup.index' %}active{% endif %}"
href="{{ url_for('backup.index') }}" role="tab">
<i class="bi bi-cloud-check"></i> 백업 관리
</a>
</li>
</ul>
<!-- 탭 콘텐츠 -->
<div class="tab-content">
{% block content %}{% endblock %}
</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>
<!-- 공통 JavaScript -->
<script src="{{ url_for('static', filename='js/common.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}대시보드 - First Garden POS{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/dashboard.css') }}" rel="stylesheet">
{% endblock %}
{% block 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>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script>
// 대시보드 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeDashboard();
});
</script>
{% endblock %}

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>

69
app/templates/upload.html Normal file
View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}파일 업로드 - First Garden POS{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/upload.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="tab-pane fade show active" 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" id="file-select-btn" 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>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
<script>
// 파일 업로드 UI 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeUploadUI();
});
</script>
{% endblock %}