From b9aa7ad4137e76194721c8b5f9bf12ab9adebf32 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 12:54:48 +0200 Subject: [PATCH] Add Export to CSV or Excel. --- app.py | 110 ++++++++++++++++++++++++++++++++++++++++- requirements.txt | 4 +- static/css/style.css | 47 +++++++++++++++--- templates/export.html | 48 ++++++++++++++++++ templates/history.html | 3 ++ templates/layout.html | 1 + 6 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 templates/export.html diff --git a/app.py b/app.py index 9ec27d3..0a0f931 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,11 @@ -from flask import Flask, render_template, request, redirect, url_for, jsonify, flash +from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, send_file, Response from models import db, TimeEntry, WorkConfig -from datetime import datetime, time import os from sqlalchemy import func +import csv +import io +import pandas as pd +from datetime import datetime, time, timedelta app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db' @@ -292,5 +295,108 @@ def resume_entry(entry_id): 'total_break_duration': entry_to_resume.total_break_duration }) +@app.route('/export') +def export(): + return render_template('export.html', title='Export Data') + +@app.route('/download_export') +def download_export(): + # Get parameters + export_format = request.args.get('format', 'csv') + period = request.args.get('period') + + # Handle date range + if period: + # Quick export options + today = datetime.now().date() + if period == 'today': + start_date = today + end_date = today + elif period == 'week': + start_date = today - timedelta(days=today.weekday()) + end_date = today + elif period == 'month': + start_date = today.replace(day=1) + end_date = today + elif period == 'all': + # Get the earliest entry date + earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first() + start_date = earliest_entry.arrival_time.date() if earliest_entry else today + end_date = today + else: + # Custom date range + try: + start_date = datetime.strptime(request.args.get('start_date'), '%Y-%m-%d').date() + end_date = datetime.strptime(request.args.get('end_date'), '%Y-%m-%d').date() + except (ValueError, TypeError): + flash('Invalid date format. Please use YYYY-MM-DD format.') + return redirect(url_for('export')) + + # Query entries within the date range + start_datetime = datetime.combine(start_date, time.min) + end_datetime = datetime.combine(end_date, time.max) + + entries = TimeEntry.query.filter( + TimeEntry.arrival_time >= start_datetime, + TimeEntry.arrival_time <= end_datetime + ).order_by(TimeEntry.arrival_time).all() + + if not entries: + flash('No entries found for the selected date range.') + return redirect(url_for('export')) + + # Prepare 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)': 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')}" + + # Export based on format + if export_format == 'csv': + output = io.StringIO() + 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': + # Convert to DataFrame and export as Excel + 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" + ) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ade5b39..55af582 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,6 @@ itsdangerous==2.0.1 click==8.0.1 Flask-SQLAlchemy==2.5.1 SQLAlchemy==1.4.23 -python-dotenv==0.19.0 \ No newline at end of file +python-dotenv==0.19.0 +pandas +xlsxwriter \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 7bd9747..adb7890 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -69,19 +69,20 @@ main { } .form-group { - margin-bottom: 15px; + margin-bottom: 1rem; } .form-group label { display: block; - margin-bottom: 5px; + margin-bottom: 0.5rem; font-weight: bold; } .form-group input, -.form-group textarea { +.form-group textarea, +.form-group select { width: 100%; - padding: 8px; + padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; } @@ -96,12 +97,16 @@ button { } .btn { - padding: 8px 16px; + display: inline-block; + background-color: #4CAF50; + color: white; + padding: 0.5rem 1rem; + text-decoration: none; border: none; border-radius: 4px; cursor: pointer; - background-color: #4CAF50; - color: white; + font-size: 1rem; + text-align: center; } .btn:hover { @@ -413,4 +418,30 @@ input[type="time"]::-webkit-datetime-edit { color: #666; margin-top: 4px; font-style: italic; -} \ No newline at end of file +} + +.export-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin: 2rem 0; +} + +.export-section { + background-color: #f9f9f9; + padding: 1.5rem; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.export-section h3 { + color: #4CAF50; + margin-top: 0; + margin-bottom: 1rem; +} + +.quick-export-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} diff --git a/templates/export.html b/templates/export.html new file mode 100644 index 0000000..dfa22e0 --- /dev/null +++ b/templates/export.html @@ -0,0 +1,48 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Export Time Entries

+ +
+
+

Date Range

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/history.html b/templates/history.html index 20234e0..b80ce2e 100644 --- a/templates/history.html +++ b/templates/history.html @@ -3,6 +3,9 @@ {% block content %}

Complete Time Entry History

+
{% if entries %} diff --git a/templates/layout.html b/templates/layout.html index aee3363..e9422de 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -12,6 +12,7 @@