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