diff --git a/app.py b/app.py index a76550f..513cc57 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,13 @@ 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 +from data_formatting import ( + format_duration, prepare_export_data, prepare_team_hours_export_data, + format_table_data, format_graph_data, format_team_data +) +from data_export import ( + export_to_csv, export_to_excel, export_team_hours_to_csv, export_team_hours_to_excel, + export_analytics_csv, export_analytics_excel +) import logging from datetime import datetime, time, timedelta import os @@ -761,6 +769,7 @@ def register(): new_user.is_verified = True # Generate verification token (even if not needed, for consistency) + token = new_user.generate_verification_token() db.session.add(new_user) @@ -948,6 +957,7 @@ def dashboard(): 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(), @@ -957,6 +967,7 @@ def dashboard(): }) 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: @@ -969,6 +980,7 @@ def dashboard(): 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 @@ -1109,7 +1121,6 @@ def edit_user(user_id): error = 'Username already exists in your company' elif email != user.email and User.query.filter_by(email=email, company_id=g.user.company_id).first(): error = 'Email already registered in your company' - if error is None: user.username = username user.email = email @@ -1445,7 +1456,6 @@ def config(): return render_template('config.html', title='Configuration', config=config) - @app.route('/api/delete/', methods=['DELETE']) @login_required def delete_entry(entry_id): @@ -1494,86 +1504,6 @@ def update_entry(entry_id): } }) -@app.route('/team/hours') -@login_required -@role_required(Role.TEAM_LEADER) # Only team leaders and above can access -@company_required -def team_hours(): - # Get the current user's team - team = Team.query.get(g.user.team_id) - - if not team: - flash('You are not assigned to any team.', 'error') - return redirect(url_for('home')) - - # Get date range from query parameters or use current week as default - today = datetime.now().date() - start_of_week = today - timedelta(days=today.weekday()) - end_of_week = start_of_week + timedelta(days=6) - - start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) - end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) - - try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() - except ValueError: - flash('Invalid date format. Using current week instead.', 'warning') - start_date = start_of_week - end_date = end_of_week - - # Generate a list of dates in the range for the table header - date_range = [] - current_date = start_date - while current_date <= end_date: - date_range.append(current_date) - current_date += timedelta(days=1) - - return render_template( - 'team_hours.html', - title=f'Team Hours', - start_date=start_date, - end_date=end_date, - date_range=date_range - ) - -@app.route('/history') -@login_required -def history(): - # Get project filter from query parameters - project_filter = request.args.get('project_id') - - # Base query for user's time entries - query = TimeEntry.query.filter_by(user_id=g.user.id) - - # Apply project filter if specified - if project_filter: - if project_filter == 'none': - # Show entries with no project assigned - query = query.filter(TimeEntry.project_id.is_(None)) - else: - # Show entries for specific project - try: - project_id = int(project_filter) - query = query.filter_by(project_id=project_id) - except ValueError: - # Invalid project ID, ignore filter - pass - - # Get filtered entries ordered by most recent first - all_entries = query.order_by(TimeEntry.arrival_time.desc()).all() - - # Get available projects for the filter dropdown (company-scoped) - available_projects = [] - if g.user.company_id: - all_projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all() - for project in all_projects: - if project.is_user_allowed(g.user): - available_projects.append(project) - - return render_template('history.html', title='Time Entry History', - entries=all_entries, available_projects=available_projects) - def calculate_work_duration(arrival_time, departure_time, total_break_duration): """ Calculate work duration considering both configured and actual break times. @@ -1943,6 +1873,7 @@ def manage_team(team_id): team_members = User.query.filter_by(team_id=team.id).all() # Get users not in this team for the add member form (company-scoped) + available_users = User.query.filter( User.company_id == g.user.company_id, (User.team_id != team.id) | (User.team_id == None) @@ -2083,6 +2014,7 @@ def edit_project(project_id): # Get available teams for the form (company-scoped) teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all() + return render_template('edit_project.html', title='Edit Project', project=project, teams=teams) @app.route('/admin/projects/delete/', methods=['POST']) @@ -2248,153 +2180,6 @@ def get_date_range(period, start_date_str=None, end_date_str=None): except (ValueError, TypeError): raise ValueError('Invalid date format') -def format_duration(seconds): - """Format duration in seconds to HH:MM:SS format.""" - if seconds is None: - return '00:00:00' - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - seconds = seconds % 60 - return f"{hours:d}:{minutes:02d}:{seconds:02d}" - -def prepare_export_data(entries): - """Prepare time entries data for export.""" - data = [] - for entry in entries: - row = { - 'Date': entry.arrival_time.strftime('%Y-%m-%d'), - 'Project Code': entry.project.code if entry.project else '', - 'Project Name': entry.project.name if entry.project else '', - 'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'), - 'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active', - 'Work Duration (HH:MM:SS)': format_duration(entry.duration) if entry.duration is not None else 'In progress', - 'Break Duration (HH:MM:SS)': format_duration(entry.total_break_duration), - 'Work Duration (seconds)': entry.duration if entry.duration is not None else 0, - 'Break Duration (seconds)': entry.total_break_duration if entry.total_break_duration is not None else 0, - 'Notes': entry.notes if entry.notes else '' - } - data.append(row) - return data - -def export_to_csv(data, filename): - """Export data to CSV format.""" - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=data[0].keys()) - writer.writeheader() - writer.writerows(data) - - return Response( - output.getvalue(), - mimetype='text/csv', - headers={'Content-Disposition': f'attachment;filename={filename}.csv'} - ) - -def export_to_excel(data, filename): - """Export data to Excel format with formatting.""" - df = pd.DataFrame(data) - output = io.BytesIO() - - with pd.ExcelWriter(output, engine='xlsxwriter') as writer: - df.to_excel(writer, sheet_name='TimeTrack Data', index=False) - - # Auto-adjust columns' width - worksheet = writer.sheets['TimeTrack Data'] - for i, col in enumerate(df.columns): - column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 - worksheet.set_column(i, i, column_width) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f"{filename}.xlsx" - ) - -def prepare_team_hours_export_data(team, team_data, date_range): - """Prepare team hours data for export.""" - export_data = [] - - for member_data in team_data: - user = member_data['user'] - daily_hours = member_data['daily_hours'] - - # Create base row with member info - row = { - 'Team': team['name'], - 'Member': user['username'], - 'Email': user['email'], - 'Total Hours': member_data['total_hours'] - } - - # Add daily hours columns - for date_str in date_range: - formatted_date = datetime.strptime(date_str, '%Y-%m-%d').strftime('%m/%d/%Y') - row[formatted_date] = daily_hours.get(date_str, 0.0) - - export_data.append(row) - - return export_data - -def export_team_hours_to_csv(data, filename): - """Export team hours data to CSV format.""" - if not data: - return None - - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=data[0].keys()) - writer.writeheader() - writer.writerows(data) - - return Response( - output.getvalue(), - mimetype='text/csv', - headers={'Content-Disposition': f'attachment;filename={filename}.csv'} - ) - -def export_team_hours_to_excel(data, filename, team_name): - """Export team hours data to Excel format with formatting.""" - if not data: - return None - - df = pd.DataFrame(data) - output = io.BytesIO() - - with pd.ExcelWriter(output, engine='xlsxwriter') as writer: - df.to_excel(writer, sheet_name=f'{team_name} Hours', index=False) - - # Get the workbook and worksheet objects - workbook = writer.book - worksheet = writer.sheets[f'{team_name} Hours'] - - # Create formats - header_format = workbook.add_format({ - 'bold': True, - 'text_wrap': True, - 'valign': 'top', - 'fg_color': '#4CAF50', - 'font_color': 'white', - 'border': 1 - }) - - # Auto-adjust columns' width and apply formatting - for i, col in enumerate(df.columns): - column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 - worksheet.set_column(i, i, column_width) - - # Apply header formatting - worksheet.write(0, i, col, header_format) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f"{filename}.xlsx" - ) - @app.route('/download_export') def download_export(): """Handle export download requests.""" @@ -2437,128 +2222,171 @@ def download_export(): flash('Invalid export format.') return redirect(url_for('export')) -@app.route('/download_team_hours_export') + +@app.route('/analytics') +@app.route('/analytics/') @login_required -@role_required(Role.TEAM_LEADER) -@company_required -def download_team_hours_export(): - """Handle team hours export download requests.""" - export_format = request.args.get('format', 'csv') +def analytics(mode='personal'): + """Unified analytics view combining history, team hours, and graphs""" + # Validate mode parameter + if mode not in ['personal', 'team']: + mode = 'personal' - # Get the current user's team - team = Team.query.get(g.user.team_id) + # Check team access for team mode + if mode == 'team': + if not g.user.team_id: + flash('You must be assigned to a team to view team analytics.', 'warning') + return redirect(url_for('analytics', mode='personal')) - if not team: - flash('You are not assigned to any team.') - return redirect(url_for('team_hours')) + if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]: + flash('You do not have permission to view team analytics.', 'error') + return redirect(url_for('analytics', mode='personal')) - # Get date range from query parameters or use current week as default + # Get available projects for filtering + available_projects = [] + all_projects = Project.query.filter_by(is_active=True).all() + for project in all_projects: + if project.is_user_allowed(g.user): + available_projects.append(project) + + # Get team members if in team mode + team_members = [] + if mode == 'team' and g.user.team_id: + team_members = User.query.filter_by(team_id=g.user.team_id).all() + + # Default date range (current week) today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) end_of_week = start_of_week + timedelta(days=6) - start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) - end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) - include_self = request.args.get('include_self', 'false') == 'true' + return render_template('analytics.html', + title='Time Analytics', + mode=mode, + available_projects=available_projects, + team_members=team_members, + default_start_date=start_of_week.strftime('%Y-%m-%d'), + default_end_date=end_of_week.strftime('%Y-%m-%d')) + +@app.route('/api/analytics/data') +@login_required +def analytics_data(): + """API endpoint for analytics data""" + mode = request.args.get('mode', 'personal') + view_type = request.args.get('view', 'table') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + project_filter = request.args.get('project_id') + granularity = request.args.get('granularity', 'daily') + + # Validate mode + if mode not in ['personal', 'team']: + return jsonify({'error': 'Invalid mode'}), 400 + + # Check permissions for team mode + if mode == 'team': + if not g.user.team_id: + return jsonify({'error': 'No team assigned'}), 403 + if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]: + return jsonify({'error': 'Insufficient permissions'}), 403 try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() - except ValueError: - flash('Invalid date format.') - return redirect(url_for('team_hours')) + # Parse dates + if start_date: + start_date = datetime.strptime(start_date, '%Y-%m-%d').date() + if end_date: + end_date = datetime.strptime(end_date, '%Y-%m-%d').date() - # Get all team members - team_members = User.query.filter_by(team_id=team.id).all() + # Get filtered data + data = get_filtered_analytics_data(g.user, mode, start_date, end_date, project_filter) - # Prepare data structure for team members' hours - team_data = [] - - for member in team_members: - # Skip if the member is the current user (team leader) and include_self is False - if member.id == g.user.id and not include_self: - continue - - # Get time entries for this member in the date range - entries = TimeEntry.query.filter( - TimeEntry.user_id == member.id, - TimeEntry.arrival_time >= datetime.combine(start_date, time.min), - TimeEntry.arrival_time <= datetime.combine(end_date, time.max) - ).order_by(TimeEntry.arrival_time).all() - - # Calculate daily and total hours - daily_hours = {} - total_seconds = 0 - - for entry in entries: - if entry.duration: # Only count completed entries - entry_date = entry.arrival_time.date() - date_str = entry_date.strftime('%Y-%m-%d') - - if date_str not in daily_hours: - daily_hours[date_str] = 0 - - daily_hours[date_str] += entry.duration - total_seconds += entry.duration - - # Convert seconds to hours for display - for date_str in daily_hours: - daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours - - total_hours = round(total_seconds / 3600, 2) # Convert to hours - - # Add member data to team data - team_data.append({ - 'user': { - 'id': member.id, - 'username': member.username, - 'email': member.email - }, - 'daily_hours': daily_hours, - 'total_hours': total_hours - }) - - if not team_data: - flash('No team member data found for the selected date range.') - return redirect(url_for('team_hours')) - - # Generate a list of dates in the range - date_range = [] - current_date = start_date - while current_date <= end_date: - date_range.append(current_date.strftime('%Y-%m-%d')) - current_date += timedelta(days=1) - - # Prepare data for export - team_info = { - 'id': team.id, - 'name': team.name, - 'description': team.description - } - - export_data = prepare_team_hours_export_data(team_info, team_data, date_range) - - # Generate filename - filename = f"{team.name.replace(' ', '_')}_hours_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}" - - # Export based on format - if export_format == 'csv': - response = export_team_hours_to_csv(export_data, filename) - if response: - return response + # Format data based on view type + if view_type == 'graph': + formatted_data = format_graph_data(data, granularity) + elif view_type == 'team': + formatted_data = format_team_data(data, granularity) else: - flash('Error generating CSV export.') - return redirect(url_for('team_hours')) - elif export_format == 'excel': - response = export_team_hours_to_excel(export_data, filename, team.name) - if response: - return response + formatted_data = format_table_data(data) + + return jsonify(formatted_data) + + except Exception as e: + logger.error(f"Error in analytics_data: {str(e)}") + return jsonify({'error': 'Internal server error'}), 500 + +def get_filtered_analytics_data(user, mode, start_date=None, end_date=None, project_filter=None): + """Get filtered time entry data for analytics""" + # Base query + query = TimeEntry.query + + # Apply user/team filter + if mode == 'personal': + query = query.filter(TimeEntry.user_id == user.id) + elif mode == 'team' and user.team_id: + team_user_ids = [u.id for u in User.query.filter_by(team_id=user.team_id).all()] + query = query.filter(TimeEntry.user_id.in_(team_user_ids)) + + # Apply date filters + if start_date: + query = query.filter(func.date(TimeEntry.arrival_time) >= start_date) + if end_date: + query = query.filter(func.date(TimeEntry.arrival_time) <= end_date) + + # Apply project filter + if project_filter: + if project_filter == 'none': + query = query.filter(TimeEntry.project_id.is_(None)) else: - flash('Error generating Excel export.') - return redirect(url_for('team_hours')) - else: - flash('Invalid export format.') - return redirect(url_for('team_hours')) + try: + project_id = int(project_filter) + query = query.filter(TimeEntry.project_id == project_id) + except ValueError: + pass + + return query.order_by(TimeEntry.arrival_time.desc()).all() + + +@app.route('/api/analytics/export') +@login_required +def analytics_export(): + """Export analytics data in various formats""" + export_format = request.args.get('format', 'csv') + view_type = request.args.get('view', 'table') + mode = request.args.get('mode', 'personal') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + project_filter = request.args.get('project_id') + + # Validate permissions + if mode == 'team': + if not g.user.team_id: + flash('No team assigned', 'error') + return redirect(url_for('analytics')) + if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]: + flash('Insufficient permissions', 'error') + return redirect(url_for('analytics')) + + try: + # Parse dates + if start_date: + start_date = datetime.strptime(start_date, '%Y-%m-%d').date() + if end_date: + end_date = datetime.strptime(end_date, '%Y-%m-%d').date() + + # Get data + data = get_filtered_analytics_data(g.user, mode, start_date, end_date, project_filter) + + if export_format == 'csv': + return export_analytics_csv(data, view_type, mode) + elif export_format == 'excel': + return export_analytics_excel(data, view_type, mode) + else: + flash('Invalid export format', 'error') + return redirect(url_for('analytics')) + + except Exception as e: + logger.error(f"Error in analytics export: {str(e)}") + flash('Error generating export', 'error') + return redirect(url_for('analytics')) if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) diff --git a/data_export.py b/data_export.py new file mode 100644 index 0000000..4928e46 --- /dev/null +++ b/data_export.py @@ -0,0 +1,246 @@ +""" +Data export utilities for TimeTrack application. +Handles exporting time entries and analytics data to various file formats (CSV, Excel). +""" + +import io +import csv +import pandas as pd +from flask import Response, send_file +from datetime import datetime +from data_formatting import format_duration + + +def export_to_csv(data, filename): + """Export data to CSV format.""" + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=data[0].keys()) + writer.writeheader() + writer.writerows(data) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': f'attachment;filename={filename}.csv'} + ) + + +def export_to_excel(data, filename): + """Export data to Excel format with formatting.""" + df = pd.DataFrame(data) + output = io.BytesIO() + + with pd.ExcelWriter(output, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name='TimeTrack Data', index=False) + + # Auto-adjust columns' width + worksheet = writer.sheets['TimeTrack Data'] + for i, col in enumerate(df.columns): + column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 + worksheet.set_column(i, i, column_width) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f"{filename}.xlsx" + ) + + +def export_team_hours_to_csv(data, filename): + """Export team hours data to CSV format.""" + if not data: + return None + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=data[0].keys()) + writer.writeheader() + writer.writerows(data) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': f'attachment;filename={filename}.csv'} + ) + + +def export_team_hours_to_excel(data, filename, team_name): + """Export team hours data to Excel format with formatting.""" + if not data: + return None + + df = pd.DataFrame(data) + output = io.BytesIO() + + with pd.ExcelWriter(output, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name=f'{team_name} Hours', index=False) + + # Get the workbook and worksheet objects + workbook = writer.book + worksheet = writer.sheets[f'{team_name} Hours'] + + # Create formats + header_format = workbook.add_format({ + 'bold': True, + 'text_wrap': True, + 'valign': 'top', + 'fg_color': '#4CAF50', + 'font_color': 'white', + 'border': 1 + }) + + # Auto-adjust columns' width and apply formatting + for i, col in enumerate(df.columns): + column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 + worksheet.set_column(i, i, column_width) + + # Apply header formatting + worksheet.write(0, i, col, header_format) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f"{filename}.xlsx" + ) + + +def export_analytics_csv(entries, view_type, mode): + """Export analytics data as CSV.""" + output = io.StringIO() + + if view_type == 'team': + # Team summary CSV + writer = csv.writer(output) + writer.writerow(['Team Member', 'Total Hours', 'Total Entries']) + + # Group by user + user_data = {} + for entry in entries: + if entry.departure_time and entry.duration: + username = entry.user.username + if username not in user_data: + user_data[username] = {'hours': 0, 'entries': 0} + user_data[username]['hours'] += entry.duration / 3600 + user_data[username]['entries'] += 1 + + for username, data in user_data.items(): + writer.writerow([username, f"{data['hours']:.2f}", data['entries']]) + else: + # Detailed entries CSV + writer = csv.writer(output) + headers = ['Date', 'Arrival Time', 'Departure Time', 'Duration', 'Break Duration', 'Project Code', 'Project Name', 'Notes'] + if mode == 'team': + headers.insert(1, 'User') + writer.writerow(headers) + + for entry in entries: + row = [ + 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', + format_duration(entry.duration) if entry.duration else 'In progress', + format_duration(entry.total_break_duration), + entry.project.code if entry.project else '', + entry.project.name if entry.project else 'No Project', + entry.notes or '' + ] + if mode == 'team': + row.insert(1, entry.user.username) + writer.writerow(row) + + output.seek(0) + filename = f"analytics_{view_type}_{mode}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename={filename}'} + ) + + +def export_analytics_excel(entries, view_type, mode): + """Export analytics data as Excel.""" + try: + output = io.BytesIO() + + if view_type == 'team': + # Team summary Excel + user_data = {} + for entry in entries: + if entry.departure_time and entry.duration: + username = entry.user.username + if username not in user_data: + user_data[username] = {'hours': 0, 'entries': 0} + user_data[username]['hours'] += entry.duration / 3600 + user_data[username]['entries'] += 1 + + df = pd.DataFrame([ + { + 'Team Member': username, + 'Total Hours': f"{data['hours']:.2f}", + 'Total Entries': data['entries'] + } + for username, data in user_data.items() + ]) + else: + # Detailed entries Excel + data_list = [] + for entry in entries: + row_data = { + 'Date': entry.arrival_time.strftime('%Y-%m-%d'), + 'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'), + 'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active', + 'Duration': format_duration(entry.duration) if entry.duration else 'In progress', + 'Break Duration': format_duration(entry.total_break_duration), + 'Project Code': entry.project.code if entry.project else '', + 'Project Name': entry.project.name if entry.project else 'No Project', + 'Notes': entry.notes or '' + } + if mode == 'team': + row_data['User'] = entry.user.username + data_list.append(row_data) + + df = pd.DataFrame(data_list) + + # Write to Excel with formatting + with pd.ExcelWriter(output, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name='Analytics Data', index=False) + + # Get workbook and worksheet + workbook = writer.book + worksheet = writer.sheets['Analytics Data'] + + # Add formatting + header_format = workbook.add_format({ + 'bold': True, + 'text_wrap': True, + 'valign': 'top', + 'fg_color': '#D7E4BD', + 'border': 1 + }) + + # Write headers with formatting + for col_num, value in enumerate(df.columns.values): + worksheet.write(0, col_num, value, header_format) + + # Auto-adjust column widths + for i, col in enumerate(df.columns): + max_len = max(df[col].astype(str).apply(len).max(), len(col)) + 2 + worksheet.set_column(i, i, min(max_len, 50)) + + output.seek(0) + filename = f"analytics_{view_type}_{mode}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + return Response( + output.getvalue(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename={filename}'} + ) + + except Exception as e: + raise Exception(f"Error creating Excel export: {str(e)}") \ No newline at end of file diff --git a/data_formatting.py b/data_formatting.py new file mode 100644 index 0000000..8b43b4f --- /dev/null +++ b/data_formatting.py @@ -0,0 +1,147 @@ +""" +Data formatting utilities for TimeTrack application. +Handles conversion of time entries and analytics data to various display formats. +""" + +from datetime import datetime +from collections import defaultdict + + +def format_duration(seconds): + """Format duration in seconds to HH:MM:SS format.""" + if seconds is None: + return '00:00:00' + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + seconds = seconds % 60 + return f"{hours:d}:{minutes:02d}:{seconds:02d}" + + +def prepare_export_data(entries): + """Prepare time entries data for export.""" + data = [] + for entry in entries: + row = { + 'Date': entry.arrival_time.strftime('%Y-%m-%d'), + 'Project Code': entry.project.code if entry.project else '', + 'Project Name': entry.project.name if entry.project else '', + 'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'), + 'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active', + 'Work Duration (HH:MM:SS)': format_duration(entry.duration) if entry.duration is not None else 'In progress', + 'Break Duration (HH:MM:SS)': format_duration(entry.total_break_duration), + 'Work Duration (seconds)': entry.duration if entry.duration is not None else 0, + 'Break Duration (seconds)': entry.total_break_duration if entry.total_break_duration is not None else 0, + 'Notes': entry.notes if entry.notes else '' + } + data.append(row) + return data + + +def prepare_team_hours_export_data(team, team_data, date_range): + """Prepare team hours data for export.""" + export_data = [] + + for member_data in team_data: + user = member_data['user'] + daily_hours = member_data['daily_hours'] + + # Create base row with member info + row = { + 'Team': team['name'], + 'Member': user['username'], + 'Email': user['email'], + 'Total Hours': member_data['total_hours'] + } + + # Add daily hours columns + for date_str in date_range: + formatted_date = datetime.strptime(date_str, '%Y-%m-%d').strftime('%m/%d/%Y') + row[formatted_date] = daily_hours.get(date_str, 0.0) + + export_data.append(row) + + return export_data + + +def format_table_data(entries): + """Format data for table view in analytics.""" + formatted_entries = [] + for entry in entries: + formatted_entry = { + 'id': entry.id, + 'date': entry.arrival_time.strftime('%Y-%m-%d'), + 'arrival_time': entry.arrival_time.strftime('%H:%M:%S'), + 'departure_time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active', + 'duration': format_duration(entry.duration) if entry.duration else 'In progress', + 'break_duration': format_duration(entry.total_break_duration), + 'project_code': entry.project.code if entry.project else None, + 'project_name': entry.project.name if entry.project else 'No Project', + 'notes': entry.notes or '', + 'user_name': entry.user.username + } + formatted_entries.append(formatted_entry) + + return {'entries': formatted_entries} + + +def format_graph_data(entries, granularity='daily'): + """Format data for graph visualization in analytics.""" + # Group data by date + daily_data = defaultdict(lambda: {'total_hours': 0, 'projects': defaultdict(int)}) + project_totals = defaultdict(int) + + for entry in entries: + if entry.departure_time and entry.duration: + date_key = entry.arrival_time.strftime('%Y-%m-%d') + hours = entry.duration / 3600 # Convert seconds to hours + + daily_data[date_key]['total_hours'] += hours + project_name = entry.project.name if entry.project else 'No Project' + daily_data[date_key]['projects'][project_name] += hours + project_totals[project_name] += hours + + # Format time series data + time_series = [] + for date, data in sorted(daily_data.items()): + time_series.append({ + 'date': date, + 'hours': round(data['total_hours'], 2) + }) + + # Format project distribution + project_distribution = [ + {'project': project, 'hours': round(hours, 2)} + for project, hours in project_totals.items() + ] + + return { + 'timeSeries': time_series, + 'projectDistribution': project_distribution, + 'totalHours': sum(project_totals.values()), + 'totalDays': len(daily_data) + } + + +def format_team_data(entries, granularity='daily'): + """Format data for team view in analytics.""" + # Group by user and date + user_data = defaultdict(lambda: {'daily_hours': defaultdict(float), 'total_hours': 0}) + + for entry in entries: + if entry.departure_time and entry.duration: + date_key = entry.arrival_time.strftime('%Y-%m-%d') + hours = entry.duration / 3600 + + user_data[entry.user.username]['daily_hours'][date_key] += hours + user_data[entry.user.username]['total_hours'] += hours + + # Format for frontend + team_data = [] + for username, data in user_data.items(): + team_data.append({ + 'username': username, + 'daily_hours': dict(data['daily_hours']), + 'total_hours': round(data['total_hours'], 2) + }) + + return {'team_data': team_data} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 2fec1c7..3bd38b9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1127,4 +1127,377 @@ input[type="time"]::-webkit-datetime-edit { .mobile-overlay { display: none !important; } +} + +/* Analytics Interface Styles */ +.analytics-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid #e9ecef; +} + +.analytics-header h2 { + color: #2c3e50; + margin: 0; + font-size: 1.8rem; + font-weight: 600; +} + +.mode-switcher { + display: flex; + gap: 0.5rem; + border-radius: 8px; + background: #f8f9fa; + padding: 0.25rem; + border: 1px solid #dee2e6; +} + +.mode-btn { + background: transparent; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; + color: #6c757d; +} + +.mode-btn.active { + background: #4CAF50; + color: white; + box-shadow: 0 2px 4px rgba(76, 175, 80, 0.3); +} + +.mode-btn:hover:not(.active) { + background: #e9ecef; + color: #495057; +} + +.filter-panel { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; + border: 1px solid #dee2e6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.filter-row { + display: flex; + gap: 1.5rem; + align-items: end; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + min-width: 150px; +} + +.filter-group label { + font-weight: 500; + color: #495057; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.filter-group input, +.filter-group select { + padding: 0.5rem; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +.filter-group input:focus, +.filter-group select:focus { + outline: none; + border-color: #4CAF50; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); +} + +.view-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + border-bottom: 2px solid #e9ecef; +} + +.tab-btn { + background: transparent; + border: none; + padding: 0.75rem 1.5rem; + cursor: pointer; + font-weight: 500; + color: #6c757d; + border-bottom: 3px solid transparent; + transition: all 0.2s ease; + font-size: 1rem; +} + +.tab-btn.active { + color: #4CAF50; + border-bottom-color: #4CAF50; + background: rgba(76, 175, 80, 0.05); +} + +.tab-btn:hover:not(.active) { + color: #495057; + background: rgba(0, 0, 0, 0.05); +} + +.view-content { + display: none; +} + +.view-content.active { + display: block; +} + +.view-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #dee2e6; +} + +.view-header h3 { + color: #2c3e50; + margin: 0; + font-size: 1.4rem; + font-weight: 600; +} + +.export-buttons { + display: flex; + gap: 0.5rem; +} + +.export-buttons .btn { + padding: 0.5rem 1rem; + font-size: 0.9rem; + border-radius: 6px; + transition: all 0.2s ease; +} + +.table-container { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.charts-container { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2rem; + margin-top: 1rem; +} + +.chart-wrapper { + background: white; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + position: relative; + min-height: 400px; +} + +.chart-controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.chart-controls select { + padding: 0.5rem; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 0.9rem; +} + +.chart-stats { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.stat-card { + background: white; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + text-align: center; + border-left: 4px solid #4CAF50; +} + +.stat-card h4 { + color: #6c757d; + margin: 0 0 0.5rem 0; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +.stat-card span { + color: #2c3e50; + font-size: 2rem; + font-weight: 700; + display: block; +} + +.team-summary-container { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.loading { + text-align: center; + padding: 3rem; + color: #6c757d; + font-size: 1.1rem; +} + +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #4CAF50; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.error-message { + background: #f8d7da; + color: #721c24; + padding: 1rem; + border-radius: 6px; + border: 1px solid #f5c6cb; + margin-bottom: 1rem; +} + +.text-center { + text-align: center; +} + +/* Modal Enhancements for Analytics */ +.modal-content { + max-width: 500px; + margin: 5% auto; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #495057; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.5rem; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #4CAF50; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); +} + +/* Responsive Design for Analytics */ +@media (max-width: 768px) { + .analytics-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .mode-switcher { + justify-content: center; + } + + .filter-row { + flex-direction: column; + gap: 1rem; + } + + .filter-group { + min-width: auto; + } + + .view-tabs { + flex-wrap: wrap; + } + + .tab-btn { + flex: 1; + text-align: center; + min-width: auto; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + } + + .view-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .export-buttons { + justify-content: center; + } + + .charts-container { + grid-template-columns: 1fr; + gap: 1rem; + } + + .chart-stats { + flex-direction: row; + overflow-x: auto; + } + + .stat-card { + min-width: 120px; + flex-shrink: 0; + } + + .stat-card span { + font-size: 1.5rem; + } + + .chart-controls { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } } \ No newline at end of file diff --git a/templates/analytics.html b/templates/analytics.html new file mode 100644 index 0000000..d56bbb7 --- /dev/null +++ b/templates/analytics.html @@ -0,0 +1,682 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

