diff --git a/app.py b/app.py index f55fc40..644ab73 100644 --- a/app.py +++ b/app.py @@ -882,47 +882,6 @@ def update_entry(entry_id): } }) -@app.route('/team/hours') -@login_required -@role_required(Role.TEAM_LEADER) # Only team leaders and above can access -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 @@ -1717,127 +1676,403 @@ 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) -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 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')) - if not team: - flash('You are not assigned to any team.') - return redirect(url_for('team_hours')) + # 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 date range from query parameters or use current week as default + # 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 filtered data + data = get_filtered_analytics_data(g.user, mode, start_date, end_date, project_filter) + + # 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: + formatted_data = format_table_data(data) + + return jsonify(formatted_data) - # Get all team members - team_members = User.query.filter_by(team_id=team.id).all() + 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 - # Prepare data structure for team members' hours - team_data = [] + # 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)) - 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 + # 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: + 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() + +def format_table_data(entries): + """Format data for table view""" + 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""" + from collections import defaultdict + + # 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 - # 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 + 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) }) - if not team_data: - flash('No team member data found for the selected date range.') - return redirect(url_for('team_hours')) + # Format project distribution + project_distribution = [ + {'project': project, 'hours': round(hours, 2)} + for project, hours in project_totals.items() + ] - # 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 + 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""" + from collections import defaultdict - export_data = prepare_team_hours_export_data(team_info, team_data, date_range) + # Group by user and date + user_data = defaultdict(lambda: {'daily_hours': defaultdict(float), 'total_hours': 0}) - # Generate filename - filename = f"{team.name.replace(' ', '_')}_hours_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}" + 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 - # Export based on format - if export_format == 'csv': - response = export_team_hours_to_csv(export_data, filename) - if response: - return response + # 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} + +def format_duration(seconds): + """Format duration in seconds to HH:MM:SS""" + if not seconds: + return "00:00:00" + + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + seconds = seconds % 60 + + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + +@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('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 - else: - flash('Error generating Excel export.') - return redirect(url_for('team_hours')) + 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')) + +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: - flash('Invalid export format.') - return redirect(url_for('team_hours')) + # 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: + logger.error(f"Error creating Excel export: {str(e)}") + flash('Error generating Excel export.') + return redirect(url_for('analytics')) if __name__ == '__main__': app.run(debug=True, port=5050) \ 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..bf0667f --- /dev/null +++ b/templates/analytics.html @@ -0,0 +1,658 @@ +{% 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 81bd4e5..2c75ab6 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] or g.user.is_admin %}

Team Management

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

Teams Managed

- +
-
-

Team Hours

-

View and monitor team member working hours.

- View Team Hours -
- + {% if g.user.is_admin %}

Team Configuration

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

Your Team Members

{% if team_members %} @@ -152,7 +147,7 @@ {% endif %}
{% endif %} - + {% if recent_entries %}
diff --git a/templates/layout.html b/templates/layout.html index d6e8137..a9fc498 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -33,7 +33,7 @@
    {% if g.user %}
  • 🏠Home
  • -
  • 📊History
  • +
  • 📊Analytics
  • {% if g.user.is_admin %} @@ -45,9 +45,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
  • @@ -56,9 +53,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 %}