Merge pull request #8 from nullmedium/improve-history-views

Improve history views
This commit is contained in:
Jens Luedicke
2025-07-02 13:17:49 +02:00
committed by GitHub
9 changed files with 1624 additions and 939 deletions

502
app.py
View File

@@ -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/<int:entry_id>', 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/<int:project_id>', 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/<mode>')
@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))

246
data_export.py Normal file
View File

@@ -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)}")

147
data_formatting.py Normal file
View File

@@ -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}

View File

@@ -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;
}
}

682
templates/analytics.html Normal file
View File

@@ -0,0 +1,682 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container">
<div class="analytics-header">
<h2>📊 Time Analytics</h2>
<div class="mode-switcher">
<button class="mode-btn {% if mode == 'personal' %}active{% endif %}"
onclick="switchMode('personal')">Personal</button>
{% if g.user.team_id and g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator'] %}
<button class="mode-btn {% if mode == 'team' %}active{% endif %}"
onclick="switchMode('team')">Team</button>
{% endif %}
</div>
</div>
<!-- Unified Filter Panel -->
<div class="filter-panel">
<div class="filter-row">
<div class="filter-group">
<label for="start-date">Start Date:</label>
<input type="date" id="start-date" value="{{ default_start_date }}">
</div>
<div class="filter-group">
<label for="end-date">End Date:</label>
<input type="date" id="end-date" value="{{ default_end_date }}">
</div>
<div class="filter-group">
<label for="project-filter">Project:</label>
<select id="project-filter">
<option value="">All Projects</option>
<option value="none">No Project Assigned</option>
{% for project in available_projects %}
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<button id="apply-filters" class="btn btn-primary">Apply Filters</button>
</div>
</div>
</div>
<!-- View Tabs -->
<div class="view-tabs">
<button class="tab-btn active" data-view="table">📋 Table View</button>
<button class="tab-btn" data-view="graph">📈 Graph View</button>
{% if mode == 'team' %}
<button class="tab-btn" data-view="team">👥 Team Summary</button>
{% endif %}
</div>
<!-- Loading Indicator -->
<div id="loading-indicator" class="loading" style="display: none;">
<div class="spinner"></div>
Loading analytics data...
</div>
<!-- Error Display -->
<div id="error-display" class="error-message" style="display: none;"></div>
<!-- Table View -->
<div id="table-view" class="view-content active">
<div class="view-header">
<h3>Detailed Time Entries</h3>
<div class="export-buttons">
<button class="btn btn-secondary" onclick="exportData('csv', 'table')">Export CSV</button>
<button class="btn btn-secondary" onclick="exportData('excel', 'table')">Export Excel</button>
</div>
</div>
<div class="table-container">
<table id="entries-table" class="time-history">
<thead>
<tr>
<th>Date</th>
{% if mode == 'team' %}
<th>User</th>
{% endif %}
<th>Project</th>
<th>Arrival</th>
<th>Departure</th>
<th>Duration</th>
<th>Break</th>
<th>Notes</th>
{% if mode == 'personal' %}
<th>Actions</th>
{% endif %}
</tr>
</thead>
<tbody id="entries-tbody">
<tr>
<td colspan="{% if mode == 'team' %}8{% else %}9{% endif %}" class="text-center">
Click "Apply Filters" to load data
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Graph View -->
<div id="graph-view" class="view-content">
<div class="view-header">
<h3>Visual Analytics</h3>
<div class="chart-controls">
<select id="chart-type">
<option value="timeSeries">Time Series</option>
<option value="projectDistribution">Project Distribution</option>
</select>
<div class="export-buttons">
<button class="btn btn-secondary" onclick="exportChart('png')">Export PNG</button>
<button class="btn btn-secondary" onclick="exportChart('pdf')">Export PDF</button>
</div>
</div>
</div>
<div class="charts-container">
<div class="chart-wrapper">
<canvas id="main-chart"></canvas>
</div>
<div class="chart-stats">
<div class="stat-card">
<h4>Total Hours</h4>
<span id="total-hours">0</span>
</div>
<div class="stat-card">
<h4>Total Days</h4>
<span id="total-days">0</span>
</div>
<div class="stat-card">
<h4>Average Hours/Day</h4>
<span id="avg-hours">0</span>
</div>
</div>
</div>
</div>
<!-- Team Summary View -->
{% if mode == 'team' %}
<div id="team-view" class="view-content">
<div class="view-header">
<h3>Team Hours Summary</h3>
<div class="export-buttons">
<button class="btn btn-secondary" onclick="exportData('csv', 'team')">Export CSV</button>
<button class="btn btn-secondary" onclick="exportData('excel', 'team')">Export Excel</button>
</div>
</div>
<div class="team-summary-container">
<table id="team-table" class="time-history">
<thead id="team-table-head">
<tr>
<th>Team Member</th>
<th>Total Hours</th>
</tr>
</thead>
<tbody id="team-tbody">
<tr>
<td colspan="2" class="text-center">
Click "Apply Filters" to load team data
</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
<!-- Edit Modal (for personal mode) -->
{% if mode == 'personal' %}
<div id="editModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span class="close">&times;</span>
<h2>Edit Time Entry</h2>
</div>
<div class="modal-body">
<form id="editForm">
<input type="hidden" id="editEntryId">
<div class="form-group">
<label for="editArrivalTime">Arrival Time:</label>
<input type="datetime-local" id="editArrivalTime" required>
</div>
<div class="form-group">
<label for="editDepartureTime">Departure Time:</label>
<input type="datetime-local" id="editDepartureTime">
</div>
<div class="form-group">
<label for="editNotes">Notes:</label>
<textarea id="editNotes" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEditModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEntry()">Save Changes</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span class="close">&times;</span>
<h2>Confirm Deletion</h2>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this time entry? This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeDeleteModal()">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
{% endif %}
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- jsPDF for PDF export -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script>
// Global analytics state and controller
let analyticsController;
document.addEventListener('DOMContentLoaded', function() {
analyticsController = new TimeAnalyticsController();
analyticsController.init();
});
class TimeAnalyticsController {
constructor() {
this.state = {
mode: '{{ mode }}',
dateRange: {
start: '{{ default_start_date }}',
end: '{{ default_end_date }}'
},
selectedProject: '',
activeView: 'table',
data: null
};
this.charts = {};
}
init() {
this.setupEventListeners();
// Auto-load data on initialization
this.loadData();
}
setupEventListeners() {
// Filter controls
document.getElementById('apply-filters').addEventListener('click', () => {
this.updateFilters();
this.loadData();
});
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchView(e.target.dataset.view);
});
});
// Chart type switching
const chartTypeSelect = document.getElementById('chart-type');
if (chartTypeSelect) {
chartTypeSelect.addEventListener('change', () => {
this.updateChart();
});
}
// Modal close handlers
document.querySelectorAll('.close').forEach(closeBtn => {
closeBtn.addEventListener('click', (e) => {
e.target.closest('.modal').style.display = 'none';
});
});
// Click outside modal to close
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
e.target.style.display = 'none';
}
});
}
updateFilters() {
this.state.dateRange.start = document.getElementById('start-date').value;
this.state.dateRange.end = document.getElementById('end-date').value;
this.state.selectedProject = document.getElementById('project-filter').value;
}
async loadData() {
this.showLoading(true);
this.hideError();
try {
const params = new URLSearchParams({
mode: this.state.mode,
view: this.state.activeView,
start_date: this.state.dateRange.start,
end_date: this.state.dateRange.end
});
if (this.state.selectedProject) {
params.append('project_id', this.state.selectedProject);
}
const response = await fetch(`/api/analytics/data?${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to load data');
}
this.state.data = data;
this.refreshCurrentView();
} catch (error) {
this.showError(error.message);
} finally {
this.showLoading(false);
}
}
refreshCurrentView() {
switch (this.state.activeView) {
case 'table':
this.updateTableView();
break;
case 'graph':
this.updateGraphView();
break;
case 'team':
this.updateTeamView();
break;
}
}
switchView(viewType) {
// Update tab appearance
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-view="${viewType}"]`).classList.add('active');
// Show/hide view content
document.querySelectorAll('.view-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${viewType}-view`).classList.add('active');
this.state.activeView = viewType;
// Load data for new view if needed
if (this.state.data) {
this.refreshCurrentView();
} else {
this.loadData();
}
}
updateTableView() {
const tbody = document.getElementById('entries-tbody');
const entries = this.state.data.entries || [];
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="text-center">No entries found for the selected criteria</td></tr>`;
return;
}
tbody.innerHTML = entries.map(entry => `
<tr>
<td>${entry.date}</td>
${this.state.mode === 'team' ? `<td>${entry.user_name}</td>` : ''}
<td>
${entry.project_code ? `<span class="project-tag">${entry.project_code}</span>` : ''}
${entry.project_name}
</td>
<td>${entry.arrival_time}</td>
<td>${entry.departure_time}</td>
<td>${entry.duration}</td>
<td>${entry.break_duration}</td>
<td class="notes-preview" title="${entry.notes}">
${entry.notes.length > 50 ? entry.notes.substring(0, 50) + '...' : entry.notes}
</td>
${this.state.mode === 'personal' ? `
<td>
<button class="btn btn-sm btn-primary" onclick="editEntry(${entry.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteEntry(${entry.id})">Delete</button>
</td>` : ''}
</tr>
`).join('');
}
updateGraphView() {
const data = this.state.data;
if (!data) return;
// Update stats
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
document.getElementById('total-days').textContent = data.totalDays || '0';
document.getElementById('avg-hours').textContent =
data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0';
this.updateChart();
}
updateChart() {
const chartType = document.getElementById('chart-type').value;
const canvas = document.getElementById('main-chart');
const ctx = canvas.getContext('2d');
// Destroy existing chart
if (this.charts.main) {
this.charts.main.destroy();
}
const data = this.state.data;
if (!data) return;
if (chartType === 'timeSeries') {
this.charts.main = new Chart(ctx, {
type: 'line',
data: {
labels: data.timeSeries?.map(d => d.date) || [],
datasets: [{
label: 'Hours Worked',
data: data.timeSeries?.map(d => d.hours) || [],
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
fill: true,
tension: 0.1
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Daily Hours Worked'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Hours'
}
},
x: {
title: {
display: true,
text: 'Date'
}
}
}
}
});
} else if (chartType === 'projectDistribution') {
this.charts.main = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.projectDistribution?.map(d => d.project) || [],
datasets: [{
data: data.projectDistribution?.map(d => d.hours) || [],
backgroundColor: [
'#4CAF50', '#2196F3', '#FF9800', '#E91E63',
'#9C27B0', '#00BCD4', '#8BC34A', '#FFC107'
]
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Time Distribution by Project'
},
legend: {
position: 'bottom'
}
}
}
});
}
}
updateTeamView() {
const tbody = document.getElementById('team-tbody');
const teamData = this.state.data.team_data || [];
if (teamData.length === 0) {
tbody.innerHTML = `<tr><td colspan="2" class="text-center">No team data found</td></tr>`;
return;
}
tbody.innerHTML = teamData.map(member => `
<tr>
<td>${member.username}</td>
<td>${member.total_hours}h</td>
</tr>
`).join('');
}
showLoading(show) {
const indicator = document.getElementById('loading-indicator');
indicator.style.display = show ? 'block' : 'none';
}
showError(message) {
const errorDiv = document.getElementById('error-display');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
hideError() {
document.getElementById('error-display').style.display = 'none';
}
}
// Global functions for mode switching and exports
function switchMode(mode) {
window.location.href = `/analytics/${mode}`;
}
function exportData(format, viewType) {
const params = new URLSearchParams({
format: format,
view: viewType,
mode: analyticsController.state.mode,
start_date: analyticsController.state.dateRange.start,
end_date: analyticsController.state.dateRange.end
});
if (analyticsController.state.selectedProject) {
params.append('project_id', analyticsController.state.selectedProject);
}
window.location.href = `/api/analytics/export?${params}`;
}
function exportChart(format) {
const chart = analyticsController.charts.main;
if (!chart) return;
const canvas = chart.canvas;
if (format === 'png') {
const link = document.createElement('a');
link.download = 'analytics-chart.png';
link.href = canvas.toDataURL('image/png');
link.click();
} else if (format === 'pdf') {
// Get chart title for PDF
const chartType = document.getElementById('chart-type').value;
const title = chartType === 'timeSeries' ? 'Daily Hours Worked' : 'Time Distribution by Project';
// Create PDF using jsPDF
const { jsPDF } = window.jspdf;
const pdf = new jsPDF('landscape', 'mm', 'a4');
// Add title
pdf.setFontSize(16);
pdf.text(title, 20, 20);
// Add chart image to PDF
const imgData = canvas.toDataURL('image/png');
const imgWidth = 250;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
pdf.addImage(imgData, 'PNG', 20, 35, imgWidth, imgHeight);
// Add export info
pdf.setFontSize(10);
const dateRange = `${analyticsController.state.dateRange.start} to ${analyticsController.state.dateRange.end}`;
pdf.text(`Date Range: ${dateRange}`, 20, imgHeight + 50);
pdf.text(`Generated: ${new Date().toLocaleString()}`, 20, imgHeight + 60);
// Save PDF
pdf.save('analytics-chart.pdf');
}
}
// Entry management functions (personal mode only)
{% if mode == 'personal' %}
function editEntry(entryId) {
// Find entry data
const entry = analyticsController.state.data.entries.find(e => e.id === entryId);
if (!entry) return;
// Populate modal
document.getElementById('editEntryId').value = entryId;
document.getElementById('editArrivalTime').value =
`${entry.date}T${entry.arrival_time}`;
if (entry.departure_time !== 'Active') {
document.getElementById('editDepartureTime').value =
`${entry.date}T${entry.departure_time}`;
}
document.getElementById('editNotes').value = entry.notes;
// Show modal
document.getElementById('editModal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
}
async function saveEntry() {
const entryId = document.getElementById('editEntryId').value;
const arrivalTime = document.getElementById('editArrivalTime').value;
const departureTime = document.getElementById('editDepartureTime').value;
const notes = document.getElementById('editNotes').value;
try {
const response = await fetch(`/api/update/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
arrival_time: arrivalTime,
departure_time: departureTime || null,
notes: notes
})
});
const result = await response.json();
if (result.success) {
closeEditModal();
analyticsController.loadData(); // Refresh data
} else {
alert('Error updating entry: ' + result.message);
}
} catch (error) {
alert('Error updating entry: ' + error.message);
}
}
let entryToDelete = null;
function deleteEntry(entryId) {
entryToDelete = entryId;
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
entryToDelete = null;
}
async function confirmDelete() {
if (!entryToDelete) return;
try {
const response = await fetch(`/api/delete/${entryToDelete}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
closeDeleteModal();
analyticsController.loadData(); // Refresh data
} else {
alert('Error deleting entry: ' + result.message);
}
} catch (error) {
alert('Error deleting entry: ' + error.message);
}
}
{% endif %}
</script>
{% endblock %}

View File

@@ -13,7 +13,7 @@
Dashboard
{% endif %}
</h1>
<!-- Quick Actions section -->
<div class="quick-actions">
<h2>Quick Actions</h2>
@@ -23,13 +23,13 @@
<p>Update your personal information and password.</p>
<a href="{{ url_for('profile') }}" class="btn btn-secondary">Edit Profile</a>
</div>
<div class="admin-card">
<h2>Configuration</h2>
<p>Configure work hours and break settings.</p>
<a href="{{ url_for('config') }}" class="btn btn-secondary">Work Config</a>
</div>
<div class="admin-card">
<h2>Time History</h2>
<p>View your complete time tracking history.</p>
@@ -61,14 +61,14 @@
</div>
</div>
</div>
<div class="admin-panel">
<div class="admin-card">
<h2>User Management</h2>
<p>Manage user accounts, permissions, and roles.</p>
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">Manage Users</a>
</div>
<div class="admin-card">
<h2>Project Management</h2>
<p>Manage projects, assign teams, and track project status.</p>
@@ -88,12 +88,12 @@
</div>
</div>
{% endif %}
<!-- Team Leader and Supervisor sections -->
{% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN] %}
<div class="team-section">
<h2>Team Management</h2>
{% if teams %}
<div class="team-stats">
<div class="stat-card">
@@ -105,14 +105,8 @@
<p>Teams Managed</p>
</div>
</div>
<div class="admin-panel">
<div class="admin-card">
<h2>Team Hours</h2>
<p>View and monitor team member working hours.</p>
<a href="{{ url_for('team_hours') }}" class="btn btn-primary">View Team Hours</a>
</div>
{% if g.user.role == Role.ADMIN %}
<div class="admin-card">
<h2>Team Configuration</h2>
@@ -121,7 +115,7 @@
</div>
{% endif %}
</div>
<div class="team-members">
<h3>Your Team Members</h3>
{% if team_members %}
@@ -152,7 +146,7 @@
{% endif %}
</div>
{% endif %}
<!-- Recent Activity section for all roles -->
{% if recent_entries %}
<div class="recent-activity">

View File

@@ -1,342 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container">
<h2>Complete Time Entry History</h2>
<div class="export-button-container">
<a href="{{ url_for('export') }}" class="btn">Export Data</a>
</div>
<!-- Project Filter -->
<div class="filter-section">
<form method="GET" action="{{ url_for('history') }}" class="filter-form">
<div class="form-group">
<label for="project-filter">Filter by Project:</label>
<select id="project-filter" name="project_id" onchange="this.form.submit()">
<option value="">All Projects</option>
{% for project in available_projects %}
<option value="{{ project.id }}"
{% if request.args.get('project_id') and request.args.get('project_id')|int == project.id %}selected{% endif %}>
{{ project.code }} - {{ project.name }}
</option>
{% endfor %}
<option value="none"
{% if request.args.get('project_id') == 'none' %}selected{% endif %}>
No Project Assigned
</option>
</select>
</div>
</form>
</div>
<div class="history-section">
{% if entries %}
<table class="time-history">
<thead>
<tr>
<th>Date</th>
<th>Project</th>
<th>Arrival</th>
<th>Departure</th>
<th>Work Duration</th>
<th>Break Duration</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-entry-id="{{ entry.id }}">
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
<td>
{% if entry.project %}
<span class="project-tag">{{ entry.project.code }}</span>
<small>{{ entry.project.name }}</small>
{% else %}
<em>No project</em>
{% endif %}
</td>
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
<td>{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) if entry.duration is not none else 'In progress' }}</td>
<td>{{ '%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' }}</td>
<td>
{% if entry.notes %}
<span class="notes-preview" title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
{% else %}
<em>-</em>
{% endif %}
</td>
<td>
<button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
<button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No time entries recorded yet.</p>
{% endif %}
</div>
</div>
<!-- Edit Entry Modal -->
<div id="edit-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Edit Time Entry</h3>
<form id="edit-entry-form">
<input type="hidden" id="edit-entry-id">
<div class="form-group">
<label for="edit-arrival-date">Arrival Date:</label>
<input type="date" id="edit-arrival-date" required>
</div>
<div class="form-group">
<label for="edit-arrival-time">Arrival Time:</label>
<input type="time" id="edit-arrival-time" required>
</div>
<div class="form-group">
<label for="edit-departure-date">Departure Date:</label>
<input type="date" id="edit-departure-date">
</div>
<div class="form-group">
<label for="edit-departure-time">Departure Time:</label>
<input type="time" id="edit-departure-time">
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Confirm Deletion</h3>
<p>Are you sure you want to delete this time entry? This action cannot be undone.</p>
<input type="hidden" id="delete-entry-id">
<div class="modal-actions">
<button id="confirm-delete" class="btn btn-danger">Delete</button>
<button id="cancel-delete" class="btn">Cancel</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Edit entry functionality
document.querySelectorAll('.edit-entry-btn').forEach(button => {
button.addEventListener('click', function() {
const entryId = this.getAttribute('data-id');
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
const cells = row.querySelectorAll('td');
// Get date and time from the row
const dateStr = cells[0].textContent.trim();
const arrivalTimeStr = cells[1].textContent.trim();
const departureTimeStr = cells[2].textContent.trim();
// Set values in the form
document.getElementById('edit-entry-id').value = entryId;
document.getElementById('edit-arrival-date').value = dateStr;
// Format time for input (HH:MM format)
document.getElementById('edit-arrival-time').value = arrivalTimeStr.substring(0, 5);
if (departureTimeStr && departureTimeStr !== 'Active') {
document.getElementById('edit-departure-date').value = dateStr;
document.getElementById('edit-departure-time').value = departureTimeStr.substring(0, 5);
} else {
document.getElementById('edit-departure-date').value = '';
document.getElementById('edit-departure-time').value = '';
}
// Show the modal
document.getElementById('edit-modal').style.display = 'block';
});
});
// Delete entry functionality
document.querySelectorAll('.delete-entry-btn').forEach(button => {
button.addEventListener('click', function() {
const entryId = this.getAttribute('data-id');
document.getElementById('delete-entry-id').value = entryId;
document.getElementById('delete-modal').style.display = 'block';
});
});
// Close modals when clicking the X
document.querySelectorAll('.close').forEach(closeBtn => {
closeBtn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals when clicking outside
window.addEventListener('click', function(event) {
if (event.target.classList.contains('modal')) {
event.target.style.display = 'none';
}
});
// Cancel delete
document.getElementById('cancel-delete').addEventListener('click', function() {
document.getElementById('delete-modal').style.display = 'none';
});
// Confirm delete
document.getElementById('confirm-delete').addEventListener('click', function() {
const entryId = document.getElementById('delete-entry-id').value;
fetch(`/api/delete/${entryId}`, {
method: 'DELETE',
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Remove the row from the table
document.querySelector(`tr[data-entry-id="${entryId}"]`).remove();
// Close the modal
document.getElementById('delete-modal').style.display = 'none';
// Show success message
alert('Entry deleted successfully');
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while deleting the entry');
});
});
// Submit edit form
document.getElementById('edit-entry-form').addEventListener('submit', function(e) {
e.preventDefault();
const entryId = document.getElementById('edit-entry-id').value;
const arrivalDate = document.getElementById('edit-arrival-date').value;
const arrivalTime = document.getElementById('edit-arrival-time').value;
const departureDate = document.getElementById('edit-departure-date').value || '';
const departureTime = document.getElementById('edit-departure-time').value || '';
// Ensure we have seconds in the time strings
const arrivalTimeWithSeconds = arrivalTime.includes(':') ?
(arrivalTime.split(':').length === 2 ? arrivalTime + ':00' : arrivalTime) :
arrivalTime + ':00:00';
// Format datetime strings for the API (YYYY-MM-DD HH:MM:SS)
const arrivalDateTime = `${arrivalDate} ${arrivalTimeWithSeconds}`;
let departureDateTime = null;
if (departureDate && departureTime) {
const departureTimeWithSeconds = departureTime.includes(':') ?
(departureTime.split(':').length === 2 ? departureTime + ':00' : departureTime) :
departureTime + ':00:00';
departureDateTime = `${departureDate} ${departureTimeWithSeconds}`;
}
// Send update request
fetch(`/api/update/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
arrival_time: arrivalDateTime,
departure_time: departureDateTime
}),
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || 'Server error');
});
}
return response.json();
})
.then(data => {
if (data.success) {
// Close the modal
document.getElementById('edit-modal').style.display = 'none';
// Refresh the page to show updated data
location.reload();
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating the entry: ' + error.message);
});
});
});
</script>
<style>
.filter-section {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.filter-form .form-group {
margin: 0;
}
.filter-form label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.filter-form select {
width: 100%;
max-width: 300px;
padding: 0.5rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.filter-form select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
.project-tag {
background: #4CAF50;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
margin-right: 0.5rem;
}
.project-tag + small {
color: #666;
font-size: 0.85rem;
}
.notes-preview {
color: #666;
font-size: 0.9rem;
cursor: help;
}
.time-history td {
vertical-align: middle;
}
.time-history .project-tag + small {
display: block;
margin-top: 0.25rem;
}
</style>
{% endblock %}

View File

@@ -41,7 +41,7 @@
<ul>
{% if g.user %}
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
<li><a href="{{ url_for('history') }}" data-tooltip="History"><i class="nav-icon">📊</i><span class="nav-text">History</span></a></li>
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
<!-- Role-based menu items -->
{% if g.user.role == Role.ADMIN %}
@@ -54,9 +54,6 @@
<li><a href="{{ url_for('admin_teams') }}" data-tooltip="Manage Teams"><i class="nav-icon">🏭</i><span class="nav-text">Manage Teams</span></a></li>
<li><a href="{{ url_for('admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
<li><a href="{{ url_for('admin_settings') }}" data-tooltip="System Settings"><i class="nav-icon">🔧</i><span class="nav-text">System Settings</span></a></li>
{% if g.user.team_id %}
<li><a href="{{ url_for('team_hours') }}" data-tooltip="Team Hours"><i class="nav-icon"></i><span class="nav-text">Team Hours</span></a></li>
{% endif %}
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
<li class="nav-divider">{{ g.user.username }}</li>
<li><a href="{{ url_for('profile') }}" data-tooltip="Profile"><i class="nav-icon">👤</i><span class="nav-text">Profile</span></a></li>
@@ -65,9 +62,6 @@
{% if g.user.role == Role.SUPERVISOR %}
<li><a href="{{ url_for('admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
{% endif %}
{% if g.user.team_id %}
<li><a href="{{ url_for('team_hours') }}" data-tooltip="Team Hours"><i class="nav-icon"></i><span class="nav-text">Team Hours</span></a></li>
{% endif %}
{% else %}
<li class="nav-divider">{{ g.user.username }}</li>
<li><a href="{{ url_for('profile') }}" data-tooltip="Profile"><i class="nav-icon">👤</i><span class="nav-text">Profile</span></a></li>

View File

@@ -1,237 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="timetrack-container">
<h2>Team Hours</h2>
<div class="date-filter">
<form id="date-range-form" method="GET" action="{{ url_for('team_hours') }}">
<div class="form-group">
<label for="start-date">Start Date:</label>
<input type="date" id="start-date" name="start_date" value="{{ start_date.strftime('%Y-%m-%d') }}">
</div>
<div class="form-group">
<label for="end-date">End Date:</label>
<input type="date" id="end-date" name="end_date" value="{{ end_date.strftime('%Y-%m-%d') }}">
</div>
<div class="form-group">
<label for="include-self">
<input type="checkbox" id="include-self" name="include_self" {% if request.args.get('include_self') %}checked{% endif %}> Include my hours
</label>
</div>
<button type="submit" class="btn">Apply Filter</button>
</form>
</div>
<!-- Export Buttons -->
<div class="export-button-container" id="export-buttons" style="display: none;">
<h4>Export Team Hours</h4>
<div class="quick-export-buttons">
<button class="btn" onclick="exportTeamHours('csv')">Export as CSV</button>
<button class="btn" onclick="exportTeamHours('excel')">Export as Excel</button>
</div>
</div>
<div class="team-hours-container">
<div id="loading">Loading team data...</div>
<div id="team-info" style="display: none;">
<h3>Team: <span id="team-name"></span></h3>
<p id="team-description"></p>
</div>
<div id="team-hours-table" style="display: none;">
<table class="time-history">
<thead id="table-header">
<tr>
<th>Team Member</th>
{% for date in date_range %}
<th>{{ date.strftime('%a, %b %d') }}</th>
{% endfor %}
<th>Total Hours</th>
</tr>
</thead>
<tbody id="table-body">
<!-- Team member data will be added dynamically -->
</tbody>
</table>
</div>
<div id="no-data" style="display: none;">
<p>No time entries found for the selected date range.</p>
</div>
<div id="error-message" style="display: none;" class="error-message">
<!-- Error messages will be displayed here -->
</div>
</div>
<div class="team-hours-details" id="member-details" style="display: none;">
<h3>Detailed Entries for <span id="selected-member"></span></h3>
<table class="time-history">
<thead>
<tr>
<th>Date</th>
<th>Arrival</th>
<th>Departure</th>
<th>Work Duration</th>
<th>Break Duration</th>
</tr>
</thead>
<tbody id="details-body">
<!-- Entry details will be added dynamically -->
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load team hours data when the page loads
loadTeamHoursData();
// Handle date filter form submission
document.getElementById('date-range-form').addEventListener('submit', function(e) {
e.preventDefault();
loadTeamHoursData();
});
function loadTeamHoursData() {
// Show loading indicator
document.getElementById('loading').style.display = 'block';
document.getElementById('team-hours-table').style.display = 'none';
document.getElementById('team-info').style.display = 'none';
document.getElementById('no-data').style.display = 'none';
document.getElementById('error-message').style.display = 'none';
document.getElementById('member-details').style.display = 'none';
// Get filter values
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
const includeSelf = document.getElementById('include-self').checked;
// Build API URL with query parameters
const apiUrl = `/api/team/hours_data?start_date=${startDate}&end_date=${endDate}&include_self=${includeSelf}`;
// Fetch data from API
fetch(apiUrl)
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || 'Failed to load team hours data');
});
}
return response.json();
})
.then(data => {
if (data.success) {
displayTeamData(data);
} else {
showError(data.message || 'Failed to load team hours data.');
}
})
.catch(error => {
console.error('Error fetching team hours data:', error);
showError(error.message || 'An error occurred while loading the team hours data.');
});
}
function displayTeamData(data) {
// Populate team info
document.getElementById('team-name').textContent = data.team.name;
document.getElementById('team-description').textContent = data.team.description || '';
document.getElementById('team-info').style.display = 'block';
// Populate team hours table
const tableHeader = document.getElementById('table-header').querySelector('tr');
tableHeader.innerHTML = '<th>Team Member</th>';
data.date_range.forEach(dateStr => {
const th = document.createElement('th');
th.textContent = new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
tableHeader.appendChild(th);
});
const totalHoursTh = document.createElement('th');
totalHoursTh.textContent = 'Total Hours';
tableHeader.appendChild(totalHoursTh);
const tableBody = document.getElementById('table-body');
tableBody.innerHTML = '';
data.team_data.forEach(memberData => {
const row = document.createElement('tr');
// Add username cell
const usernameCell = document.createElement('td');
usernameCell.textContent = memberData.user.username;
row.appendChild(usernameCell);
// Add daily hours cells
data.date_range.forEach(dateStr => {
const cell = document.createElement('td');
cell.textContent = `${memberData.daily_hours[dateStr] || 0}h`;
row.appendChild(cell);
});
// Add total hours cell
const totalCell = document.createElement('td');
totalCell.innerHTML = `<strong>${memberData.total_hours}h</strong>`;
row.appendChild(totalCell);
tableBody.appendChild(row);
});
// Populate detailed entries
document.getElementById('team-hours-table').style.display = 'block';
document.getElementById('export-buttons').style.display = 'block';
document.getElementById('loading').style.display = 'none';
}
function showError(message) {
document.getElementById('error-message').textContent = message;
document.getElementById('error-message').style.display = 'block';
document.getElementById('loading').style.display = 'none';
}
});
// Export function (global scope)
function exportTeamHours(format) {
console.log('Export function called with format:', format);
try {
// Get current filter values
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
const includeSelf = document.getElementById('include-self').checked;
console.log('Filter values:', { startDate, endDate, includeSelf });
// Validate required fields
if (!startDate || !endDate) {
alert('Please select both start and end dates before exporting.');
return;
}
// Build export URL with query parameters
const exportUrl = `/download_team_hours_export?format=${format}&start_date=${startDate}&end_date=${endDate}&include_self=${includeSelf}`;
console.log('Export URL:', exportUrl);
// Show loading indicator
const exportButtons = document.getElementById('export-buttons');
const originalHTML = exportButtons.innerHTML;
exportButtons.innerHTML = '<h4>Generating export...</h4><p>Please wait...</p>';
// Trigger download
window.location.href = exportUrl;
// Restore buttons after a short delay
setTimeout(() => {
exportButtons.innerHTML = originalHTML;
}, 2000);
} catch (error) {
console.error('Error in exportTeamHours:', error);
alert('An error occurred while trying to export. Please try again.');
}
}
</script>
{% endblock %}