📊 Time Analytics

+
+ + {% if g.user.team_id and g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator'] %} + + {% endif %} +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ + + {% if mode == 'team' %} + + {% endif %} +
+ + + + + + + + +
+
+

Detailed Time Entries

+
+ + +
+
+
+ + + + + {% if mode == 'team' %} + + {% endif %} + + + + + + + {% if mode == 'personal' %} + + {% endif %} + + + + + + + +
DateUserProjectArrivalDepartureDurationBreakNotesActions
+ Click "Apply Filters" to load data +
+
+
+ + +
+
+

Visual Analytics

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

Total Hours

+ 0 +
+
+

Total Days

+ 0 +
+
+

Average Hours/Day

+ 0 +
+
+
+
+ + + {% if mode == 'team' %} +
+
+

Team Hours Summary

+
+ + +
+
+
+ + + + + + + + + + + + +
Team MemberTotal Hours
+ Click "Apply Filters" to load team data +
+
+
+ {% endif %} +
+ + +{% if mode == 'personal' %} + + + + +{% endif %} + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index 8b3bab2..177a5be 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -13,7 +13,7 @@ Dashboard {% endif %} - +

Quick Actions

@@ -23,13 +23,13 @@

Update your personal information and password.

Edit Profile
- +

Configuration

Configure work hours and break settings.

Work Config
- +

Time History

View your complete time tracking history.

@@ -61,14 +61,14 @@
- +

User Management

Manage user accounts, permissions, and roles.

Manage Users
- +

Project Management

Manage projects, assign teams, and track project status.

@@ -88,12 +88,12 @@
{% endif %} - + {% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN] %}

