From 336d998a8a439d53e282129c880cc5e5c352c3ee Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Thu, 3 Jul 2025 14:36:19 +0200 Subject: [PATCH] Add a customizable dashboard feature. --- app.py | 663 +++++++++++- migrate_db.py | 152 ++- models.py | 164 ++- templates/dashboard.html | 2143 +++++++++++++++++++++++++++++++++----- templates/layout.html | 1 + 5 files changed, 2805 insertions(+), 318 deletions(-) diff --git a/app.py b/app.py index 445867c..d48e3cd 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data @@ -879,62 +879,11 @@ def verify_email(token): return redirect(url_for('login')) @app.route('/dashboard') -@role_required(Role.TEAM_LEADER) +@role_required(Role.TEAM_MEMBER) +@company_required def dashboard(): - # Get dashboard data based on user role - dashboard_data = {} - - if g.user.role == Role.ADMIN and g.user.company_id: - # Admin sees everything within their company - - dashboard_data.update({ - 'total_users': User.query.filter_by(company_id=g.user.company_id).count(), - 'total_teams': Team.query.filter_by(company_id=g.user.company_id).count(), - 'blocked_users': User.query.filter_by(company_id=g.user.company_id, is_blocked=True).count(), - 'unverified_users': User.query.filter_by(company_id=g.user.company_id, is_verified=False).count(), - 'recent_registrations': User.query.filter_by(company_id=g.user.company_id).order_by(User.id.desc()).limit(5).all() - }) - - if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]: - - # Team leaders and supervisors see team-related data - if g.user.team_id or g.user.role == Role.ADMIN: - if g.user.role == Role.ADMIN and g.user.company_id: - # Admin can see all teams in their company - teams = Team.query.filter_by(company_id=g.user.company_id).all() - team_members = User.query.filter( - User.team_id.isnot(None), - User.company_id == g.user.company_id - ).all() - else: - # Team leaders/supervisors see their own team - teams = [Team.query.get(g.user.team_id)] if g.user.team_id else [] - - team_members = User.query.filter_by( - team_id=g.user.team_id, - company_id=g.user.company_id - ).all() if g.user.team_id else [] - - dashboard_data.update({ - 'teams': teams, - 'team_members': team_members, - 'team_member_count': len(team_members) - }) - - # Get recent time entries for the user's oversight - if g.user.role == Role.ADMIN: - # Admin sees all recent entries - recent_entries = TimeEntry.query.order_by(TimeEntry.arrival_time.desc()).limit(10).all() - elif g.user.team_id: - # Team leaders see their team's entries - team_user_ids = [user.id for user in User.query.filter_by(team_id=g.user.team_id).all()] - recent_entries = TimeEntry.query.filter(TimeEntry.user_id.in_(team_user_ids)).order_by(TimeEntry.arrival_time.desc()).limit(10).all() - else: - recent_entries = [] - - dashboard_data['recent_entries'] = recent_entries - - return render_template('dashboard.html', title='Dashboard', **dashboard_data) + """User dashboard with configurable widgets.""" + return render_template('dashboard.html', title='Dashboard') # Redirect old admin dashboard URL to new dashboard @@ -4467,6 +4416,606 @@ def delete_kanban_column(column_id): db.session.rollback() return jsonify({'success': False, 'message': str(e)}) +# Dashboard API Endpoints +@app.route('/api/dashboard') +@role_required(Role.TEAM_MEMBER) +@company_required +def get_dashboard(): + """Get user's dashboard configuration and widgets.""" + try: + # Get or create user dashboard + dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first() + if not dashboard: + dashboard = UserDashboard(user_id=g.user.id) + db.session.add(dashboard) + db.session.commit() + logger.info(f"Created new dashboard {dashboard.id} for user {g.user.id}") + else: + logger.info(f"Using existing dashboard {dashboard.id} for user {g.user.id}") + + # Get user's widgets + widgets = DashboardWidget.query.filter_by(dashboard_id=dashboard.id).order_by(DashboardWidget.grid_y, DashboardWidget.grid_x).all() + + logger.info(f"Found {len(widgets)} widgets for dashboard {dashboard.id}") + + # Convert to JSON format + widget_data = [] + for widget in widgets: + # Convert grid size to simple size names + if widget.grid_width == 1 and widget.grid_height == 1: + size = 'small' + elif widget.grid_width == 2 and widget.grid_height == 1: + size = 'medium' + elif widget.grid_width == 2 and widget.grid_height == 2: + size = 'large' + elif widget.grid_width == 3 and widget.grid_height == 1: + size = 'wide' + else: + size = 'small' + + # Parse config JSON + try: + import json + config = json.loads(widget.config) if widget.config else {} + except (json.JSONDecodeError, TypeError): + config = {} + + widget_data.append({ + 'id': widget.id, + 'type': widget.widget_type.value, + 'title': widget.title, + 'size': size, + 'grid_x': widget.grid_x, + 'grid_y': widget.grid_y, + 'grid_width': widget.grid_width, + 'grid_height': widget.grid_height, + 'config': config + }) + + return jsonify({ + 'success': True, + 'dashboard': { + 'id': dashboard.id, + 'layout_config': dashboard.layout_config, + 'grid_columns': dashboard.grid_columns, + 'theme': dashboard.theme + }, + 'widgets': widget_data + }) + + except Exception as e: + logger.error(f"Error loading dashboard: {e}") + return jsonify({'success': False, 'error': str(e)}) + +@app.route('/api/dashboard/widgets', methods=['POST']) +@role_required(Role.TEAM_MEMBER) +@company_required +def create_or_update_widget(): + """Create or update a dashboard widget.""" + try: + data = request.get_json() + + # Get or create user dashboard + dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first() + if not dashboard: + dashboard = UserDashboard(user_id=g.user.id) + db.session.add(dashboard) + db.session.flush() # Get the ID + logger.info(f"Created new dashboard {dashboard.id} for user {g.user.id} in widget creation") + else: + logger.info(f"Using existing dashboard {dashboard.id} for user {g.user.id} in widget creation") + + # Check if updating existing widget + widget_id = data.get('widget_id') + if widget_id: + widget = DashboardWidget.query.filter_by( + id=widget_id, + dashboard_id=dashboard.id + ).first() + if not widget: + return jsonify({'success': False, 'error': 'Widget not found'}) + else: + # Create new widget + widget = DashboardWidget(dashboard_id=dashboard.id) + # Find next available position + max_y = db.session.query(func.max(DashboardWidget.grid_y)).filter_by( + dashboard_id=dashboard.id + ).scalar() or 0 + widget.grid_y = max_y + 1 + widget.grid_x = 0 + + # Update widget properties + widget.widget_type = WidgetType(data['type']) + widget.title = data['title'] + + # Convert size to grid dimensions + size = data.get('size', 'small') + if size == 'small': + widget.grid_width = 1 + widget.grid_height = 1 + elif size == 'medium': + widget.grid_width = 2 + widget.grid_height = 1 + elif size == 'large': + widget.grid_width = 2 + widget.grid_height = 2 + elif size == 'wide': + widget.grid_width = 3 + widget.grid_height = 1 + + # Build config from form data + config = {} + for key, value in data.items(): + if key not in ['type', 'title', 'size', 'widget_id']: + config[key] = value + + # Store config as JSON string + if config: + import json + widget.config = json.dumps(config) + else: + widget.config = None + + if not widget_id: + db.session.add(widget) + logger.info(f"Creating new widget: {widget.widget_type.value} for dashboard {dashboard.id}") + else: + logger.info(f"Updating existing widget {widget_id}") + + db.session.commit() + logger.info(f"Widget saved successfully with ID: {widget.id}") + + # Verify the widget was actually saved + saved_widget = DashboardWidget.query.filter_by(id=widget.id).first() + if saved_widget: + logger.info(f"Verification: Widget {widget.id} exists in database with dashboard_id: {saved_widget.dashboard_id}") + else: + logger.error(f"Verification failed: Widget {widget.id} not found in database") + + # Convert grid size back to simple size name for response + if widget.grid_width == 1 and widget.grid_height == 1: + size_name = 'small' + elif widget.grid_width == 2 and widget.grid_height == 1: + size_name = 'medium' + elif widget.grid_width == 2 and widget.grid_height == 2: + size_name = 'large' + elif widget.grid_width == 3 and widget.grid_height == 1: + size_name = 'wide' + else: + size_name = 'small' + + # Parse config for response + try: + import json + config_dict = json.loads(widget.config) if widget.config else {} + except (json.JSONDecodeError, TypeError): + config_dict = {} + + return jsonify({ + 'success': True, + 'message': 'Widget saved successfully', + 'widget': { + 'id': widget.id, + 'type': widget.widget_type.value, + 'title': widget.title, + 'size': size_name, + 'grid_x': widget.grid_x, + 'grid_y': widget.grid_y, + 'grid_width': widget.grid_width, + 'grid_height': widget.grid_height, + 'config': config_dict + } + }) + + except Exception as e: + db.session.rollback() + logger.error(f"Error saving widget: {e}") + return jsonify({'success': False, 'error': str(e)}) + +@app.route('/api/dashboard/widgets/', methods=['DELETE']) +@role_required(Role.TEAM_MEMBER) +@company_required +def delete_widget(widget_id): + """Delete a dashboard widget.""" + try: + # Get user dashboard + dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first() + if not dashboard: + return jsonify({'success': False, 'error': 'Dashboard not found'}) + + # Find and delete widget + widget = DashboardWidget.query.filter_by( + id=widget_id, + dashboard_id=dashboard.id + ).first() + + if not widget: + return jsonify({'success': False, 'error': 'Widget not found'}) + + # No need to update positions for grid-based layout + + db.session.delete(widget) + db.session.commit() + + return jsonify({'success': True, 'message': 'Widget deleted successfully'}) + + except Exception as e: + db.session.rollback() + logger.error(f"Error deleting widget: {e}") + return jsonify({'success': False, 'error': str(e)}) + +@app.route('/api/dashboard/positions', methods=['POST']) +@role_required(Role.TEAM_MEMBER) +@company_required +def update_widget_positions(): + """Update widget grid positions after drag and drop.""" + try: + data = request.get_json() + positions = data.get('positions', []) + + # Get user dashboard + dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first() + if not dashboard: + return jsonify({'success': False, 'error': 'Dashboard not found'}) + + # Update grid positions + for pos_data in positions: + widget = DashboardWidget.query.filter_by( + id=pos_data['id'], + dashboard_id=dashboard.id + ).first() + if widget: + # For now, just assign sequential grid positions + # In a more advanced implementation, we'd calculate actual grid coordinates + widget.grid_x = pos_data.get('grid_x', 0) + widget.grid_y = pos_data.get('grid_y', pos_data.get('position', 0)) + + db.session.commit() + + return jsonify({'success': True, 'message': 'Positions updated successfully'}) + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating positions: {e}") + return jsonify({'success': False, 'error': str(e)}) + +# Widget data endpoints +@app.route('/api/dashboard/widgets//data') +@role_required(Role.TEAM_MEMBER) +@company_required +def get_widget_data(widget_id): + """Get data for a specific widget.""" + try: + # Get user dashboard + dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first() + if not dashboard: + return jsonify({'success': False, 'error': 'Dashboard not found'}) + + # Find widget + widget = DashboardWidget.query.filter_by( + id=widget_id, + dashboard_id=dashboard.id + ).first() + + if not widget: + return jsonify({'success': False, 'error': 'Widget not found'}) + + # Get widget-specific data based on type + widget_data = {} + + if widget.widget_type == WidgetType.DAILY_SUMMARY: + from datetime import datetime, timedelta + + config = widget.config or {} + period = config.get('summary_period', 'daily') + + # Calculate time summaries + now = datetime.now() + + # Today's summary + start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= start_of_today, + TimeEntry.departure_time.isnot(None) + ).all() + today_seconds = sum(entry.duration or 0 for entry in today_entries) + + # This week's summary + start_of_week = start_of_today - timedelta(days=start_of_today.weekday()) + week_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= start_of_week, + TimeEntry.departure_time.isnot(None) + ).all() + week_seconds = sum(entry.duration or 0 for entry in week_entries) + + # This month's summary + start_of_month = start_of_today.replace(day=1) + month_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= start_of_month, + TimeEntry.departure_time.isnot(None) + ).all() + month_seconds = sum(entry.duration or 0 for entry in month_entries) + + widget_data.update({ + 'today': f"{today_seconds // 3600}h {(today_seconds % 3600) // 60}m", + 'week': f"{week_seconds // 3600}h {(week_seconds % 3600) // 60}m", + 'month': f"{month_seconds // 3600}h {(month_seconds % 3600) // 60}m", + 'entries_today': len(today_entries), + 'entries_week': len(week_entries), + 'entries_month': len(month_entries) + }) + + elif widget.widget_type == WidgetType.ACTIVE_PROJECTS: + config = widget.config or {} + project_filter = config.get('project_filter', 'all') + max_projects = int(config.get('max_projects', 5)) + + # Get user's projects + if g.user.role in [Role.ADMIN, Role.SUPERVISOR]: + projects = Project.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).limit(max_projects).all() + elif g.user.team_id: + projects = Project.query.filter( + Project.company_id == g.user.company_id, + Project.is_active == True, + db.or_(Project.team_id == g.user.team_id, Project.team_id == None) + ).limit(max_projects).all() + else: + projects = [] + + widget_data['projects'] = [{ + 'id': p.id, + 'name': p.name, + 'code': p.code, + 'description': p.description + } for p in projects] + + elif widget.widget_type == WidgetType.ASSIGNED_TASKS: + config = widget.config or {} + task_filter = config.get('task_filter', 'assigned') + task_status = config.get('task_status', 'active') + + # Get user's tasks based on filter + if task_filter == 'assigned': + tasks = Task.query.filter_by(assigned_to_id=g.user.id) + elif task_filter == 'created': + tasks = Task.query.filter_by(created_by_id=g.user.id) + else: + # Get tasks from user's projects + if g.user.team_id: + project_ids = [p.id for p in Project.query.filter( + Project.company_id == g.user.company_id, + db.or_(Project.team_id == g.user.team_id, Project.team_id == None) + ).all()] + tasks = Task.query.filter(Task.project_id.in_(project_ids)) + else: + tasks = Task.query.join(Project).filter(Project.company_id == g.user.company_id) + + # Filter by status if specified + if task_status != 'all': + if task_status == 'active': + tasks = tasks.filter(Task.status.in_([TaskStatus.PENDING, TaskStatus.IN_PROGRESS])) + elif task_status == 'pending': + tasks = tasks.filter_by(status=TaskStatus.PENDING) + elif task_status == 'completed': + tasks = tasks.filter_by(status=TaskStatus.COMPLETED) + + tasks = tasks.limit(10).all() + + widget_data['tasks'] = [{ + 'id': t.id, + 'name': t.name, + 'description': t.description, + 'status': t.status.value if t.status else 'Pending', + 'priority': t.priority.value if t.priority else 'Medium', + 'project_name': t.project.name if t.project else 'No Project' + } for t in tasks] + + elif widget.widget_type == WidgetType.WEEKLY_CHART: + from datetime import datetime, timedelta + + # Get weekly data for chart + now = datetime.now() + start_of_week = now - timedelta(days=now.weekday()) + + weekly_data = [] + for i in range(7): + day = start_of_week + timedelta(days=i) + day_start = day.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + day_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= day_start, + TimeEntry.arrival_time < day_end, + TimeEntry.departure_time.isnot(None) + ).all() + + total_seconds = sum(entry.duration or 0 for entry in day_entries) + weekly_data.append({ + 'day': day.strftime('%A'), + 'date': day.strftime('%Y-%m-%d'), + 'hours': round(total_seconds / 3600, 2), + 'entries': len(day_entries) + }) + + widget_data['weekly_data'] = weekly_data + + elif widget.widget_type == WidgetType.TASK_PRIORITY: + # Get tasks by priority + if g.user.team_id: + project_ids = [p.id for p in Project.query.filter( + Project.company_id == g.user.company_id, + db.or_(Project.team_id == g.user.team_id, Project.team_id == None) + ).all()] + tasks = Task.query.filter( + Task.project_id.in_(project_ids), + Task.assigned_to_id == g.user.id + ).order_by(Task.priority.desc(), Task.created_at.desc()).limit(10).all() + else: + tasks = Task.query.filter_by(assigned_to_id=g.user.id).order_by( + Task.priority.desc(), Task.created_at.desc() + ).limit(10).all() + + widget_data['priority_tasks'] = [{ + 'id': t.id, + 'name': t.name, + 'description': t.description, + 'priority': t.priority.value if t.priority else 'Medium', + 'status': t.status.value if t.status else 'Pending', + 'project_name': t.project.name if t.project else 'No Project' + } for t in tasks] + + elif widget.widget_type == WidgetType.KANBAN_SUMMARY: + # Get kanban data summary + if g.user.team_id: + project_ids = [p.id for p in Project.query.filter( + Project.company_id == g.user.company_id, + db.or_(Project.team_id == g.user.team_id, Project.team_id == None) + ).all()] + boards = KanbanBoard.query.filter( + KanbanBoard.project_id.in_(project_ids), + KanbanBoard.is_active == True + ).limit(3).all() + else: + boards = [] + + board_summaries = [] + for board in boards: + columns = KanbanColumn.query.filter_by(board_id=board.id).order_by(KanbanColumn.position).all() + total_cards = sum(len([c for c in col.cards if c.is_active]) for col in columns) + + board_summaries.append({ + 'id': board.id, + 'name': board.name, + 'project_name': board.project.name, + 'total_cards': total_cards, + 'columns': len(columns) + }) + + widget_data['kanban_boards'] = board_summaries + + elif widget.widget_type == WidgetType.PROJECT_PROGRESS: + # Get project progress data + if g.user.role in [Role.ADMIN, Role.SUPERVISOR]: + projects = Project.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).limit(5).all() + elif g.user.team_id: + projects = Project.query.filter( + Project.company_id == g.user.company_id, + Project.is_active == True, + db.or_(Project.team_id == g.user.team_id, Project.team_id == None) + ).limit(5).all() + else: + projects = [] + + project_progress = [] + for project in projects: + total_tasks = Task.query.filter_by(project_id=project.id).count() + completed_tasks = Task.query.filter_by( + project_id=project.id, + status=TaskStatus.COMPLETED + ).count() + + progress = (completed_tasks / total_tasks * 100) if total_tasks > 0 else 0 + + project_progress.append({ + 'id': project.id, + 'name': project.name, + 'code': project.code, + 'progress': round(progress, 1), + 'completed_tasks': completed_tasks, + 'total_tasks': total_tasks + }) + + widget_data['project_progress'] = project_progress + + elif widget.widget_type == WidgetType.PRODUCTIVITY_METRICS: + from datetime import datetime, timedelta + + # Calculate productivity metrics + now = datetime.now() + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = today - timedelta(days=7) + + # This week vs last week comparison + this_week_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= week_ago, + TimeEntry.departure_time.isnot(None) + ).all() + + last_week_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= week_ago - timedelta(days=7), + TimeEntry.arrival_time < week_ago, + TimeEntry.departure_time.isnot(None) + ).all() + + this_week_hours = sum(entry.duration or 0 for entry in this_week_entries) / 3600 + last_week_hours = sum(entry.duration or 0 for entry in last_week_entries) / 3600 + + productivity_change = ((this_week_hours - last_week_hours) / last_week_hours * 100) if last_week_hours > 0 else 0 + + widget_data.update({ + 'this_week_hours': round(this_week_hours, 1), + 'last_week_hours': round(last_week_hours, 1), + 'productivity_change': round(productivity_change, 1), + 'avg_daily_hours': round(this_week_hours / 7, 1), + 'total_entries': len(this_week_entries) + }) + + return jsonify({ + 'success': True, + 'data': widget_data + }) + + except Exception as e: + logger.error(f"Error getting widget data: {e}") + return jsonify({'success': False, 'error': str(e)}) + +@app.route('/api/current-timer-status') +@role_required(Role.TEAM_MEMBER) +@company_required +def get_current_timer_status(): + """Get current timer status for dashboard widgets.""" + try: + # Get the user's current active time entry + active_entry = TimeEntry.query.filter_by( + user_id=g.user.id, + departure_time=None + ).first() + + if active_entry: + # Calculate current duration + now = datetime.now() + elapsed_seconds = int((now - active_entry.arrival_time).total_seconds()) + + return jsonify({ + 'success': True, + 'isActive': True, + 'startTime': active_entry.arrival_time.isoformat(), + 'currentDuration': elapsed_seconds, + 'entryId': active_entry.id + }) + else: + return jsonify({ + 'success': True, + 'isActive': False, + 'message': 'No active timer' + }) + + except Exception as e: + logger.error(f"Error getting timer status: {e}") + return jsonify({'success': False, 'error': str(e)}) + if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) - app.run(debug=False, host='0.0.0.0', port=port) \ No newline at end of file + app.run(debug=True, host='0.0.0.0', port=port) \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py index 07e8f16..8e31419 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -16,7 +16,8 @@ try: from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, - KanbanColumn, KanbanCard) + KanbanColumn, KanbanCard, WidgetType, UserDashboard, DashboardWidget, + WidgetTemplate) from werkzeug.security import generate_password_hash FLASK_AVAILABLE = True except ImportError: @@ -74,6 +75,7 @@ def run_all_migrations(db_path=None): migrate_task_system(db_path) migrate_system_events(db_path) migrate_kanban_system(db_path) + migrate_dashboard_system(db_path) if FLASK_AVAILABLE: with app.app_context(): @@ -985,6 +987,149 @@ def migrate_kanban_system(db_file=None): conn.close() +def migrate_dashboard_system(db_file=None): + """Migrate to add Dashboard widget system.""" + db_path = get_db_path(db_file) + + print(f"Migrating Dashboard system in {db_path}...") + + if not os.path.exists(db_path): + print(f"Database file {db_path} does not exist. Run basic migration first.") + return False + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check if user_dashboard table already exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_dashboard'") + if cursor.fetchone(): + print("Dashboard tables already exist. Skipping migration.") + return True + + print("Creating Dashboard system tables...") + + # Create user_dashboard table + cursor.execute(""" + CREATE TABLE user_dashboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name VARCHAR(100) DEFAULT 'My Dashboard', + is_default BOOLEAN DEFAULT 1, + layout_config TEXT, + grid_columns INTEGER DEFAULT 6, + theme VARCHAR(20) DEFAULT 'light', + auto_refresh INTEGER DEFAULT 300, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user (id) + ) + """) + + # Create dashboard_widget table + cursor.execute(""" + CREATE TABLE dashboard_widget ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dashboard_id INTEGER NOT NULL, + widget_type VARCHAR(50) NOT NULL, + grid_x INTEGER NOT NULL DEFAULT 0, + grid_y INTEGER NOT NULL DEFAULT 0, + grid_width INTEGER NOT NULL DEFAULT 1, + grid_height INTEGER NOT NULL DEFAULT 1, + title VARCHAR(100), + config TEXT, + refresh_interval INTEGER DEFAULT 60, + is_visible BOOLEAN DEFAULT 1, + is_minimized BOOLEAN DEFAULT 0, + z_index INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (dashboard_id) REFERENCES user_dashboard (id) + ) + """) + + # Create widget_template table + cursor.execute(""" + CREATE TABLE widget_template ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + widget_type VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(50), + default_width INTEGER DEFAULT 1, + default_height INTEGER DEFAULT 1, + default_config TEXT, + required_role VARCHAR(50) DEFAULT 'Team Member', + is_active BOOLEAN DEFAULT 1, + category VARCHAR(50) DEFAULT 'General', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create indexes for better performance + cursor.execute("CREATE INDEX idx_user_dashboard_user ON user_dashboard(user_id)") + cursor.execute("CREATE INDEX idx_user_dashboard_default ON user_dashboard(user_id, is_default)") + cursor.execute("CREATE INDEX idx_dashboard_widget_dashboard ON dashboard_widget(dashboard_id)") + cursor.execute("CREATE INDEX idx_dashboard_widget_type ON dashboard_widget(widget_type)") + cursor.execute("CREATE INDEX idx_widget_template_type ON widget_template(widget_type)") + cursor.execute("CREATE INDEX idx_widget_template_category ON widget_template(category)") + + # Insert default widget templates + default_templates = [ + # Time Tracking Widgets + ('current_timer', 'Current Timer', 'Shows active time tracking session', '⏲️', 2, 1, '{}', 'Team Member', 'Time'), + ('daily_summary', 'Daily Summary', 'Today\'s time tracking summary', '📊', 2, 1, '{}', 'Team Member', 'Time'), + ('weekly_chart', 'Weekly Chart', 'Weekly time distribution chart', '📈', 3, 2, '{}', 'Team Member', 'Time'), + ('break_reminder', 'Break Reminder', 'Reminds when breaks are due', '☕', 1, 1, '{}', 'Team Member', 'Time'), + + # Project Management Widgets + ('active_projects', 'Active Projects', 'List of current active projects', '📁', 2, 2, '{}', 'Team Member', 'Projects'), + ('project_progress', 'Project Progress', 'Visual progress of projects', '🎯', 2, 1, '{}', 'Team Member', 'Projects'), + ('project_activity', 'Recent Activity', 'Recent project activities', '🔄', 2, 1, '{}', 'Team Member', 'Projects'), + ('project_deadlines', 'Upcoming Deadlines', 'Projects with approaching deadlines', '⚠️', 2, 1, '{}', 'Team Member', 'Projects'), + + # Task Management Widgets + ('assigned_tasks', 'My Tasks', 'Tasks assigned to me', '✅', 2, 2, '{}', 'Team Member', 'Tasks'), + ('task_priority', 'Priority Matrix', 'Tasks organized by priority', '🔥', 2, 2, '{}', 'Team Member', 'Tasks'), + ('kanban_summary', 'Kanban Overview', 'Summary of Kanban boards', '📋', 3, 1, '{}', 'Team Member', 'Tasks'), + ('task_trends', 'Task Trends', 'Task completion trends', '📉', 2, 1, '{}', 'Team Member', 'Tasks'), + + # Analytics Widgets + ('productivity_metrics', 'Productivity', 'Personal productivity metrics', '⚡', 1, 1, '{}', 'Team Member', 'Analytics'), + ('time_distribution', 'Time Distribution', 'How time is distributed', '🥧', 2, 2, '{}', 'Team Member', 'Analytics'), + ('goal_progress', 'Goals', 'Progress towards goals', '🎯', 1, 1, '{}', 'Team Member', 'Analytics'), + ('performance_comparison', 'Performance', 'Performance comparison over time', '📊', 2, 1, '{}', 'Team Member', 'Analytics'), + + # Team Widgets (Role-based) + ('team_overview', 'Team Overview', 'Overview of team performance', '👥', 3, 2, '{}', 'Team Leader', 'Team'), + ('resource_allocation', 'Resources', 'Team resource allocation', '📊', 2, 2, '{}', 'Administrator', 'Team'), + ('team_performance', 'Team Performance', 'Team performance metrics', '📈', 3, 1, '{}', 'Supervisor', 'Team'), + ('company_metrics', 'Company Metrics', 'Company-wide metrics', '🏢', 3, 2, '{}', 'System Administrator', 'Team'), + + # Quick Action Widgets + ('quick_timer', 'Quick Timer', 'Quick time tracking controls', '▶️', 1, 1, '{}', 'Team Member', 'Actions'), + ('favorite_projects', 'Favorites', 'Quick access to favorite projects', '⭐', 1, 2, '{}', 'Team Member', 'Actions'), + ('recent_actions', 'Recent Actions', 'Recently performed actions', '🕒', 2, 1, '{}', 'Team Member', 'Actions'), + ('shortcuts_panel', 'Shortcuts', 'Quick action shortcuts', '🚀', 1, 1, '{}', 'Team Member', 'Actions'), + ] + + cursor.executemany(""" + INSERT INTO widget_template + (widget_type, name, description, icon, default_width, default_height, default_config, required_role, category) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, default_templates) + + conn.commit() + print("Dashboard system migration completed successfully!") + return True + + except Exception as e: + print(f"Error during Dashboard system migration: {e}") + conn.rollback() + raise + finally: + conn.close() + def main(): """Main function with command line interface.""" @@ -1004,6 +1149,8 @@ def main(): help='Run only system events migration') parser.add_argument('--kanban', '-k', action='store_true', help='Run only Kanban system migration') + parser.add_argument('--dashboard', '--dash', action='store_true', + help='Run only dashboard system migration') args = parser.parse_args() @@ -1039,6 +1186,9 @@ def main(): elif args.kanban: migrate_kanban_system(db_path) + elif args.dashboard: + migrate_dashboard_system(db_path) + else: # Default: run all migrations run_all_migrations(db_path) diff --git a/models.py b/models.py index 086a8ab..be22a12 100644 --- a/models.py +++ b/models.py @@ -830,4 +830,166 @@ class KanbanCard(db.Model): def can_user_access(self, user): """Check if a user can access this card""" # Check board's project permissions - return self.column.board.project.is_user_allowed(user) \ No newline at end of file + return self.column.board.project.is_user_allowed(user) + +# Dashboard Widget System +class WidgetType(enum.Enum): + # Time Tracking Widgets + CURRENT_TIMER = "current_timer" + DAILY_SUMMARY = "daily_summary" + WEEKLY_CHART = "weekly_chart" + BREAK_REMINDER = "break_reminder" + + # Project Management Widgets + ACTIVE_PROJECTS = "active_projects" + PROJECT_PROGRESS = "project_progress" + PROJECT_ACTIVITY = "project_activity" + PROJECT_DEADLINES = "project_deadlines" + + # Task Management Widgets + ASSIGNED_TASKS = "assigned_tasks" + TASK_PRIORITY = "task_priority" + KANBAN_SUMMARY = "kanban_summary" + TASK_TRENDS = "task_trends" + + # Analytics Widgets + PRODUCTIVITY_METRICS = "productivity_metrics" + TIME_DISTRIBUTION = "time_distribution" + GOAL_PROGRESS = "goal_progress" + PERFORMANCE_COMPARISON = "performance_comparison" + + # Team Widgets (Role-based) + TEAM_OVERVIEW = "team_overview" + RESOURCE_ALLOCATION = "resource_allocation" + TEAM_PERFORMANCE = "team_performance" + COMPANY_METRICS = "company_metrics" + + # Quick Action Widgets + QUICK_TIMER = "quick_timer" + FAVORITE_PROJECTS = "favorite_projects" + RECENT_ACTIONS = "recent_actions" + SHORTCUTS_PANEL = "shortcuts_panel" + +class WidgetSize(enum.Enum): + SMALL = "1x1" # 1 grid unit + MEDIUM = "2x1" # 2 grid units wide, 1 high + LARGE = "2x2" # 2x2 grid units + WIDE = "3x1" # 3 grid units wide, 1 high + TALL = "1x2" # 1 grid unit wide, 2 high + EXTRA_LARGE = "3x2" # 3x2 grid units + +class UserDashboard(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(100), default='My Dashboard') + is_default = db.Column(db.Boolean, default=True) + layout_config = db.Column(db.Text) # JSON string for grid layout configuration + + # Dashboard settings + grid_columns = db.Column(db.Integer, default=6) # Number of grid columns + theme = db.Column(db.String(20), default='light') # light, dark, auto + auto_refresh = db.Column(db.Integer, default=300) # Auto-refresh interval in seconds + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + user = db.relationship('User', backref='dashboards') + widgets = db.relationship('DashboardWidget', backref='dashboard', lazy=True, cascade='all, delete-orphan') + + # Unique constraint - one default dashboard per user + __table_args__ = (db.Index('idx_user_default_dashboard', 'user_id', 'is_default'),) + + def __repr__(self): + return f'' + +class DashboardWidget(db.Model): + id = db.Column(db.Integer, primary_key=True) + dashboard_id = db.Column(db.Integer, db.ForeignKey('user_dashboard.id'), nullable=False) + widget_type = db.Column(db.Enum(WidgetType), nullable=False) + + # Grid position and size + grid_x = db.Column(db.Integer, nullable=False, default=0) # X position in grid + grid_y = db.Column(db.Integer, nullable=False, default=0) # Y position in grid + grid_width = db.Column(db.Integer, nullable=False, default=1) # Width in grid units + grid_height = db.Column(db.Integer, nullable=False, default=1) # Height in grid units + + # Widget configuration + title = db.Column(db.String(100)) # Custom widget title + config = db.Column(db.Text) # JSON string for widget-specific configuration + refresh_interval = db.Column(db.Integer, default=60) # Refresh interval in seconds + + # Widget state + is_visible = db.Column(db.Boolean, default=True) + is_minimized = db.Column(db.Boolean, default=False) + z_index = db.Column(db.Integer, default=1) # Stacking order + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f'' + + @property + def config_dict(self): + """Parse widget configuration JSON""" + if self.config: + import json + try: + return json.loads(self.config) + except: + return {} + return {} + + @config_dict.setter + def config_dict(self, value): + """Set widget configuration as JSON""" + import json + self.config = json.dumps(value) if value else None + +class WidgetTemplate(db.Model): + """Pre-defined widget templates for easy dashboard setup""" + id = db.Column(db.Integer, primary_key=True) + widget_type = db.Column(db.Enum(WidgetType), nullable=False) + name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50)) # Icon name or emoji + + # Default configuration + default_width = db.Column(db.Integer, default=1) + default_height = db.Column(db.Integer, default=1) + default_config = db.Column(db.Text) # JSON string for default widget configuration + + # Access control + required_role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER) + is_active = db.Column(db.Boolean, default=True) + + # Categories for organization + category = db.Column(db.String(50), default='General') # Time, Projects, Tasks, Analytics, Team, Actions + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.now) + + def __repr__(self): + return f'' + + def can_user_access(self, user): + """Check if user has required role to use this widget""" + if not self.is_active: + return False + + # Define role hierarchy + role_hierarchy = { + Role.TEAM_MEMBER: 1, + Role.TEAM_LEADER: 2, + Role.SUPERVISOR: 3, + Role.ADMIN: 4, + Role.SYSTEM_ADMIN: 5 + } + + user_level = role_hierarchy.get(user.role, 0) + required_level = role_hierarchy.get(self.required_role, 0) + + return user_level >= required_level \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index 4ad3f4f..3c281d1 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,309 +1,1934 @@ {% extends "layout.html" %} {% block content %} -
-

