Refactor and enhance export functionality with Team Hours support
- Fix missing import statements for CSV/Excel export functionality - Refactor export code into modular helper functions for better maintainability - Add comprehensive Team Hours export feature with CSV and Excel support - Enhance export UI styling with modern gradients and hover effects - Add role-based access control for team export functionality - Include date range filtering and team leader inclusion options - Add proper error handling and user feedback for export operations - Update dependencies to include pandas and xlsxwriter - Fix JavaScript scope issues for export button functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
366
app.py
366
app.py
@@ -1,8 +1,11 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g
|
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
|
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
import os
|
import os
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import pandas as pd
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask_mail import Mail, Message
|
from flask_mail import Mail, Message
|
||||||
@@ -1296,43 +1299,196 @@ def team_hours_data():
|
|||||||
'start_date': start_date.strftime('%Y-%m-%d'),
|
'start_date': start_date.strftime('%Y-%m-%d'),
|
||||||
'end_date': end_date.strftime('%Y-%m-%d')
|
'end_date': end_date.strftime('%Y-%m-%d')
|
||||||
})
|
})
|
||||||
=======
|
|
||||||
@app.route('/export')
|
@app.route('/export')
|
||||||
def export():
|
def export():
|
||||||
return render_template('export.html', title='Export Data')
|
return render_template('export.html', title='Export Data')
|
||||||
|
|
||||||
@app.route('/download_export')
|
def get_date_range(period, start_date_str=None, end_date_str=None):
|
||||||
def download_export():
|
"""Get start and end date based on period or custom date range."""
|
||||||
# Get parameters
|
today = datetime.now().date()
|
||||||
export_format = request.args.get('format', 'csv')
|
|
||||||
period = request.args.get('period')
|
|
||||||
|
|
||||||
# Handle date range
|
|
||||||
if period:
|
if period:
|
||||||
# Quick export options
|
|
||||||
today = datetime.now().date()
|
|
||||||
if period == 'today':
|
if period == 'today':
|
||||||
start_date = today
|
return today, today
|
||||||
end_date = today
|
|
||||||
elif period == 'week':
|
elif period == 'week':
|
||||||
start_date = today - timedelta(days=today.weekday())
|
start_date = today - timedelta(days=today.weekday())
|
||||||
end_date = today
|
return start_date, today
|
||||||
elif period == 'month':
|
elif period == 'month':
|
||||||
start_date = today.replace(day=1)
|
start_date = today.replace(day=1)
|
||||||
end_date = today
|
return start_date, today
|
||||||
elif period == 'all':
|
elif period == 'all':
|
||||||
# Get the earliest entry date
|
|
||||||
earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first()
|
earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first()
|
||||||
start_date = earliest_entry.arrival_time.date() if earliest_entry else today
|
start_date = earliest_entry.arrival_time.date() if earliest_entry else today
|
||||||
end_date = today
|
return start_date, today
|
||||||
else:
|
else:
|
||||||
# Custom date range
|
# Custom date range
|
||||||
try:
|
try:
|
||||||
start_date = datetime.strptime(request.args.get('start_date'), '%Y-%m-%d').date()
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||||
end_date = datetime.strptime(request.args.get('end_date'), '%Y-%m-%d').date()
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||||
|
return start_date, end_date
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
flash('Invalid date format. Please use YYYY-MM-DD format.')
|
raise ValueError('Invalid date format')
|
||||||
return redirect(url_for('export'))
|
|
||||||
|
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'),
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
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."""
|
||||||
|
export_format = request.args.get('format', 'csv')
|
||||||
|
period = request.args.get('period')
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date, end_date = get_date_range(
|
||||||
|
period,
|
||||||
|
request.args.get('start_date'),
|
||||||
|
request.args.get('end_date')
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid date format. Please use YYYY-MM-DD format.')
|
||||||
|
return redirect(url_for('export'))
|
||||||
|
|
||||||
# Query entries within the date range
|
# Query entries within the date range
|
||||||
start_datetime = datetime.combine(start_date, time.min)
|
start_datetime = datetime.combine(start_date, time.min)
|
||||||
@@ -1347,58 +1503,140 @@ def download_export():
|
|||||||
flash('No entries found for the selected date range.')
|
flash('No entries found for the selected date range.')
|
||||||
return redirect(url_for('export'))
|
return redirect(url_for('export'))
|
||||||
|
|
||||||
# Prepare data for export
|
# Prepare data and filename
|
||||||
data = []
|
data = prepare_export_data(entries)
|
||||||
for entry in entries:
|
|
||||||
row = {
|
|
||||||
'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',
|
|
||||||
'Work Duration (HH:MM:SS)': f"{entry.duration//3600:d}:{(entry.duration%3600)//60:02d}:{entry.duration%60:02d}" if entry.duration is not None else 'In progress',
|
|
||||||
'Break Duration (HH:MM:SS)': f"{entry.total_break_duration//3600:d}:{(entry.total_break_duration%3600)//60:02d}:{entry.total_break_duration%60:02d}" if entry.total_break_duration is not None else '00:00:00',
|
|
||||||
'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
|
|
||||||
}
|
|
||||||
data.append(row)
|
|
||||||
|
|
||||||
# Generate filename
|
|
||||||
filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
|
filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
|
||||||
|
|
||||||
# Export based on format
|
# Export based on format
|
||||||
if export_format == 'csv':
|
if export_format == 'csv':
|
||||||
output = io.StringIO()
|
return export_to_csv(data, filename)
|
||||||
writer = csv.DictWriter(output, fieldnames=data[0].keys())
|
|
||||||
writer.writeheader()
|
|
||||||
writer.writerows(data)
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
output.getvalue(),
|
|
||||||
mimetype='text/csv',
|
|
||||||
headers={'Content-Disposition': f'attachment;filename={filename}.csv'}
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
elif export_format == 'excel':
|
elif export_format == 'excel':
|
||||||
# Convert to DataFrame and export as Excel
|
return export_to_excel(data, filename)
|
||||||
df = pd.DataFrame(data)
|
else:
|
||||||
output = io.BytesIO()
|
flash('Invalid export format.')
|
||||||
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
|
return redirect(url_for('export'))
|
||||||
df.to_excel(writer, sheet_name='TimeTrack Data', index=False)
|
|
||||||
|
@app.route('/download_team_hours_export')
|
||||||
|
@login_required
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
def download_team_hours_export():
|
||||||
|
"""Handle team hours export download requests."""
|
||||||
|
export_format = request.args.get('format', 'csv')
|
||||||
|
|
||||||
|
# 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.')
|
||||||
|
return redirect(url_for('team_hours'))
|
||||||
|
|
||||||
|
# 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'))
|
||||||
|
include_self = request.args.get('include_self', 'false') == 'true'
|
||||||
|
|
||||||
|
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'))
|
||||||
|
|
||||||
|
# Get all team members
|
||||||
|
team_members = User.query.filter_by(team_id=team.id).all()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
# Auto-adjust columns' width
|
# Get time entries for this member in the date range
|
||||||
worksheet = writer.sheets['TimeTrack Data']
|
entries = TimeEntry.query.filter(
|
||||||
for i, col in enumerate(df.columns):
|
TimeEntry.user_id == member.id,
|
||||||
column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2
|
TimeEntry.arrival_time >= datetime.combine(start_date, time.min),
|
||||||
worksheet.set_column(i, i, column_width)
|
TimeEntry.arrival_time <= datetime.combine(end_date, time.max)
|
||||||
|
).order_by(TimeEntry.arrival_time).all()
|
||||||
|
|
||||||
output.seek(0)
|
# Calculate daily and total hours
|
||||||
|
daily_hours = {}
|
||||||
|
total_seconds = 0
|
||||||
|
|
||||||
return send_file(
|
for entry in entries:
|
||||||
output,
|
if entry.duration: # Only count completed entries
|
||||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
entry_date = entry.arrival_time.date()
|
||||||
as_attachment=True,
|
date_str = entry_date.strftime('%Y-%m-%d')
|
||||||
download_name=f"{filename}.xlsx"
|
|
||||||
)
|
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
|
||||||
|
else:
|
||||||
|
flash('Error generating CSV export.')
|
||||||
|
return redirect(url_for('team_hours'))
|
||||||
|
elif export_format == 'excel':
|
||||||
|
response = export_team_hours_to_excel(export_data, filename, team.name)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
flash('Error generating Excel export.')
|
||||||
|
return redirect(url_for('team_hours'))
|
||||||
|
else:
|
||||||
|
flash('Invalid export format.')
|
||||||
|
return redirect(url_for('team_hours'))
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
@@ -9,3 +9,6 @@ SQLAlchemy==1.4.23
|
|||||||
python-dotenv==0.19.0
|
python-dotenv==0.19.0
|
||||||
pyotp==2.6.0
|
pyotp==2.6.0
|
||||||
qrcode[pil]==7.3.1
|
qrcode[pil]==7.3.1
|
||||||
|
pandas==1.5.3
|
||||||
|
xlsxwriter==3.1.2
|
||||||
|
Flask-Mail==0.9.1
|
||||||
|
|||||||
@@ -708,29 +708,146 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
|
/* Export Page Styling */
|
||||||
.export-options {
|
.export-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-section {
|
.export-section {
|
||||||
background-color: #f9f9f9;
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
padding: 1.5rem;
|
padding: 2rem;
|
||||||
border-radius: 5px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-section h3 {
|
.export-section h3 {
|
||||||
color: #4CAF50;
|
color: #4CAF50;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #4CAF50;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-export-buttons {
|
.quick-export-buttons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-export-buttons .btn {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-export-buttons .btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button-container {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button-container .btn {
|
||||||
|
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 10px rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button-container .btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4);
|
||||||
|
background: linear-gradient(135deg, #45a049 0%, #4CAF50 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom date range form styling */
|
||||||
|
.export-section .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section .form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section .form-group input,
|
||||||
|
.export-section .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section .form-group input:focus,
|
||||||
|
.export-section .form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Team Hours Export Styling */
|
||||||
|
#export-buttons {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-buttons h4 {
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-buttons .quick-export-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-buttons .quick-export-buttons .btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-buttons .quick-export-buttons .btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 class="team-hours-container">
|
||||||
<div id="loading">Loading team data...</div>
|
<div id="loading">Loading team data...</div>
|
||||||
<div id="team-info" style="display: none;">
|
<div id="team-info" style="display: none;">
|
||||||
@@ -171,6 +180,7 @@
|
|||||||
|
|
||||||
// Populate detailed entries
|
// Populate detailed entries
|
||||||
document.getElementById('team-hours-table').style.display = 'block';
|
document.getElementById('team-hours-table').style.display = 'block';
|
||||||
|
document.getElementById('export-buttons').style.display = 'block';
|
||||||
document.getElementById('loading').style.display = 'none';
|
document.getElementById('loading').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +189,49 @@
|
|||||||
document.getElementById('error-message').style.display = 'block';
|
document.getElementById('error-message').style.display = 'block';
|
||||||
document.getElementById('loading').style.display = 'none';
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user