Merge pull request #8 from nullmedium/improve-history-views
Improve history views
This commit is contained in:
502
app.py
502
app.py
@@ -1,5 +1,13 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
|
||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company
|
||||
from data_formatting import (
|
||||
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
||||
format_table_data, format_graph_data, format_team_data
|
||||
)
|
||||
from data_export import (
|
||||
export_to_csv, export_to_excel, export_team_hours_to_csv, export_team_hours_to_excel,
|
||||
export_analytics_csv, export_analytics_excel
|
||||
)
|
||||
import logging
|
||||
from datetime import datetime, time, timedelta
|
||||
import os
|
||||
@@ -761,6 +769,7 @@ def register():
|
||||
new_user.is_verified = True
|
||||
|
||||
# Generate verification token (even if not needed, for consistency)
|
||||
|
||||
token = new_user.generate_verification_token()
|
||||
|
||||
db.session.add(new_user)
|
||||
@@ -948,6 +957,7 @@ def dashboard():
|
||||
|
||||
if g.user.role == Role.ADMIN and g.user.company_id:
|
||||
# Admin sees everything within their company
|
||||
|
||||
dashboard_data.update({
|
||||
'total_users': User.query.filter_by(company_id=g.user.company_id).count(),
|
||||
'total_teams': Team.query.filter_by(company_id=g.user.company_id).count(),
|
||||
@@ -957,6 +967,7 @@ def dashboard():
|
||||
})
|
||||
|
||||
if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
|
||||
|
||||
# Team leaders and supervisors see team-related data
|
||||
if g.user.team_id or g.user.role == Role.ADMIN:
|
||||
if g.user.role == Role.ADMIN and g.user.company_id:
|
||||
@@ -969,6 +980,7 @@ def dashboard():
|
||||
else:
|
||||
# Team leaders/supervisors see their own team
|
||||
teams = [Team.query.get(g.user.team_id)] if g.user.team_id else []
|
||||
|
||||
team_members = User.query.filter_by(
|
||||
team_id=g.user.team_id,
|
||||
company_id=g.user.company_id
|
||||
@@ -1109,7 +1121,6 @@ def edit_user(user_id):
|
||||
error = 'Username already exists in your company'
|
||||
elif email != user.email and User.query.filter_by(email=email, company_id=g.user.company_id).first():
|
||||
error = 'Email already registered in your company'
|
||||
|
||||
if error is None:
|
||||
user.username = username
|
||||
user.email = email
|
||||
@@ -1445,7 +1456,6 @@ def config():
|
||||
|
||||
return render_template('config.html', title='Configuration', config=config)
|
||||
|
||||
|
||||
@app.route('/api/delete/<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
246
data_export.py
Normal 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
147
data_formatting.py
Normal 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}
|
||||
@@ -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
682
templates/analytics.html
Normal 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">×</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">×</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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">×</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">×</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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user