- {% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %} - Admin Dashboard - {% elif g.user.role == Role.SUPERVISOR %} - Supervisor Dashboard - {% elif g.user.role == Role.TEAM_LEADER %} - Team Leader Dashboard - {% else %} - Dashboard - {% endif %} -

- - -
-

Quick Actions

-
-
-

My Profile

-

Update your personal information and password.

- Edit Profile -
- -
-

Configuration

-

Configure work hours and break settings.

- Work Config -
- -
-

Analytics

-

View the complete time tracking history.

- View Analytics -
+
+
+

My Dashboard

+
+ +
- - {% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %} -
-

System Overview

-
-
-

{{ total_users }}

-

Total Users

-
-
-

{{ total_teams }}

-

Total Teams

-
-
-

{{ blocked_users }}

-

Blocked Users

-
-
-

{{ unverified_users }}

-

Unverified Users

-
-
+
+
-
-
-

User Management

-

Manage user accounts, permissions, and roles.

- Manage Users -
- -
-

Project Management

-

Manage projects, assign teams, and track project status.

- Manage Projects -
- -
-

Team Management

-

Configure teams and their members.

- Manage Teams -
- -
-

System Settings

-

Configure application-wide settings like registration and more.

- System Settings + + - {% endif %} +
- - {% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN, Role.SYSTEM_ADMIN] %} -
-