Team Management

- + {% if teams %}
@@ -105,14 +105,8 @@

Teams Managed

- +
-
-

Team Hours

-

View and monitor team member working hours.

- View Team Hours -
- {% if g.user.role == Role.ADMIN %}

Team Configuration

@@ -121,7 +115,7 @@
{% endif %}
- +

Your Team Members

{% if team_members %} @@ -152,7 +146,7 @@ {% endif %}
{% endif %} - + {% if recent_entries %}
diff --git a/templates/history.html b/templates/history.html deleted file mode 100644 index 3633e4a..0000000 --- a/templates/history.html +++ /dev/null @@ -1,342 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -
-

Complete Time Entry History

- - - -
-
-
- - -
-
-
- -
- {% if entries %} - - - - - - - - - - - - - - - {% for entry in entries %} - - - - - - - - - - - {% endfor %} - -
DateProjectArrivalDepartureWork DurationBreak DurationNotesActions
{{ entry.arrival_time.strftime('%Y-%m-%d') }} - {% if entry.project %} - {{ entry.project.code }} - {{ entry.project.name }} - {% else %} - No project - {% endif %} - {{ entry.arrival_time.strftime('%H:%M:%S') }}{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) if entry.duration is not none else 'In progress' }}{{ '%d:%02d:%02d'|format(entry.total_break_duration//3600, (entry.total_break_duration%3600)//60, entry.total_break_duration%60) if entry.total_break_duration is not none else '00:00:00' }} - {% if entry.notes %} - {{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %} - {% else %} - - - {% endif %} - - - -
- {% else %} -

No time entries recorded yet.

- {% endif %} -
-
- - - - - - - - - - -{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index a27d7e6..28b0eec 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -41,7 +41,7 @@
    {% if g.user %}
  • 🏠Home
  • -
  • 📊History
  • +
  • 📊Analytics
  • {% if g.user.role == Role.ADMIN %} @@ -54,9 +54,6 @@
  • 🏭Manage Teams
  • 📝Manage Projects
  • 🔧System Settings
  • - {% if g.user.team_id %} -
  • Team Hours
  • - {% endif %} {% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
  • 👤Profile
  • @@ -65,9 +62,6 @@ {% if g.user.role == Role.SUPERVISOR %}
  • 📝Manage Projects
  • {% endif %} - {% if g.user.team_id %} -
  • Team Hours
  • - {% endif %} {% else %}
  • 👤Profile
  • diff --git a/templates/team_hours.html b/templates/team_hours.html deleted file mode 100644 index df2a451..0000000 --- a/templates/team_hours.html +++ /dev/null @@ -1,237 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -
    -

    Team Hours

    - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    - -
    -
    - - - - -
    -
    Loading team data...
    - - - - - - - -
    - - -
    - - -{% endblock %}