mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 19:20:21 -06:00
This commit introduces several high-impact features to improve user experience and productivity: New Features: - Activity Logging: Comprehensive audit trail tracking user actions across the system with Activity model, including IP address and user agent tracking - Time Entry Templates: Reusable templates for frequently logged activities with usage tracking and quick-start functionality - Saved Filters: Save and reuse common search/filter combinations across different views (projects, tasks, reports) - User Preferences: Enhanced user settings including email notifications, timezone, date/time formats, week start day, and theme preferences - Excel Export: Generate formatted Excel exports for time entries and reports with styling and proper formatting - Email Notifications: Complete email system for task assignments, overdue invoices, comments, and weekly summaries with HTML templates - Scheduled Tasks: Background task scheduler for periodic operations Models Added: - Activity: Tracks all user actions with detailed context and metadata - TimeEntryTemplate: Stores reusable time entry configurations - SavedFilter: Manages user-saved filter configurations Routes Added: - user.py: User profile and settings management - saved_filters.py: CRUD operations for saved filters - time_entry_templates.py: Template management endpoints UI Enhancements: - Bulk actions widget component - Keyboard shortcuts help modal with advanced shortcuts - Save filter widget component - Email notification templates - User profile and settings pages - Saved filters management interface - Time entry templates interface Database Changes: - Migration 022: Creates activities and time_entry_templates tables - Adds user preference columns (notifications, timezone, date/time formats) - Proper indexes for query optimization Backend Updates: - Enhanced keyboard shortcuts system (commands.js, keyboard-shortcuts-advanced.js) - Updated projects, reports, and tasks routes with activity logging - Safe database commit utilities integration - Event tracking for analytics Dependencies: - Added openpyxl for Excel generation - Added Flask-Mail dependencies - Updated requirements.txt All new features include proper error handling, activity logging integration, and maintain existing functionality while adding new capabilities.
299 lines
10 KiB
Python
299 lines
10 KiB
Python
"""Excel export utilities for reports and data export"""
|
|
|
|
import io
|
|
from datetime import datetime
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
|
|
def create_time_entries_excel(entries, filename_prefix='timetracker_export'):
|
|
"""Create Excel file from time entries
|
|
|
|
Args:
|
|
entries: List of TimeEntry objects
|
|
filename_prefix: Prefix for the filename
|
|
|
|
Returns:
|
|
tuple: (BytesIO object with Excel file, filename)
|
|
"""
|
|
# Create workbook
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Time Entries"
|
|
|
|
# Define styles
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
|
border = Border(
|
|
left=Side(style='thin'),
|
|
right=Side(style='thin'),
|
|
top=Side(style='thin'),
|
|
bottom=Side(style='thin')
|
|
)
|
|
|
|
# Headers
|
|
headers = [
|
|
'ID', 'User', 'Project', 'Client', 'Task', 'Start Time', 'End Time',
|
|
'Duration (hours)', 'Duration (formatted)', 'Notes', 'Tags',
|
|
'Source', 'Billable', 'Created At'
|
|
]
|
|
|
|
# Write headers with styling
|
|
for col_num, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col_num, value=header)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
cell.alignment = header_alignment
|
|
cell.border = border
|
|
|
|
# Write data
|
|
for row_num, entry in enumerate(entries, 2):
|
|
data = [
|
|
entry.id,
|
|
entry.user.display_name if entry.user else 'Unknown',
|
|
entry.project.name if entry.project else 'N/A',
|
|
entry.project.client.name if (entry.project and entry.project.client) else 'N/A',
|
|
entry.task.name if entry.task else 'N/A',
|
|
entry.start_time.isoformat() if entry.start_time else '',
|
|
entry.end_time.isoformat() if entry.end_time else '',
|
|
entry.duration_hours if entry.end_time else 0,
|
|
entry.duration_formatted if entry.end_time else 'In Progress',
|
|
entry.notes or '',
|
|
entry.tags or '',
|
|
entry.source or 'manual',
|
|
'Yes' if entry.billable else 'No',
|
|
entry.created_at.isoformat() if entry.created_at else ''
|
|
]
|
|
|
|
for col_num, value in enumerate(data, 1):
|
|
cell = ws.cell(row=row_num, column=col_num, value=value)
|
|
cell.border = border
|
|
|
|
# Format duration column as number
|
|
if col_num == 8 and isinstance(value, (int, float)):
|
|
cell.number_format = '0.00'
|
|
|
|
# Auto-adjust column widths
|
|
for col in ws.columns:
|
|
max_length = 0
|
|
column = col[0].column_letter
|
|
for cell in col:
|
|
try:
|
|
if len(str(cell.value)) > max_length:
|
|
max_length = len(str(cell.value))
|
|
except:
|
|
pass
|
|
adjusted_width = min(max_length + 2, 50) # Cap at 50
|
|
ws.column_dimensions[column].width = adjusted_width
|
|
|
|
# Add summary at the bottom
|
|
last_row = len(entries) + 2
|
|
ws.cell(row=last_row + 1, column=1, value="Summary")
|
|
ws.cell(row=last_row + 1, column=1).font = Font(bold=True)
|
|
|
|
total_hours = sum(e.duration_hours for e in entries if e.end_time)
|
|
billable_hours = sum(e.duration_hours for e in entries if e.end_time and e.billable)
|
|
|
|
ws.cell(row=last_row + 2, column=1, value="Total Hours:")
|
|
ws.cell(row=last_row + 2, column=2, value=total_hours).number_format = '0.00'
|
|
ws.cell(row=last_row + 3, column=1, value="Billable Hours:")
|
|
ws.cell(row=last_row + 3, column=2, value=billable_hours).number_format = '0.00'
|
|
ws.cell(row=last_row + 4, column=1, value="Total Entries:")
|
|
ws.cell(row=last_row + 4, column=2, value=len(entries))
|
|
|
|
# Save to BytesIO
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
output.seek(0)
|
|
|
|
# Generate filename
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'{filename_prefix}_{timestamp}.xlsx'
|
|
|
|
return output, filename
|
|
|
|
|
|
def create_project_report_excel(projects_data, start_date, end_date):
|
|
"""Create Excel file for project report
|
|
|
|
Args:
|
|
projects_data: List of project dictionaries with hours and costs
|
|
start_date: Report start date
|
|
end_date: Report end date
|
|
|
|
Returns:
|
|
tuple: (BytesIO object with Excel file, filename)
|
|
"""
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Project Report"
|
|
|
|
# Styles
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
border = Border(
|
|
left=Side(style='thin'),
|
|
right=Side(style='thin'),
|
|
top=Side(style='thin'),
|
|
bottom=Side(style='thin')
|
|
)
|
|
|
|
# Add report header
|
|
ws.merge_cells('A1:H1')
|
|
title_cell = ws['A1']
|
|
title_cell.value = f"Project Report: {start_date} to {end_date}"
|
|
title_cell.font = Font(bold=True, size=14)
|
|
title_cell.alignment = Alignment(horizontal="center")
|
|
|
|
# Column headers
|
|
headers = [
|
|
'Project', 'Client', 'Total Hours', 'Billable Hours',
|
|
'Hourly Rate', 'Billable Amount', 'Total Costs', 'Total Value'
|
|
]
|
|
|
|
for col_num, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=3, column=col_num, value=header)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
cell.border = border
|
|
|
|
# Write project data
|
|
for row_num, project in enumerate(projects_data, 4):
|
|
data = [
|
|
project.get('name', ''),
|
|
project.get('client', ''),
|
|
project.get('total_hours', 0),
|
|
project.get('billable_hours', 0),
|
|
project.get('hourly_rate', 0),
|
|
project.get('billable_amount', 0),
|
|
project.get('total_costs', 0),
|
|
project.get('total_value', 0),
|
|
]
|
|
|
|
for col_num, value in enumerate(data, 1):
|
|
cell = ws.cell(row=row_num, column=col_num, value=value)
|
|
cell.border = border
|
|
|
|
# Format numbers
|
|
if col_num in [3, 4]: # Hours
|
|
cell.number_format = '0.00'
|
|
elif col_num in [5, 6, 7, 8]: # Money
|
|
cell.number_format = '#,##0.00'
|
|
|
|
# Auto-adjust columns
|
|
for col in ws.columns:
|
|
max_length = 0
|
|
column = col[0].column_letter
|
|
for cell in col:
|
|
try:
|
|
if len(str(cell.value)) > max_length:
|
|
max_length = len(str(cell.value))
|
|
except:
|
|
pass
|
|
adjusted_width = min(max_length + 2, 40)
|
|
ws.column_dimensions[column].width = adjusted_width
|
|
|
|
# Add totals
|
|
last_row = len(projects_data) + 4
|
|
ws.cell(row=last_row + 1, column=1, value="TOTALS").font = Font(bold=True)
|
|
|
|
total_hours = sum(p.get('total_hours', 0) for p in projects_data)
|
|
total_billable_hours = sum(p.get('billable_hours', 0) for p in projects_data)
|
|
total_amount = sum(p.get('billable_amount', 0) for p in projects_data)
|
|
total_costs = sum(p.get('total_costs', 0) for p in projects_data)
|
|
total_value = sum(p.get('total_value', 0) for p in projects_data)
|
|
|
|
ws.cell(row=last_row + 1, column=3, value=total_hours).number_format = '0.00'
|
|
ws.cell(row=last_row + 1, column=4, value=total_billable_hours).number_format = '0.00'
|
|
ws.cell(row=last_row + 1, column=6, value=total_amount).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 1, column=7, value=total_costs).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 1, column=8, value=total_value).number_format = '#,##0.00'
|
|
|
|
# Save to BytesIO
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
output.seek(0)
|
|
|
|
filename = f'project_report_{start_date}_to_{end_date}.xlsx'
|
|
return output, filename
|
|
|
|
|
|
def create_invoice_excel(invoice, items):
|
|
"""Create Excel file for a single invoice
|
|
|
|
Args:
|
|
invoice: Invoice object
|
|
items: List of InvoiceItem objects
|
|
|
|
Returns:
|
|
tuple: (BytesIO object with Excel file, filename)
|
|
"""
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Invoice"
|
|
|
|
# Invoice header
|
|
ws.merge_cells('A1:D1')
|
|
ws['A1'] = f"INVOICE {invoice.invoice_number}"
|
|
ws['A1'].font = Font(bold=True, size=16)
|
|
ws['A1'].alignment = Alignment(horizontal="center")
|
|
|
|
# Invoice details
|
|
ws['A3'] = "Client:"
|
|
ws['B3'] = invoice.client_name
|
|
ws['A4'] = "Issue Date:"
|
|
ws['B4'] = invoice.issue_date.strftime('%Y-%m-%d')
|
|
ws['A5'] = "Due Date:"
|
|
ws['B5'] = invoice.due_date.strftime('%Y-%m-%d')
|
|
ws['A6'] = "Status:"
|
|
ws['B6'] = invoice.status.upper()
|
|
|
|
# Items header
|
|
headers = ['Description', 'Quantity', 'Unit Price', 'Amount']
|
|
for col_num, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=8, column=col_num, value=header)
|
|
cell.font = Font(bold=True)
|
|
cell.fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
|
|
|
# Items
|
|
row = 9
|
|
for item in items:
|
|
ws.cell(row=row, column=1, value=item.description)
|
|
ws.cell(row=row, column=2, value=item.quantity).number_format = '0.00'
|
|
ws.cell(row=row, column=3, value=float(item.unit_price)).number_format = '#,##0.00'
|
|
ws.cell(row=row, column=4, value=float(item.amount)).number_format = '#,##0.00'
|
|
row += 1
|
|
|
|
# Totals
|
|
row += 1
|
|
ws.cell(row=row, column=3, value="Subtotal:").font = Font(bold=True)
|
|
ws.cell(row=row, column=4, value=float(invoice.subtotal)).number_format = '#,##0.00'
|
|
|
|
row += 1
|
|
ws.cell(row=row, column=3, value=f"Tax ({invoice.tax_rate}%):").font = Font(bold=True)
|
|
ws.cell(row=row, column=4, value=float(invoice.tax_amount)).number_format = '#,##0.00'
|
|
|
|
row += 1
|
|
ws.cell(row=row, column=3, value="TOTAL:").font = Font(bold=True, size=12)
|
|
total_cell = ws.cell(row=row, column=4, value=float(invoice.total_amount))
|
|
total_cell.number_format = '#,##0.00'
|
|
total_cell.font = Font(bold=True, size=12)
|
|
total_cell.fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
|
|
|
|
# Adjust columns
|
|
ws.column_dimensions['A'].width = 40
|
|
ws.column_dimensions['B'].width = 15
|
|
ws.column_dimensions['C'].width = 15
|
|
ws.column_dimensions['D'].width = 15
|
|
|
|
# Save
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
output.seek(0)
|
|
|
|
filename = f'invoice_{invoice.invoice_number}.xlsx'
|
|
return output, filename
|
|
|