Team Management

- - {% if teams %} -
-
-

{{ team_member_count }}

-

Team Members

-
-
-

{{ teams|length }}

-

Teams Managed

-
-
- -
- {% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %} -
-

Team Configuration

-

Create and manage team structures.

- Configure Teams -
- {% endif %} -
- -
-

Your Team Members

- {% if team_members %} -
- {% for member in team_members %} -
-

{{ member.username }}

-

{{ member.role.value if member.role else 'Team Member' }}

-

{{ member.email }}

- {% if member.is_blocked %} - Blocked - {% elif not member.is_verified %} - Unverified - {% else %} - Active - {% endif %} + + - - {% if recent_entries %} -
-

Recent Time Entries

-
- - - - {% if g.user.role in [Role.ADMIN, Role.TEAM_LEADER, Role.SUPERVISOR] %} - - {% endif %} - - - - - - - - - {% for entry in recent_entries %} - - {% if g.user.role in [Role.ADMIN, Role.TEAM_LEADER, Role.SUPERVISOR] %} - - {% endif %} - - - - - - - {% endfor %} - -
UserDateArrivalDepartureDurationStatus
{{ entry.user.username }}{{ entry.arrival_time.strftime('%Y-%m-%d') }}{{ entry.arrival_time.strftime('%H:%M:%S') }}{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }} - {% if entry.duration %} - {{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) }} - {% else %} - In progress - {% endif %} - - {% if not entry.departure_time %} - Active - {% else %} - Completed - {% endif %} -
-
+ + + + + + + + + + {% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 124130b..e409605 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -41,6 +41,7 @@