commit c2797ee401ce42e355fce28c9230b355fe1dab1c Author: bl3kunja Date: Thu Apr 16 17:58:36 2026 +0200 added PDF support and autoupdating on refresh diff --git a/__pycache__/data_manager.cpython-312.pyc b/__pycache__/data_manager.cpython-312.pyc new file mode 100644 index 0000000..683c2b3 Binary files /dev/null and b/__pycache__/data_manager.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..cc81f5c --- /dev/null +++ b/app.py @@ -0,0 +1,398 @@ +from flask import Flask, render_template, request, jsonify, session +from flask_cors import CORS +import json +from datetime import datetime +from data_manager import DataManager +import os +from functools import wraps + +app = Flask(__name__, static_folder='static', template_folder='templates') +CORS(app) +app.secret_key = os.environ.get('SECRET_KEY', 'rfid-detection-secret-key-change-in-production') + +# Create data directory if it doesn't exist +os.makedirs('data', exist_ok=True) + +# Global variables +data_manager = DataManager("data/participants.json", "data/scan_logs.json") +last_scan_result = None + +# Admin credentials (change this to your desired password) +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Pleterski') + +def require_admin(f): + """Decorator to require admin authentication""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'admin' not in session or not session['admin']: + return jsonify({'success': False, 'message': 'Unauthorized'}), 401 + return f(*args, **kwargs) + return decorated_function + + +def process_card(uid): + """Process scanned card or key""" + global last_scan_result + participant = data_manager.get_participant_by_card(uid) + + # Get card and key UIDs from participant + card_uid = participant.get('card_uid') if participant else None + key_uid = participant.get('key_uid') if participant else None + + if participant and not data_manager.is_card_expired(participant): + data_manager.log_scan(uid, "PASS", participant['name'], card_uid=card_uid, key_uid=key_uid) + last_scan_result = { + 'uid': uid, + 'name': participant['name'], + 'status': 'PASS', + 'color': 'green', + 'timestamp': datetime.now().isoformat() + } + else: + # FAIL if participant not found or card is expired + name = participant['name'] if participant else "Unknown" + reason = "Expired" if participant and data_manager.is_card_expired(participant) else "Unknown" + # For FAIL scans, log the scanned UID as card_uid + data_manager.log_scan(uid, "FAIL", name, reason, card_uid=uid, key_uid=None) + last_scan_result = { + 'uid': uid, + 'name': name if participant else 'Unknown', + 'status': 'FAIL', + 'color': 'red', + 'timestamp': datetime.now().isoformat() + } + + return last_scan_result + +# ===== AUTHENTICATION ROUTES ===== + +@app.route('/api/login', methods=['POST']) +def login(): + """Admin login""" + try: + data = request.json + password = data.get('password', '').strip() + + if password == ADMIN_PASSWORD: + session['admin'] = True + return jsonify({'success': True, 'message': 'Logged in successfully'}) + else: + return jsonify({'success': False, 'message': 'Invalid password'}), 401 + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/api/logout', methods=['POST']) +def logout(): + """Admin logout""" + session.pop('admin', None) + return jsonify({'success': True, 'message': 'Logged out successfully'}) + +@app.route('/api/check-admin', methods=['GET']) +def check_admin(): + """Check if user is logged in as admin""" + is_admin = 'admin' in session and session['admin'] + return jsonify({'is_admin': is_admin}) + +# ===== API Routes ===== + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/api/scan', methods=['POST']) +def receive_wifi_scan(): + """Receive card scan from WiFi-enabled ESP8266""" + try: + data = request.json + uid = data.get('uid', '').strip() + + if not uid: + return jsonify({'success': False, 'message': 'No UID provided'}), 400 + + print(f"[WiFi Scan] Card scanned via WiFi: {uid}") + result = process_card(uid) + print(f"[WiFi Scan] Result: {result}") + return jsonify({'success': True, 'result': result}) + except Exception as e: + print(f"[WiFi Scan Error] {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/scan', methods=['POST']) +def receive_wifi_scan_root(): + """Fallback route for /scan (without /api prefix)""" + try: + data = request.json + uid = data.get('uid', '').strip() + + if not uid: + return jsonify({'success': False, 'message': 'No UID provided'}), 400 + + print(f"[WiFi Scan Root] Card scanned via WiFi: {uid}") + result = process_card(uid) + print(f"[WiFi Scan Root] Result: {result}") + return jsonify({'success': True, 'result': result}) + except Exception as e: + print(f"[WiFi Scan Root Error] {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/api/participants', methods=['GET']) +def get_participants(): + """Get all participants""" + participants = data_manager.get_all_participants() + return jsonify(participants) + +@app.route('/api/participants', methods=['POST']) +@require_admin +def add_participant(): + """Add a new participant (admin only)""" + try: + data = request.json + if not data: + return jsonify({'success': False, 'message': 'Invalid JSON'}), 400 + + name = (data.get('name') or '').strip() + card_uid = (data.get('card_uid') or '').strip() or None + key_uid = (data.get('key_uid') or '').strip() or None + expiration_date = (data.get('expiration_date') or '').strip() or None + + if not name: + return jsonify({'success': False, 'message': 'Name is required'}), 400 + + if not card_uid and not key_uid: + return jsonify({'success': False, 'message': 'At least one of card UID or key UID is required'}), 400 + + if data_manager.add_participant(name, card_uid, expiration_date, key_uid): + return jsonify({'success': True, 'message': 'Participant added'}) + else: + return jsonify({'success': False, 'message': 'Duplicate UID or error'}), 400 + except Exception as e: + print(f"Error adding participant: {e}") + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/api/participants/', methods=['PUT']) +@require_admin +def update_participant(participant_id): + """Update a participant (admin only)""" + try: + data = request.json + if not data: + return jsonify({'success': False, 'message': 'Invalid JSON'}), 400 + + name = (data.get('name') or '').strip() + card_uid = (data.get('card_uid') or '').strip() or None + key_uid = (data.get('key_uid') or '').strip() or None + expiration_date = (data.get('expiration_date') or '').strip() or None + + if not name: + return jsonify({'success': False, 'message': 'Name is required'}), 400 + + if not card_uid and not key_uid: + return jsonify({'success': False, 'message': 'At least one of card UID or key UID is required'}), 400 + + if data_manager.update_participant(participant_id, name, card_uid, expiration_date, key_uid): + return jsonify({'success': True, 'message': 'Participant updated'}) + else: + return jsonify({'success': False, 'message': 'Update failed'}), 400 + except Exception as e: + print(f"Error updating participant: {e}") + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/api/participants/', methods=['DELETE']) +@require_admin +def delete_participant(participant_id): + """Delete a participant (admin only)""" + if data_manager.delete_participant(participant_id): + return jsonify({'success': True, 'message': 'Participant deleted'}) + else: + return jsonify({'success': False, 'message': 'Delete failed'}), 400 + +@app.route('/api/logs', methods=['GET']) +def get_logs(): + """Get scan logs""" + filter_type = request.args.get('filter', 'All') + logs = data_manager.get_scan_history(1000) + + if filter_type == 'PASS': + logs = [log for log in logs if log['scan_result'] == 'PASS'] + elif filter_type == 'FAIL': + logs = [log for log in logs if log['scan_result'] == 'FAIL'] + + return jsonify(logs) + +@app.route('/api/logs/unregistered', methods=['POST']) +def add_unregistered_log(): + """Add unregistered user to logs""" + try: + data = request.json + if not data: + return jsonify({'success': False, 'message': 'Invalid JSON'}), 400 + + name = (data.get('name') or '').strip() + timestamp = (data.get('timestamp') or '').strip() or None + + if not name: + return jsonify({'success': False, 'message': 'Name is required'}), 400 + + if data_manager.log_unregistered_user(name, timestamp): + return jsonify({'success': True, 'message': 'Unregistered user logged'}) + else: + return jsonify({'success': False, 'message': 'Error logging user'}), 400 + except Exception as e: + print(f"Error adding unregistered log: {e}") + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/api/logs/', methods=['DELETE']) +@require_admin +def delete_log(log_id): + """Delete a log entry (admin only)""" + try: + if data_manager.delete_log(log_id): + return jsonify({'success': True, 'message': 'Log deleted'}) + else: + return jsonify({'success': False, 'message': 'Log not found'}), 404 + except Exception as e: + print(f"Error deleting log: {e}") + return jsonify({'success': False, 'message': str(e)}), 400 + +@app.route('/api/export', methods=['GET']) +def export_logs(): + """Export logs to Excel - returns file data for client-side saving""" + try: + import openpyxl + from openpyxl.styles import PatternFill, Font, Alignment + import io + import base64 + from datetime import datetime as dt + + logs = data_manager.get_scan_history(10000) + + # Apply filters from query parameters + filter_type = request.args.get('filter', 'All') + participant_filter = request.args.get('participant', '') + start_date = request.args.get('startDate', '') + end_date = request.args.get('endDate', '') + + # Filter by result type + if filter_type == 'PASS': + logs = [log for log in logs if log['scan_result'] == 'PASS'] + elif filter_type == 'FAIL': + logs = [log for log in logs if log['scan_result'] == 'FAIL'] + + # Filter by participant + if participant_filter: + logs = [log for log in logs if log.get('name') == participant_filter] + + # Filter by date range + if start_date: + start_dt = dt.strptime(start_date, '%Y-%m-%d') + logs = [log for log in logs if dt.fromisoformat(log['timestamp'].replace('Z', '+00:00')) >= start_dt] + + if end_date: + end_dt = dt.strptime(end_date, '%Y-%m-%d') + end_dt = end_dt.replace(hour=23, minute=59, second=59) + logs = [log for log in logs if dt.fromisoformat(log['timestamp'].replace('Z', '+00:00')) <= end_dt] + + if not logs: + return jsonify({'success': False, 'message': 'No logs to export'}), 400 + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Scan Logs" + + headers = ["ID", "Name", "Card UID", "Key UID", "Result", "Date", "Time"] + ws.append(headers) + + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + for log in logs: + timestamp = log['timestamp'] + date_part = timestamp[:10] + time_part = timestamp[11:19] + + ws.append([ + log['id'], + log['name'], + log['card_uid'], + log.get('key_uid', ''), + log['scan_result'], + date_part, + time_part + ]) + + pass_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + pass_font = Font(color="006100") + fail_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + fail_font = Font(color="9C0006") + + for idx, row in enumerate(ws.iter_rows(min_row=2, max_row=ws.max_row), start=2): + result_cell = row[4] # Result is now column 5 (index 4) + if result_cell.value == "PASS": + result_cell.fill = pass_fill + result_cell.font = pass_font + else: + result_cell.fill = fail_fill + result_cell.font = fail_font + + ws.column_dimensions['A'].width = 8 + ws.column_dimensions['B'].width = 20 + ws.column_dimensions['C'].width = 20 + ws.column_dimensions['D'].width = 20 + ws.column_dimensions['E'].width = 10 + ws.column_dimensions['F'].width = 12 + ws.column_dimensions['G'].width = 10 + + # Generate filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"scan_logs_{timestamp}.xlsx" + + # Save to bytes buffer + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + # Encode to base64 for transfer + file_data = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return jsonify({ + 'success': True, + 'filename': filename, + 'data': file_data + }) + + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route('/api/stats', methods=['GET']) +def get_stats(): + """Get statistics""" + stats = data_manager.get_statistics() + return jsonify(stats) + +@app.route('/api/documents', methods=['GET']) +def get_documents(): + """List PDF files in static/PDF folder""" + pdf_dir = os.path.join(app.static_folder, 'PDF') + os.makedirs(pdf_dir, exist_ok=True) + files = [f for f in os.listdir(pdf_dir) if f.lower().endswith('.pdf')] + files.sort() + return jsonify(files) + +@app.route('/api/last-scan', methods=['GET']) +def get_last_scan(): + """Get last scan result""" + return jsonify(last_scan_result if last_scan_result else { + 'uid': '-', + 'name': '-', + 'status': '-', + 'color': 'gray' + }) + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/data/participants.json b/data/participants.json new file mode 100644 index 0000000..2c3aa10 --- /dev/null +++ b/data/participants.json @@ -0,0 +1,10 @@ +[ + { + "id": 1, + "name": "Matej", + "card_uid": "11 11", + "key_uid": null, + "expiration_date": null, + "date_added": "2026-02-07T08:06:13.526959" + } +] \ No newline at end of file diff --git a/data/scan_logs.json b/data/scan_logs.json new file mode 100644 index 0000000..9657029 --- /dev/null +++ b/data/scan_logs.json @@ -0,0 +1,11 @@ +[ + { + "id": 1, + "card_uid": null, + "key_uid": null, + "name": "Dejan", + "scan_result": "MANUAL", + "reason": "Unregistered", + "timestamp": "2026-02-12T13:34:11.034695" + } +] \ No newline at end of file diff --git a/data_manager.py b/data_manager.py new file mode 100644 index 0000000..bfe529b --- /dev/null +++ b/data_manager.py @@ -0,0 +1,248 @@ +import json +import os +from datetime import datetime +from typing import Dict, List, Optional + +class DataManager: + def __init__(self, participants_file="participants.json", logs_file="scan_logs.json"): + self.participants_file = participants_file + self.logs_file = logs_file + self.init_files() + + def init_files(self): + """Initialize JSON files if they don't exist""" + if not os.path.exists(self.participants_file): + self.save_participants([]) + if not os.path.exists(self.logs_file): + self.save_logs([]) + + def load_participants(self) -> List[Dict]: + """Load participants from JSON file""" + try: + with open(self.participants_file, 'r') as f: + return json.load(f) + except Exception as e: + print(f"Error loading participants: {e}") + return [] + + def save_participants(self, participants: List[Dict]): + """Save participants to JSON file""" + try: + with open(self.participants_file, 'w') as f: + json.dump(participants, f, indent=2) + except Exception as e: + print(f"Error saving participants: {e}") + + def load_logs(self) -> List[Dict]: + """Load scan logs from JSON file""" + try: + with open(self.logs_file, 'r') as f: + return json.load(f) + except Exception as e: + print(f"Error loading logs: {e}") + return [] + + def save_logs(self, logs: List[Dict]): + """Save scan logs to JSON file""" + try: + with open(self.logs_file, 'w') as f: + json.dump(logs, f, indent=2) + except Exception as e: + print(f"Error saving logs: {e}") + + def add_participant(self, name: str, card_uid: str = None, expiration_date: str = None, key_uid: str = None) -> bool: + """Add a new participant""" + try: + participants = self.load_participants() + + # Check if card UID already exists + if card_uid and any(p.get('card_uid') == card_uid for p in participants): + return False + + # Check if key UID already exists + if key_uid and any(p.get('key_uid') == key_uid for p in participants): + return False + + new_participant = { + 'id': len(participants) + 1, + 'name': name, + 'card_uid': card_uid, + 'key_uid': key_uid, + 'expiration_date': expiration_date, + 'date_added': datetime.now().isoformat() + } + participants.append(new_participant) + self.save_participants(participants) + return True + except Exception as e: + print(f"Error adding participant: {e}") + return False + + def get_participant_by_card(self, card_uid: str) -> Optional[Dict]: + """Get participant by card UID or key UID""" + try: + participants = self.load_participants() + for p in participants: + if p.get('card_uid') == card_uid or p.get('key_uid') == card_uid: + return p + return None + except Exception as e: + print(f"Error getting participant: {e}") + return None + + def is_card_expired(self, participant: Dict) -> bool: + """Check if participant's card is expired""" + try: + expiration_date = participant.get('expiration_date') + if not expiration_date: + return False + + exp_date = datetime.fromisoformat(expiration_date.replace('Z', '+00:00')) + current_date = datetime.now() + return current_date > exp_date + except Exception as e: + print(f"Error checking expiration: {e}") + return False + + def get_all_participants(self) -> List[Dict]: + """Get all participants""" + return self.load_participants() + + def update_participant(self, participant_id: int, name: str, card_uid: str = None, expiration_date: str = None, key_uid: str = None) -> bool: + """Update participant information""" + try: + participants = self.load_participants() + + # Find the participant being updated + participant_to_update = None + for p in participants: + if p['id'] == participant_id: + participant_to_update = p + break + + if not participant_to_update: + return False + + # Check if new card_uid is already used by another participant (skip if same as current) + if card_uid and card_uid != participant_to_update.get('card_uid'): + for p in participants: + if p['id'] != participant_id and p.get('card_uid') == card_uid: + return False + + # Check if new key_uid is already used by another participant (skip if same as current) + if key_uid and key_uid != participant_to_update.get('key_uid'): + for p in participants: + if p['id'] != participant_id and p.get('key_uid') == key_uid: + return False + + # Update name (always) + participant_to_update['name'] = name + + # Update UIDs and expiration only if explicitly provided (not None) + # card_uid and key_uid can be explicitly set to empty string to clear them + if card_uid is not None: + participant_to_update['card_uid'] = card_uid if card_uid else None + if key_uid is not None: + participant_to_update['key_uid'] = key_uid if key_uid else None + if expiration_date is not None: + participant_to_update['expiration_date'] = expiration_date if expiration_date else None + + self.save_participants(participants) + return True + except Exception as e: + print(f"Error updating participant: {e}") + return False + + def delete_participant(self, participant_id: int) -> bool: + """Delete participant""" + try: + participants = self.load_participants() + participants = [p for p in participants if p['id'] != participant_id] + self.save_participants(participants) + return True + except Exception as e: + print(f"Error deleting participant: {e}") + return False + + def log_scan(self, scanned_uid: str, result: str, name: str = "Unknown", reason: str = None, card_uid: str = None, key_uid: str = None): + """Log a card/key scan""" + try: + logs = self.load_logs() + log_entry = { + 'id': len(logs) + 1, + 'card_uid': card_uid, + 'key_uid': key_uid, + 'name': name, + 'scan_result': result, + 'reason': reason, + 'timestamp': datetime.now().isoformat() + } + logs.append(log_entry) + self.save_logs(logs) + except Exception as e: + print(f"Error logging scan: {e}") + + def get_scan_history(self, limit: int = 100) -> List[Dict]: + """Get scan history (most recent first)""" + try: + logs = self.load_logs() + return logs[-limit:][::-1] # Return last N items in reverse order + except Exception as e: + print(f"Error getting scan history: {e}") + return [] + + def get_statistics(self) -> Dict: + """Get statistics from data""" + try: + participants = self.load_participants() + logs = self.load_logs() + + pass_count = sum(1 for l in logs if l['scan_result'] == 'PASS') + fail_count = sum(1 for l in logs if l['scan_result'] == 'FAIL') + + return { + 'total_participants': len(participants), + 'total_passes': pass_count, + 'total_fails': fail_count, + 'total_scans': len(logs) + } + except Exception as e: + print(f"Error getting statistics: {e}") + return {} + + def log_unregistered_user(self, name: str, timestamp: str = None) -> bool: + """Log an unregistered user manually (admin only)""" + try: + logs = self.load_logs() + log_entry = { + 'id': len(logs) + 1, + 'card_uid': None, + 'key_uid': None, + 'name': name, + 'scan_result': 'MANUAL', + 'reason': 'Unregistered', + 'timestamp': timestamp if timestamp else datetime.now().isoformat() + } + logs.append(log_entry) + self.save_logs(logs) + return True + except Exception as e: + print(f"Error logging unregistered user: {e}") + return False + + def delete_log(self, log_id: int) -> bool: + """Delete a log entry by ID (admin only)""" + try: + logs = self.load_logs() + original_length = len(logs) + logs = [log for log in logs if log['id'] != log_id] + + if len(logs) == original_length: + # Log ID not found + return False + + self.save_logs(logs) + return True + except Exception as e: + print(f"Error deleting log: {e}") + return False diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..81b70a5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.1.0 +flask-cors==4.0.0 +openpyxl==3.1.5 +python-dotenv==1.0.1 diff --git a/static/assets/logo.png b/static/assets/logo.png new file mode 100644 index 0000000..da243c2 Binary files /dev/null and b/static/assets/logo.png differ diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..df7c1f5 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1347 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Dark Mode Variables */ +:root { + --bg-primary: #2c2c2c; + --bg-secondary: #ffffff; + --bg-tertiary: #f5f5f5; + --bg-quaternary: #fafafa; + --text-primary: #000000; + --text-secondary: #666666; + --border-color: #e0e0e0; + --accent-blue: #3A3A3C; + --accent-blue-hover: #2a2a2c; + --accent-green: #10b981; + --accent-green-hover: #059669; + --accent-red: #ef4444; + --accent-red-hover: #dc2626; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +html { + height: 100%; + width: 100%; +} + +body { + width: 100%; + overflow-x: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); +} + +.container { + display: flex; + height: 100vh; + gap: 16px; + padding: 16px; +} + +/* Left Panel */ +.left-panel { + width: 320px; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 24px; + overflow-y: auto; + box-shadow: none; +} + +.logo-section { + text-align: center; + margin-bottom: 32px; +} + +.logo { + max-width: 140px; + height: auto; + border-radius: 8px; +} + +.result-section { + margin-bottom: 32px; +} + +.result-section h3, +.stats-section h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + color: var(--accent-blue); + border-bottom: 2px solid var(--accent-blue); + padding-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stats-section { + margin-bottom: 32px; +} + +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.stat-card { + background: var(--bg-quaternary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + text-align: center; + box-shadow: none; +} + +.stat-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 8px; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + color: var(--accent-blue); +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.form-group select, +.form-group input { + width: 100%; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 14px; + background-color: var(--bg-tertiary); + color: var(--text-primary); + transition: all 0.2s ease; +} + +.form-group select:focus, +.form-group input:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + background-color: var(--bg-tertiary); +} + +.result-card { + background: var(--bg-quaternary); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 24px; + text-align: center; + min-height: 160px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 12px; +} + +.result-logo { + max-width: 280px; + height: auto; + border-radius: 8px; + display: block !important; + margin: 0 auto 20px auto; +} + +.result-card.pass { + background: #f0fdf4; + border-color: var(--accent-green); +} + +.result-card.fail { + background: #fef2f2; + border-color: var(--accent-red); +} + +.result-card.fail .result-uid-box { + border-color: var(--accent-red); +} + +.result-card.fail .result-name { + border-color: var(--accent-red); +} + +.result-uid-box { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-bottom: 12px; + border: 2px solid var(--accent-green); + border-radius: 6px; + padding: 10px 14px; + background-color: #ffffff; + min-width: 200px; +} + +.result-uid { + font-size: 12px; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.3px; + font-weight: 600; +} + +.result-name { + font-size: 14px; + margin-bottom: 8px; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.3px; + border: 2px solid var(--accent-green); + border-radius: 6px; + padding: 10px 14px; + background-color: #ffffff; + font-weight: 600; + min-width: 200px; + text-align: center; +} + +.copy-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease; +} + +.copy-btn:hover { + color: var(--accent-blue); +} + +.copy-btn svg { + transition: transform 0.2s ease; +} + +.copy-btn:hover svg { + transform: scale(1.15); +} + +.result-status { + font-size: 28px; + font-weight: 700; + margin-top: 16px; + letter-spacing: 1px; +} + +.result-card.pass .result-status { + color: var(--accent-green); +} + +.result-card.fail .result-status { + color: var(--accent-red); +} + +/* Right Panel */ +.right-panel { + flex: 1; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: none; + gap: 0; + border-width: 1px; +} + +/* Admin Section - similar to result-section */ +.admin-section { + margin-bottom: 32px; +} + +.admin-section h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + color: var(--accent-blue); + border-bottom: 2px solid var(--accent-blue); + padding-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.admin-btn { + width: 100%; + padding: 16px; + font-size: 14px; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + background-color: #3A3A3C; + color: #ffffff; +} + +.admin-btn:hover { + background-color: #2a2a2c; +} + +.admin-btn:active { + transform: translateY(0); +} + +.admin-btn.active { + background-color: var(--accent-green); + color: #ffffff; +} + +.admin-btn.active:hover { + background-color: var(--accent-green-hover); +} + +/* Tab Navigation */ +.tab-nav { + display: flex; + gap: 0; + border-bottom: none; + background-color: transparent; + padding: 12px 0 0 0; + margin: 0; +} + +.tab-nav-btn { + flex: 1; + padding: 14px 24px; + background-color: #e8e8e8; + border: 2px solid var(--border-color); + border-bottom: none; + border-radius: 8px 8px 0 0; + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + position: relative; + margin: 0 4px; +} + +.tab-nav-btn:first-child { + margin-left: 0; +} + +.tab-nav-btn:last-child { + margin-right: 0; +} + +.tab-nav-btn:hover { + color: var(--accent-blue); + border-color: var(--accent-blue); +} + +.tab-nav-btn.active { + color: #ffffff; + background-color: var(--accent-blue); + border-color: var(--accent-blue); + border-bottom: none; +} + +/* Tab Panels */ +.tab-panel { + display: none; + flex: 1; + overflow: hidden; + background-color: var(--bg-secondary); + flex-direction: column; + border: 2px solid var(--border-color); + border-radius: 0 0 8px 8px; + margin: 0 0 12px 0; + border-top-width: 0; +} + +.tab-panel.active { + display: flex; + border: 2px solid var(--accent-blue); + border-top-width: 2px; + background-color: var(--bg-secondary); +} + +.tab-panel::-webkit-scrollbar { + width: 8px; +} + +.tab-panel::-webkit-scrollbar-track { + background: var(--bg-tertiary); +} + +.tab-panel::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.tab-panel::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Panel Header */ +.panel-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-secondary); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.panel-header h2 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.header-actions { + display: flex; + gap: 10px; + flex-wrap: nowrap; + flex-shrink: 0; +} + +.header-actions .btn { + width: auto; + margin: 0; + padding: 8px 14px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; +} + +/* Panel Filters */ +.panel-filters { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-secondary); + display: flex; + flex-direction: row; + gap: 16px; + flex-shrink: 0; + align-items: center; + flex-wrap: wrap; +} + +.filters-row { + display: flex; + gap: 14px; + flex-wrap: nowrap; + align-items: center; + flex: 1; +} + +.filters-quick { + display: flex; + gap: 8px; + flex-wrap: nowrap; + align-items: center; + margin-left: auto; +} + +/* Panel Content */ +.panel-content { + flex: 1; + padding: 16px; + overflow-y: auto; + min-height: 0; +} + +/* Two-column layout for tab content */ +.tab-layout { + display: flex; + gap: 20px; + flex: 1; + height: 100%; +} + +.tab-left-column { + width: 250px; + flex-shrink: 0; + overflow-y: auto; + padding: 0 8px 0 20px; +} + +.tab-right-column { + flex: 1; + overflow-y: auto; + padding: 0 20px 0 0; +} + +.tab-left-column::-webkit-scrollbar, +.tab-right-column::-webkit-scrollbar { + width: 8px; +} + +.tab-left-column::-webkit-scrollbar-track, +.tab-right-column::-webkit-scrollbar-track { + background: var(--bg-tertiary); +} + +.tab-left-column::-webkit-scrollbar-thumb, +.tab-right-column::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.tab-left-column::-webkit-scrollbar-thumb:hover, +.tab-right-column::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +.filter-group { + display: flex; + flex-direction: row; + gap: 6px; + align-items: center; + flex: 1; + min-width: 0; +} + +.filter-group label { + font-weight: 600; + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; + flex-shrink: 0; +} + +.filter-group select, +.filter-group input { + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 13px; + background-color: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + min-width: 0; + height: 40px; +} + +.filter-group select:hover, +.filter-group input:hover { + border-color: var(--accent-blue); +} + +.filter-group select:focus, +.filter-group input:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + background-color: var(--bg-secondary); +} + +.btn-filter { + padding: 10px 18px; + background-color: #3A3A3C; + color: #ffffff; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; + flex-shrink: 0; + height: 40px; + display: flex; + align-items: center; +} + +.btn-filter:hover { + background-color: #2a2a2c; +} + +.quick-filter-buttons { + display: flex; + gap: 8px; + flex-wrap: nowrap; +} + +.btn-quick-filter { + padding: 10px 14px; + background-color: #f5f5f5; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; + flex: 1; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-quick-filter:hover { + border-color: var(--accent-blue); + background-color: #efefef; +} + +.btn-quick-filter.active { + background-color: var(--accent-blue); + color: #ffffff; + border-color: var(--accent-blue); +} + +.table-container { + overflow-x: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--bg-secondary); + -webkit-overflow-scrolling: touch; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + table-layout: auto; +} + +table thead { + background-color: var(--bg-tertiary); + position: sticky; + top: 0; + z-index: 10; +} + +table th { + padding: 14px 12px; + text-align: left; + font-weight: 600; + color: var(--text-primary); + border-bottom: 2px solid var(--border-color); + text-transform: uppercase; + letter-spacing: 0.3px; + font-size: 12px; + word-wrap: break-word; +} + +table td { + padding: 12px; + border-bottom: 1px solid #f0f0f0; + word-wrap: break-word; + overflow-wrap: break-word; + color: var(--text-primary); +} + +table td[style*="display: none"], +table th[style*="display: none"] { + display: none !important; + width: 0 !important; + padding: 0 !important; + border: none !important; +} + +table tbody tr { + background-color: var(--bg-secondary); + transition: background-color 0.15s ease; +} + +table tbody tr:hover { + background-color: #f9f9f9; +} + +table tbody tr.expired { + background-color: #fef2f2; + border-left: 3px solid var(--accent-red); +} + +table tbody tr.expired:hover { + background-color: #ffe5e5; +} + +table .pass { + color: var(--accent-green); + font-weight: 600; +} + +table .fail { + color: var(--accent-red); + font-weight: 600; +} + +table .manual { + color: #8b5cf6; + font-weight: 600; +} + +table tbody tr.duplicate-warning { + background-color: #fffbf0; + border-left: 3px solid #f59e0b; +} + +table tbody tr.duplicate-warning:hover { + background-color: #fffaf5; +} + +.action-btn { + padding: 8px 14px; + margin-right: 6px; + font-size: 12px; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.edit-btn { + background-color: #3A3A3C; + color: #ffffff; +} + +.edit-btn:hover { + background-color: #2a2a2c; +} + +.delete-btn { + background-color: var(--accent-red); + color: white; +} + +.delete-btn:hover { + background-color: var(--accent-red-hover); +} + +.btn { + width: 100%; + padding: 12px; + margin-bottom: 12px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.btn-primary { + background-color: #3A3A3C; + color: #ffffff; +} + +.btn-primary:hover { + background-color: #2a2a2c; +} + + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: var(--bg-secondary); + padding: 32px; + border-radius: 8px; + border: 1px solid var(--border-color); + width: 90%; + max-width: 420px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.modal-content h2 { + margin-bottom: 24px; + color: var(--accent-blue); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.close { + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: var(--text-secondary); + transition: color 0.2s ease; + line-height: 1; + margin-top: -4px; +} + +.close:hover { + color: var(--text-primary); +} + +/* Login Modal Styling */ +#loginModal .modal-content { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); + border: 2px solid var(--accent-blue); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +#loginModal .modal-content h2 { + color: var(--accent-blue); + margin-bottom: 28px; + text-align: center; + font-size: 18px; +} + +#loginModal .form-group label { + color: var(--accent-blue); + font-weight: 700; + font-size: 12px; + margin-bottom: 10px; +} + +#loginModal .form-group input { + padding: 14px 16px; + font-size: 15px; + border: 2px solid var(--border-color); + border-radius: 8px; + transition: all 0.3s ease; + background-color: var(--bg-secondary); +} + +#loginModal .form-group input:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15); + background-color: var(--bg-secondary); +} + +#loginModal .form-group input::placeholder { + color: var(--text-secondary); +} + +#loginModal .btn-primary { + background: linear-gradient(135deg, #3A3A3C 0%, #2a2a2c 100%); + border: 2px solid transparent; + font-size: 13px; + letter-spacing: 1px; + padding: 14px; + margin-top: 8px; +} + +#loginModal .btn-primary:hover { + background: linear-gradient(135deg, #2a2a2c 0%, #1a1a1c 100%); + border-color: var(--accent-blue); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-tertiary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Desktop View */ +@media (min-width: 1024px) { + .container { + gap: 20px; + padding: 20px; + } + + .left-panel { + width: 360px; + padding: 28px; + } + + .right-panel { + padding: 28px; + } +} + +/* Tablet View */ +@media (min-width: 769px) and (max-width: 1023px) { + .container { + gap: 14px; + padding: 14px; + } + + .left-panel { + width: 300px; + padding: 20px; + } + + .right-panel { + padding: 20px; + } + + .tab-btn { + padding: 10px 16px; + font-size: 13px; + } +} + +/* Login Modal Mobile */ +@media (max-width: 768px) { + #loginModal .modal-content { + padding: 28px; + max-width: 85%; + } + + #loginModal .modal-content h2 { + font-size: 16px; + margin-bottom: 24px; + } + + .admin-bar { + padding: 12px 16px; + gap: 8px; + } + + .admin-bar span { + font-size: 10px; + } + + .btn-admin { + padding: 8px 16px; + font-size: 11px; + } +} + +/* Mobile View */ +@media (max-width: 768px) { + html, body { + height: auto; + min-height: 100vh; + } + + .container { + flex-direction: column; + gap: 12px; + padding: 12px; + height: auto; + } + + .tab-layout { + flex-direction: column; + gap: 12px; + } + + .tab-left-column { + width: 100%; + flex-shrink: 0; + } + + .left-panel { + width: 100%; + padding: 16px; + min-height: auto; + } + + .right-panel { + width: 100%; + padding: 16px; + min-height: 60vh; + } + + .logo-section { + margin-bottom: 20px; + } + + .logo { + max-width: 100px; + } + + .result-section { + margin-bottom: 24px; + } + + .result-card { + min-height: 120px; + padding: 16px; + } + + .result-status { + font-size: 24px; + } + + .tabs { + gap: 4px; + margin-bottom: 16px; + } + + .tab-btn { + padding: 10px 14px; + font-size: 12px; + } + + .add-section, + .list-section { + margin-bottom: 20px; + } + + .action-btn { + padding: 6px 10px; + font-size: 11px; + margin-right: 4px; + } + + .table-container { + font-size: 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + width: 100%; + border-radius: 8px; + } + + table { + min-width: 600px; + table-layout: auto; + } + + table th, + table td { + padding: 10px 12px; + font-size: 12px; + word-wrap: break-word; + white-space: normal; + } + + .filters-container { + grid-template-columns: 1fr; + gap: 12px; + } + + .filter-group { + width: 100%; + } + + .filter-group select, + .filter-group input { + width: 100%; + } + + .btn-filter { + width: 100%; + } + + .controls-section { + flex-direction: column; + } + + .btn { + padding: 10px; + font-size: 13px; + margin-bottom: 10px; + } + + .modal-content { + padding: 24px; + max-width: 90%; + } +} + +/* Small Mobile View */ +@media (max-width: 480px) { + .container { + padding: 8px; + gap: 8px; + } + + .left-panel, + .right-panel { + padding: 12px; + } + + .logo { + max-width: 80px; + } + + .form-group { + margin-bottom: 12px; + } + + .action-btn { + padding: 6px 8px; + font-size: 10px; + margin-bottom: 4px; + display: inline-block; + margin-right: 2px; + } + + .tab-btn { + padding: 8px 12px; + font-size: 11px; + } + + .modal-content { + padding: 20px; + } +} + +/* High DPI / Retina Display */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + * { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } +} + +/* Landscape Mobile */ +@media (max-height: 600px) and (orientation: landscape) { + .container { + height: auto; + min-height: 100vh; + } + + .left-panel, + .right-panel { + padding: 12px; + } + + .result-card { + min-height: auto; + padding: 12px; + } + + .tab-content { + flex: auto; + } +} + +/* Dark Mode - Additional refinements for better readability */ +@media (prefers-color-scheme: dark) { + /* Already using dark mode as default */ +} + +/* Documents Grid */ +.documents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 20px; + padding: 4px; +} + +.document-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + text-align: center; +} + +.document-icon { + color: var(--text-secondary); +} + +.document-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + word-break: break-word; + line-height: 1.4; +} + +.document-print-btn { + width: 100%; + padding: 8px 12px; + font-size: 13px; +} + +/* Print Styles */ +@media print { + body { + background: #fff !important; + color: #000 !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + body * { + visibility: hidden; + } + + #print-header, + #print-header *, + #logs-tab, + #logs-tab * { + visibility: visible; + } + + #print-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + text-align: center; + padding: 16px 0 8px; + background: #fff; + border: none !important; + outline: none !important; + box-shadow: none !important; + visibility: visible !important; + display: block !important; + } + + #print-header img { + height: 64px; + display: block; + margin: 0 auto 8px; + } + + + #logs-tab { + position: absolute; + top: 160px; + left: 0; + width: 100%; + background: #fff !important; + border: none !important; + outline: none !important; + box-shadow: none !important; + } + + .panel-header { + border: none !important; + box-shadow: none !important; + } + + .panel-content, .table-container { + border: none !important; + box-shadow: none !important; + overflow: visible !important; + } + + .panel-header .header-actions, + .panel-filters, + .action-btn, + #logs-actions-header { + display: none !important; + } + + .table-container { + border: 1px solid #e0e0e0; + border-radius: 8px; + background: #fff; + overflow: visible !important; + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + table-layout: auto; + } + + table thead { + background-color: #f5f5f5 !important; + } + + table th { + padding: 14px 12px; + text-align: left; + font-weight: 600; + color: #000 !important; + border-bottom: 2px solid #e0e0e0 !important; + text-transform: uppercase; + letter-spacing: 0.3px; + font-size: 12px; + background-color: #f5f5f5 !important; + } + + table td { + padding: 12px; + border-bottom: 1px solid #f0f0f0 !important; + color: #000 !important; + background-color: #fff !important; + } + + table tbody tr:nth-child(even) td { + background-color: #fafafa !important; + } + + table .pass { color: #10b981 !important; font-weight: 600; } + table .fail { color: #ef4444 !important; font-weight: 600; } + table .manual { color: #8b5cf6 !important; font-weight: 600; } +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..f5417f3 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,874 @@ +let editingParticipantId = null; +let isAdmin = false; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + checkAdminStatus(); + loadParticipants(); + // Show today's entries by default + filterByToday(); + updateLastScanResult(); + + // Auto-refresh logs every 5 seconds + setInterval(refreshLogs, 5000); + setInterval(loadParticipants, 5000); + setInterval(updateLastScanResult, 1000); + setInterval(checkAdminStatus, 10000); +}); + +// ===== AUTHENTICATION ===== +async function checkAdminStatus() { + try { + const response = await fetch('/api/check-admin'); + const data = await response.json(); + isAdmin = data.is_admin; + updateAdminUI(); + } catch (error) { + console.error('Error checking admin status:', error); + } +} + +function updateAdminUI() { + const adminBtn = document.getElementById('adminToggleBtn'); + const addBtn = document.querySelector('[onclick="openAddParticipantModal()"]'); + const addUnregisteredBtn = document.getElementById('addUnregisteredBtn'); + + if (!adminBtn) return; // Exit if button doesn't exist yet + + if (isAdmin) { + adminBtn.textContent = 'Odjava Admin'; + adminBtn.onclick = adminLogout; + adminBtn.classList.add('active'); + if (addBtn) addBtn.style.display = 'block'; + } else { + adminBtn.textContent = 'Prijava Admin'; + adminBtn.onclick = openLoginModal; + adminBtn.classList.remove('active'); + if (addBtn) addBtn.style.display = 'none'; + } + // Show unregistered button to both admin and non-admin users + if (addUnregisteredBtn) addUnregisteredBtn.style.display = 'block'; + updateUIDVisibility(); +} + +async function adminLogin() { + const password = document.getElementById('loginPassword').value; + + if (!password) { + alert('Prosimo, vnesite geslo'); + return; + } + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + const data = await response.json(); + + if (data.success) { + isAdmin = true; + closeLoginModal(); + updateAdminUI(); + loadParticipants(); + alert('Uspešna prijava!'); + } else { + alert('Napaka: ' + data.message); + } + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +async function adminLogout() { + try { + const response = await fetch('/api/logout', { + method: 'POST' + }); + + if (response.ok) { + isAdmin = false; + updateAdminUI(); + loadParticipants(); + alert('Odjavljen'); + } + } catch (error) { + console.error('Logout error:', error); + } +} + +function openLoginModal() { + document.getElementById('loginModal').classList.add('show'); + document.getElementById('loginPassword').focus(); +} + +function closeLoginModal() { + document.getElementById('loginModal').classList.remove('show'); + document.getElementById('loginPassword').value = ''; +} + +function handleLoginKeypress(event) { + if (event.key === 'Enter') { + adminLogin(); + } +} + +// ===== PARTICIPANT MANAGEMENT ===== +async function loadParticipants() { + try { + const response = await fetch('/api/participants'); + const participants = await response.json(); + + const tbody = document.getElementById('participantsTable'); + tbody.innerHTML = ''; + + participants.forEach(p => { + const row = document.createElement('tr'); + const dateAdded = p.date_added ? p.date_added.split('T')[0] : '-'; + const expiration = p.expiration_date ? p.expiration_date.split('T')[0] : '-'; + + // Check if card is expired + let isExpired = false; + if (expiration !== '-') { + const expirationDate = new Date(expiration); + const today = new Date(); + today.setHours(0, 0, 0, 0); + isExpired = expirationDate < today; + } + + const keyUID = p.key_uid ? p.key_uid : '-'; + + const actionButtons = isAdmin ? ` + + + ` : ''; + + row.innerHTML = ` + ${p.id} + ${p.name} + ${p.card_uid || '-'} + ${keyUID} + ${expiration} + ${dateAdded} + ${actionButtons || '-'} + `; + + // Add expired class if card is expired + if (isExpired) { + row.classList.add('expired'); + } + + tbody.appendChild(row); + }); + + // Update participant filter dropdown + updateParticipantFilter(participants); + updateUIDVisibility(); + } catch (error) { + console.error('Error loading participants:', error); + } +} + +function updateParticipantFilter(participants) { + const filterSelect = document.getElementById('participantFilter'); + const currentValue = filterSelect.value; + + // Clear existing options except the first one + filterSelect.innerHTML = ''; + + // Sort participants by name and add to dropdown + participants + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach(p => { + const option = document.createElement('option'); + option.value = p.name; + option.textContent = p.name; + filterSelect.appendChild(option); + }); + + // Restore previous selection if it still exists + filterSelect.value = currentValue; +} + +async function addParticipant() { + const name = document.getElementById('inputName').value.trim(); + const cardUID = document.getElementById('inputCardUID').value.trim(); + const keyUID = document.getElementById('inputKeyUID').value.trim(); + const expiration = document.getElementById('inputExpiration').value; + + if (!name) { + alert('Prosimo, izpolnite polje za ime'); + return; + } + + if (!cardUID && !keyUID) { + alert('Prosimo, vnesite vsaj UID kartice ali UID ključa'); + return; + } + + try { + const response = await fetch('/api/participants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name, + card_uid: cardUID || null, + key_uid: keyUID || null, + expiration_date: expiration || null + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + alert('Napaka: ' + (errorData.message || 'Unknown error')); + return; + } + + const data = await response.json(); + if (data.success) { + alert('Udeleženec je bil uspešno dodan'); + closeAddParticipantModal(); + loadParticipants(); + } else { + alert('Napaka: ' + data.message); + } + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +function openEditModal(id, name, cardUID, keyUID, expiration) { + editingParticipantId = id; + document.getElementById('editName').value = name; + document.getElementById('editCardUID').value = cardUID; + document.getElementById('editKeyUID').value = keyUID; + document.getElementById('editExpiration').value = expiration !== '-' ? expiration : ''; + document.getElementById('editModal').classList.add('show'); +} + +function openAddParticipantModal() { + document.getElementById('addParticipantModal').classList.add('show'); +} + +function closeAddParticipantModal() { + document.getElementById('addParticipantModal').classList.remove('show'); + document.getElementById('inputName').value = ''; + document.getElementById('inputCardUID').value = ''; + document.getElementById('inputKeyUID').value = ''; + document.getElementById('inputExpiration').value = ''; +} + +function openAddUnregisteredModal() { + document.getElementById('addUnregisteredModal').classList.add('show'); + document.getElementById('inputUnregisteredName').focus(); +} + +function closeAddUnregisteredModal() { + document.getElementById('addUnregisteredModal').classList.remove('show'); + document.getElementById('inputUnregisteredName').value = ''; +} + +async function addUnregisteredUser() { + const name = document.getElementById('inputUnregisteredName').value.trim(); + + if (!name) { + alert('Prosimo, izpolnite polje za ime'); + return; + } + + try { + const response = await fetch('/api/logs/unregistered', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name + }) + }); + + const data = await response.json(); + if (data.success) { + alert('Neregistrirani udeleženec je bil uspešno dodan'); + closeAddUnregisteredModal(); + refreshLogs(); + } else { + alert('Napaka: ' + data.message); + } + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +function closeModal() { + document.getElementById('editModal').classList.remove('show'); + editingParticipantId = null; +} + +async function saveChanges() { + if (!editingParticipantId) return; + + const name = document.getElementById('editName').value.trim(); + const cardUID = document.getElementById('editCardUID').value.trim(); + const keyUID = document.getElementById('editKeyUID').value.trim(); + const expiration = document.getElementById('editExpiration').value; + + if (!name) { + alert('Prosimo, izpolnite polje za ime'); + return; + } + + if (!cardUID && !keyUID) { + alert('Prosimo, vnesite vsaj UID kartice ali UID ključa'); + return; + } + + try { + const response = await fetch(`/api/participants/${editingParticipantId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name, + card_uid: cardUID, + key_uid: keyUID, + expiration_date: expiration || null + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + alert('Napaka: ' + (errorData.message || 'Unknown error')); + return; + } + + const data = await response.json(); + if (data.success) { + alert('Udeleženec je bil uspešno posodobljen'); + closeModal(); + loadParticipants(); + } else { + alert('Napaka: ' + data.message); + } + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +async function deleteParticipant(id) { + if (!confirm('Ali ste prepričani, da želite izbrisati tega udeleženca?')) return; + + try { + const response = await fetch(`/api/participants/${id}`, { + method: 'DELETE' + }); + + const data = await response.json(); + if (data.success) { + alert('Udeleženec je bil uspešno izbrisan'); + loadParticipants(); + } else { + alert('Napaka: ' + data.message); + } + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +// ===== LOGS MANAGEMENT ===== +async function refreshLogs() { + try { + const filter = document.getElementById('filterSelect').value || 'All'; + const response = await fetch(`/api/logs?filter=${filter}`); + const logs = await response.json(); + + // Apply client-side filtering + const filteredLogs = applyLogFilters(logs); + + const tbody = document.getElementById('logsTable'); + tbody.innerHTML = ''; + + if (filteredLogs.length === 0) { + tbody.innerHTML = `Ni najdenih dnevnikov, ki bi ustrezali filtrom`; + return; + } + + // Count occurrences of each name to detect duplicates + const nameCounts = {}; + filteredLogs.forEach(log => { + nameCounts[log.name] = (nameCounts[log.name] || 0) + 1; + }); + + // Count registered vs unregistered users + let registeredCount = 0; + let unregisteredCount = 0; + + filteredLogs.forEach(log => { + const row = document.createElement('tr'); + const timestamp = log.timestamp; + const date = timestamp.split('T')[0]; + const time = timestamp.split('T')[1].substring(0, 8); + + // Determine status class + let statusClass = 'pass'; + if (log.scan_result === 'FAIL') { + statusClass = 'fail'; + } else if (log.scan_result === 'MANUAL') { + statusClass = 'manual'; + } + + // Check if this is an unregistered duplicate + const isDuplicate = nameCounts[log.name] > 1; + + const actionButtons = isAdmin ? ` + + ` : ''; + + row.innerHTML = ` + ${log.id} + ${log.name} + ${log.card_uid || '-'} + ${log.key_uid || '-'} + ${log.scan_result} + ${date} + ${time} + ${actionButtons} + `; + + // Add duplicate warning class if this is a duplicate unregistered user + if (isDuplicate && log.scan_result === 'MANUAL') { + row.classList.add('duplicate-warning'); + } + + // Count registered vs unregistered + if (log.scan_result === 'MANUAL') { + unregisteredCount++; + } else if (log.scan_result === 'PASS') { + registeredCount++; + } + + tbody.appendChild(row); + }); + + // Update stats display + document.getElementById('statRegistered').textContent = registeredCount; + document.getElementById('statUnregistered').textContent = unregisteredCount; + + updateUIDVisibility(); + } catch (error) { + console.error('Error loading logs:', error); + } +} + +function applyLogFilters(logs) { + const participantFilter = document.getElementById('participantFilter').value; + const startDateFilter = document.getElementById('startDateFilter').value; + const endDateFilter = document.getElementById('endDateFilter').value; + + return logs.filter(log => { + // Filter by participant + if (participantFilter && log.name !== participantFilter) { + return false; + } + + // Filter by date range + if (startDateFilter || endDateFilter) { + const logDate = log.timestamp.split('T')[0]; + + if (startDateFilter && logDate < startDateFilter) { + return false; + } + + if (endDateFilter && logDate > endDateFilter) { + return false; + } + } + + return true; + }); +} + +async function deleteLog(logId) { + if (!confirm('Ali ste prepričani, da želite izbrisati ta dnevnik?')) return; + + try { + const response = await fetch(`/api/logs/${logId}`, { + method: 'DELETE' + }); + + const data = await response.json(); + if (data.success) { + alert('Dnevnik je bil uspešno izbrisan'); + refreshLogs(); + } else { + alert('Napaka: ' + data.message); + } + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +function resetFilters() { + document.getElementById('filterSelect').value = 'All'; + document.getElementById('participantFilter').value = ''; + document.getElementById('startDateFilter').value = ''; + document.getElementById('endDateFilter').value = ''; + // Remove active class from all quick filter buttons + document.querySelectorAll('.btn-quick-filter').forEach(btn => { + btn.classList.remove('active'); + }); + refreshLogs(); +} + +function getDateString(date) { + return date.toISOString().split('T')[0]; +} + +function filterByToday() { + const today = new Date(); + document.getElementById('startDateFilter').value = getDateString(today); + document.getElementById('endDateFilter').value = getDateString(today); + updateQuickFilterButtons('today'); + refreshLogs(); +} + +function filterByLastWeek() { + const today = new Date(); + const lastWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); + document.getElementById('startDateFilter').value = getDateString(lastWeek); + document.getElementById('endDateFilter').value = getDateString(today); + updateQuickFilterButtons('week'); + refreshLogs(); +} + +function filterByLastMonth() { + const today = new Date(); + const lastMonth = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); + document.getElementById('startDateFilter').value = getDateString(lastMonth); + document.getElementById('endDateFilter').value = getDateString(today); + updateQuickFilterButtons('month'); + refreshLogs(); +} + +function filterByLastYear() { + const today = new Date(); + const lastYear = new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000); + document.getElementById('startDateFilter').value = getDateString(lastYear); + document.getElementById('endDateFilter').value = getDateString(today); + updateQuickFilterButtons('year'); + refreshLogs(); +} + +function updateQuickFilterButtons(activeFilter) { + const buttons = document.querySelectorAll('.btn-quick-filter'); + buttons.forEach(btn => btn.classList.remove('active')); + + if (activeFilter === 'today') buttons[0].classList.add('active'); + else if (activeFilter === 'week') buttons[1].classList.add('active'); + else if (activeFilter === 'month') buttons[2].classList.add('active'); + else if (activeFilter === 'year') buttons[3].classList.add('active'); +} + +async function exportLogs() { + try { + // Get current filter values + const filter = document.getElementById('filterSelect').value; + const participant = document.getElementById('participantFilter').value; + const startDate = document.getElementById('startDateFilter').value; + const endDate = document.getElementById('endDateFilter').value; + + // Build query string with filters + const params = new URLSearchParams({ + filter: filter, + participant: participant, + startDate: startDate, + endDate: endDate + }); + + const response = await fetch('/api/export?' + params.toString()); + const result = await response.json(); + + if (!result.success) { + alert('Napaka pri izvozу: ' + result.message); + return; + } + + // Decode base64 file data + const binaryString = atob(result.data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Create blob and trigger download with file dialog + const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + + // Try to use pywebview if available (for desktop app) + if (window.pywebview && window.pywebview.api) { + try { + const success = await window.pywebview.api.save_file(result.filename, result.data); + if (success) { + alert('Datoteka je bila uspešno izvožena'); + return; + } else { + console.log('PyWebView save was cancelled, falling back to browser download'); + } + } catch (error) { + console.log('PyWebView save failed, falling back to browser download', error); + } + } + + // Fallback to browser download + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + alert('Datoteka je bila uspešno izvožena'); + } catch (error) { + alert('Napaka: ' + error.message); + } +} + +// ===== PRINT LOGS ===== +function printLogs() { + const start = document.getElementById('startDateFilter').value; + const end = document.getElementById('endDateFilter').value; + const dateRange = document.getElementById('print-date-range'); + + if (start && end) { + dateRange.textContent = `Obdobje: ${start} – ${end}`; + } else if (start) { + dateRange.textContent = `Od: ${start}`; + } else if (end) { + dateRange.textContent = `Do: ${end}`; + } else { + dateRange.textContent = `Natisnjeno: ${new Date().toLocaleDateString('sl-SI')}`; + } + + window.print(); +} + +// ===== DOCUMENTS ===== +async function loadDocuments() { + try { + const response = await fetch('/api/documents'); + const files = await response.json(); + const grid = document.getElementById('documentsGrid'); + + if (files.length === 0) { + grid.innerHTML = `

