Consolitated History views.

This commit is contained in:
2025-07-01 10:41:05 +02:00
committed by Jens Luedicke
parent de510baac1
commit ad10c1fa7d
6 changed files with 1417 additions and 399 deletions

497
app.py
View File

@@ -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') @app.route('/history')
@login_required @login_required
@@ -1717,127 +1676,403 @@ def download_export():
flash('Invalid export format.') flash('Invalid export format.')
return redirect(url_for('export')) return redirect(url_for('export'))
@app.route('/download_team_hours_export')
@app.route('/analytics')
@app.route('/analytics/<mode>')
@login_required @login_required
@role_required(Role.TEAM_LEADER) def analytics(mode='personal'):
def download_team_hours_export(): """Unified analytics view combining history, team hours, and graphs"""
"""Handle team hours export download requests.""" # Validate mode parameter
export_format = request.args.get('format', 'csv') if mode not in ['personal', 'team']:
mode = 'personal'
# Get the current user's team # Check team access for team mode
team = Team.query.get(g.user.team_id) 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: if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
flash('You are not assigned to any team.') flash('You do not have permission to view team analytics.', 'error')
return redirect(url_for('team_hours')) 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() today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday()) start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6) end_of_week = start_of_week + timedelta(days=6)
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) return render_template('analytics.html',
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) title='Time Analytics',
include_self = request.args.get('include_self', 'false') == 'true' 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: try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() # Parse dates
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() 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)
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:
try:
project_id = int(project_filter)
query = query.filter(TimeEntry.project_id == project_id)
except ValueError: except ValueError:
flash('Invalid date format.') pass
return redirect(url_for('team_hours'))
# Get all team members return query.order_by(TimeEntry.arrival_time.desc()).all()
team_members = User.query.filter_by(team_id=team.id).all()
# Prepare data structure for team members' hours def format_table_data(entries):
team_data = [] """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)
for member in team_members: return {'entries': formatted_entries}
# 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 def format_graph_data(entries, granularity='daily'):
entries = TimeEntry.query.filter( """Format data for graph visualization"""
TimeEntry.user_id == member.id, from collections import defaultdict
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 # Group data by date
daily_hours = {} daily_data = defaultdict(lambda: {'total_hours': 0, 'projects': defaultdict(int)})
total_seconds = 0 project_totals = defaultdict(int)
for entry in entries: for entry in entries:
if entry.duration: # Only count completed entries if entry.departure_time and entry.duration:
entry_date = entry.arrival_time.date() date_key = entry.arrival_time.strftime('%Y-%m-%d')
date_str = entry_date.strftime('%Y-%m-%d') hours = entry.duration / 3600 # Convert seconds to hours
if date_str not in daily_hours: daily_data[date_key]['total_hours'] += hours
daily_hours[date_str] = 0 project_name = entry.project.name if entry.project else 'No Project'
daily_data[date_key]['projects'][project_name] += hours
project_totals[project_name] += hours
daily_hours[date_str] += entry.duration # Format time series data
total_seconds += entry.duration time_series = []
for date, data in sorted(daily_data.items()):
# Convert seconds to hours for display time_series.append({
for date_str in daily_hours: 'date': date,
daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours 'hours': round(data['total_hours'], 2)
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: # Format project distribution
flash('No team member data found for the selected date range.') project_distribution = [
return redirect(url_for('team_hours')) {'project': project, 'hours': round(hours, 2)}
for project, hours in project_totals.items()
]
# Generate a list of dates in the range return {
date_range = [] 'timeSeries': time_series,
current_date = start_date 'projectDistribution': project_distribution,
while current_date <= end_date: 'totalHours': sum(project_totals.values()),
date_range.append(current_date.strftime('%Y-%m-%d')) 'totalDays': len(daily_data)
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) def format_team_data(entries, granularity='daily'):
"""Format data for team view"""
from collections import defaultdict
# Generate filename # Group by user and date
filename = f"{team.name.replace(' ', '_')}_hours_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}" 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}
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)
# Export based on format
if export_format == 'csv': if export_format == 'csv':
response = export_team_hours_to_csv(export_data, filename) return export_analytics_csv(data, view_type, mode)
if response:
return response
else:
flash('Error generating CSV export.')
return redirect(url_for('team_hours'))
elif export_format == 'excel': elif export_format == 'excel':
response = export_team_hours_to_excel(export_data, filename, team.name) return export_analytics_excel(data, view_type, mode)
if response:
return response
else: 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'))
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:
logger.error(f"Error creating Excel export: {str(e)}")
flash('Error generating Excel export.') flash('Error generating Excel export.')
return redirect(url_for('team_hours')) return redirect(url_for('analytics'))
else:
flash('Invalid export format.')
return redirect(url_for('team_hours'))
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, port=5050) app.run(debug=True, port=5050)

View File

@@ -1128,3 +1128,376 @@ input[type="time"]::-webkit-datetime-edit {
display: none !important; 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;
}
}

658
templates/analytics.html Normal file
View File

@@ -0,0 +1,658 @@
{% 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>
<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;
const link = document.createElement('a');
if (format === 'png') {
link.download = 'analytics-chart.png';
link.href = canvas.toDataURL('image/png');
} else if (format === 'pdf') {
// For PDF export, we'd need a library like jsPDF
// For now, just export as PNG
link.download = 'analytics-chart.png';
link.href = canvas.toDataURL('image/png');
}
link.click();
}
// 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

@@ -107,11 +107,6 @@
</div> </div>
<div class="admin-panel"> <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.is_admin %} {% if g.user.is_admin %}
<div class="admin-card"> <div class="admin-card">

View File

@@ -33,7 +33,7 @@
<ul> <ul>
{% if g.user %} {% 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('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 --> <!-- Role-based menu items -->
{% if g.user.is_admin %} {% if g.user.is_admin %}
@@ -45,9 +45,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_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_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> <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] %} {% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
<li class="nav-divider">{{ g.user.username }}</li> <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> <li><a href="{{ url_for('profile') }}" data-tooltip="Profile"><i class="nav-icon">👤</i><span class="nav-text">Profile</span></a></li>
@@ -56,9 +53,6 @@
{% if g.user.role == Role.SUPERVISOR %} {% 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> <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 %} {% 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 %} {% else %}
<li class="nav-divider">{{ g.user.username }}</li> <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> <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 %}