Ni dokumentov. Dodajte PDF datoteke v mapo static/PDF.

`; + return; + } + + grid.innerHTML = ''; + files.forEach(filename => { + const card = document.createElement('div'); + card.className = 'document-card'; + const displayName = filename.replace(/\.pdf$/i, '').replace(/_/g, ' '); + card.innerHTML = ` +
+ + + + + + +
+
${displayName}
+ + `; + grid.appendChild(card); + }); + } catch (error) { + console.error('Error loading documents:', error); + } +} + +function printDocument(filename) { + const url = `/static/PDF/${encodeURIComponent(filename)}`; + let iframe = document.getElementById('print-iframe'); + if (!iframe) { + iframe = document.createElement('iframe'); + iframe.id = 'print-iframe'; + iframe.style.cssText = 'display:none;position:fixed;top:0;left:0;width:0;height:0;'; + document.body.appendChild(iframe); + } + iframe.onload = () => iframe.contentWindow.print(); + iframe.src = url; +} + +// ===== TAB SWITCHING ===== +function switchTab(tabName, event) { + // Hide all tabs + document.querySelectorAll('.tab-panel').forEach(tab => { + tab.classList.remove('active'); + }); + + // Remove active class from all buttons + document.querySelectorAll('.tab-nav-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + // Show selected tab + document.getElementById(tabName + '-tab').classList.add('active'); + + // Add active class to clicked button + event.target.classList.add('active'); + + // Load tab-specific data + if (tabName === 'documents') loadDocuments(); +} + +// Close modal when clicking outside +window.onclick = function(event) { + const editModal = document.getElementById('editModal'); + const addModal = document.getElementById('addParticipantModal'); + const loginModal = document.getElementById('loginModal'); + const addUnregisteredModal = document.getElementById('addUnregisteredModal'); + + if (event.target === editModal) { + closeModal(); + } + + if (event.target === addModal) { + closeAddParticipantModal(); + } + + if (event.target === loginModal) { + closeLoginModal(); + } + + if (event.target === addUnregisteredModal) { + closeAddUnregisteredModal(); + } +} + +// ===== LAST SCAN RESULT ===== +let lastScannedUID = '-'; +let lastScanTimestamp = null; + +async function updateLastScanResult() { + try { + const response = await fetch('/api/last-scan'); + const result = await response.json(); + + const resultCard = document.getElementById('resultCard'); + const uidDiv = resultCard.querySelector('.result-uid'); + const nameDiv = resultCard.querySelector('.result-name'); + const statusDiv = resultCard.querySelector('.result-status'); + + uidDiv.textContent = result.uid; + nameDiv.textContent = result.name; + statusDiv.textContent = result.status; + lastScannedUID = result.uid; + + // If a new scan just happened, immediately refresh the logs table + if (result.timestamp && result.timestamp !== lastScanTimestamp) { + lastScanTimestamp = result.timestamp; + refreshLogs(); + } + + // Update card styling based on result + resultCard.className = 'result-card'; + if (result.status === 'PASS') { + resultCard.classList.add('pass'); + } else if (result.status === 'FAIL') { + resultCard.classList.add('fail'); + } + } catch (error) { + console.error('Error updating last scan result:', error); + } +} + +function copyUID(evt) { + if (lastScannedUID === '-') { + alert('Ni skeniranega UID-ja za kopiranje'); + return; + } + + const copyBtn = evt.currentTarget; + + // Try using clipboard API first + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(lastScannedUID).then(() => { + showCopySuccess(copyBtn); + }).catch(err => { + console.error('Clipboard API failed:', err); + fallbackCopy(lastScannedUID, copyBtn); + }); + } else { + // Fallback for older browsers + fallbackCopy(lastScannedUID, copyBtn); + } +} + +function fallbackCopy(text, copyBtn) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + showCopySuccess(copyBtn); + } catch (err) { + console.error('Fallback copy failed:', err); + alert('Napaka pri kopiranju UID-ja'); + } + document.body.removeChild(textarea); +} + +function showCopySuccess(copyBtn) { + copyBtn.style.color = 'var(--accent-green)'; + setTimeout(() => { + copyBtn.style.color = ''; + }, 1500); +} + +// ===== UID VISIBILITY MANAGEMENT ===== +function updateUIDVisibility() { + const display = isAdmin ? '' : 'none'; + + // Participants table columns + const cardUIDHeader = document.getElementById('card-uid-header'); + const keyUIDHeader = document.getElementById('key-uid-header'); + if (cardUIDHeader) cardUIDHeader.style.display = display; + if (keyUIDHeader) keyUIDHeader.style.display = display; + + // Logs table columns + const logsCardUIDHeader = document.getElementById('logs-card-uid-header'); + const logsKeyUIDHeader = document.getElementById('logs-key-uid-header'); + const logsActionsHeader = document.getElementById('logs-actions-header'); + if (logsCardUIDHeader) logsCardUIDHeader.style.display = display; + if (logsKeyUIDHeader) logsKeyUIDHeader.style.display = display; + if (logsActionsHeader) logsActionsHeader.style.display = display; + + // Hide/show corresponding cells in all table rows + hideTableColumn('participantsTable', 2, !isAdmin); // Card UID column + hideTableColumn('participantsTable', 3, !isAdmin); // Key UID column + hideTableColumn('logsTable', 2, !isAdmin); // Card UID column + hideTableColumn('logsTable', 3, !isAdmin); // Key UID column + hideTableColumn('logsTable', 7, !isAdmin); // Actions column + + // Last scan result UID + const scanUIDContainer = document.getElementById('scan-result-uid-container'); + if (scanUIDContainer) scanUIDContainer.style.display = display; +} + +function hideTableColumn(tableId, columnIndex, hide) { + const table = document.getElementById(tableId); + if (!table) return; + + const rows = table.getElementsByTagName('tr'); + for (let row of rows) { + const cell = row.cells[columnIndex]; + if (cell) { + cell.style.display = hide ? 'none' : ''; + } + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f45f16a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,251 @@ + + + + + + RFID Detektivni Sistem + + + +
+ +
+
+ +

Zadnji rezultat skeniranja

+
+
+
-
+ +
+
-
+
-
+
+
+ +
+

Statistika

+
+
+
Registrirani
+
0
+
+
+
Neregistrirani
+
0
+
+
+
+ +
+

Dostop Administrator

+ +
+
+ + +
+ +
+ + + +
+ + +
+
+

Člani

+ +
+
+
+ + + + + + + + + + + + + + +
IDImeUID karticeUID ključaDatum veljavnostiDatum dodajanjaDejanja
+
+
+
+ + +
+
+

Zgodovina skeniranja

+
+ + + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + +
IDImeUID karticeUID ključaRezultatDatumČasDejanja
+
+
+
+ + +
+
+

Dokumenti

+
+
+
+

Nalaganje dokumentov...

+
+
+
+
+
+ + + + + + + + + + + + + + + + + +