feat(reports): add Finished Tasks report with per-task hours and filters

- New route: GET /reports/tasks
  - Lists tasks with status "done" completed within the selected date range
  - Filters: project_id, user_id, start_date, end_date (defaults to last 30 days)
  - Aggregates hours per task from matching TimeEntry records
  - Shows assignee, completion date, total hours, and entry count per task
- UI: new template templates/reports/task_report.html with filters, summary cards, and results table
- Navigation: add “Finished Tasks Report” link under Project Reports on templates/reports/index.html
- Consistent styling and behavior with existing project/user reports
- No schema changes; login required
This commit is contained in:
Dries Peeters
2025-09-03 20:54:13 +02:00
parent a835ec3f34
commit 079695d1b8
3 changed files with 294 additions and 1 deletions
+89 -1
View File
@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Settings
from app.models import User, Project, TimeEntry, Settings, Task
from datetime import datetime, timedelta
import csv
import io
@@ -374,3 +374,91 @@ def summary_report():
week_hours=week_hours,
month_hours=month_hours,
project_stats=project_stats[:10]) # Top 10 projects
@reports_bp.route('/reports/tasks')
@login_required
def task_report():
"""Report of finished tasks within a project, including hours spent per task"""
project_id = request.args.get('project_id', type=int)
user_id = request.args.get('user_id', type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
# Filters data
projects = Project.query.order_by(Project.name).all()
users = User.query.filter_by(is_active=True).order_by(User.username).all()
# Default date range: last 30 days
if not start_date:
start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.utcnow().strftime('%Y-%m-%d')
try:
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1)
except ValueError:
flash('Invalid date format', 'error')
return render_template('reports/task_report.html', projects=projects, users=users)
# Base tasks query: finished tasks
tasks_query = Task.query.filter(Task.status == 'done')
if project_id:
tasks_query = tasks_query.filter(Task.project_id == project_id)
# Filter by completion window intersects [start_dt, end_dt]
tasks_query = tasks_query.filter(Task.completed_at.isnot(None))
tasks_query = tasks_query.filter(Task.completed_at >= start_dt, Task.completed_at <= end_dt)
# Optional: only tasks that have time entries by a specific user
if user_id:
tasks_query = tasks_query.join(TimeEntry, TimeEntry.task_id == Task.id).filter(TimeEntry.user_id == user_id)
tasks = tasks_query.order_by(Task.completed_at.desc()).all()
# Compute hours per task (sum of entry durations; respect user/project filters and date range)
task_rows = []
total_hours = 0.0
for task in tasks:
te_query = TimeEntry.query.filter(
TimeEntry.task_id == task.id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time <= end_dt
)
if project_id:
te_query = te_query.filter(TimeEntry.project_id == project_id)
if user_id:
te_query = te_query.filter(TimeEntry.user_id == user_id)
entries = te_query.all()
hours = sum(e.duration_hours for e in entries)
total_hours += hours
task_rows.append({
'task': task,
'project': task.project,
'assignee': task.assigned_user,
'completed_at': task.completed_at,
'hours': round(hours, 2),
'entries_count': len(entries),
})
summary = {
'tasks_count': len(task_rows),
'total_hours': round(total_hours, 2),
}
return render_template(
'reports/task_report.html',
projects=projects,
users=users,
tasks=task_rows,
summary=summary,
start_date=start_date,
end_date=end_date,
selected_project=project_id,
selected_user=user_id,
)
+3
View File
@@ -106,6 +106,9 @@
<a href="{{ url_for('reports.project_report') }}" class="btn btn-primary">
<i class="fas fa-chart-bar"></i> Project Report
</a>
<a href="{{ url_for('reports.task_report') }}" class="btn btn-outline-primary mt-2">
<i class="fas fa-tasks"></i> Finished Tasks Report
</a>
</div>
</div>
</div>
+202
View File
@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% block title %}Finished Tasks Report - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('reports.reports') }}">Reports</a></li>
<li class="breadcrumb-item active">Finished Tasks</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-tasks text-primary"></i> Finished Tasks Report
</h1>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fas fa-filter"></i> Filters
</h5>
</div>
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-3">
<label for="start_date" class="form-label">Start Date</label>
<input type="date" class="form-control" id="start_date" name="start_date"
value="{{ start_date or '' }}">
</div>
<div class="col-md-3">
<label for="end_date" class="form-label">End Date</label>
<input type="date" class="form-control" id="end_date" name="end_date"
value="{{ end_date or '' }}">
</div>
<div class="col-md-3">
<label for="project" class="form-label">Project</label>
<select class="form-select" id="project" name="project_id">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if selected_project|int == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="user" class="form-label">User</label>
<select class="form-select" id="user" name="user_id">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.id }}" {% if selected_user|int == user.id %}selected{% endif %}>
{{ user.display_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> Apply Filters
</button>
<a href="{{ url_for('reports.task_report') }}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> Clear
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Summary Statistics -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-tasks"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Finished Tasks</div>
<div class="summary-value">{{ summary.tasks_count }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Hours</div>
<div class="summary-value">{{ "%.2f"|format(summary.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Finished Tasks Table -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-list"></i> Finished Tasks ({{ tasks|length }})
</h5>
</div>
<div class="card-body p-0">
{% if tasks %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Task</th>
<th>Project</th>
<th>Assignee</th>
<th>Completed</th>
<th>Hours</th>
<th>Entries</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for row in tasks %}
<tr>
<td>
<div>
<strong>{{ row.task.name }}</strong>
{% if row.task.description %}
<br><small class="text-muted">{{ row.task.description[:60] }}{% if row.task.description|length > 60 %}...{% endif %}</small>
{% endif %}
</div>
</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=row.project.id) }}">
{{ row.project.name }}
</a>
</td>
<td>
{% if row.assignee %}
{{ row.assignee.display_name }}
{% else %}
<span class="text-muted">Unassigned</span>
{% endif %}
</td>
<td>
{% if row.completed_at %}
{{ row.completed_at.strftime('%Y-%m-%d') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td><strong>{{ "%.2f"|format(row.hours) }}h</strong></td>
<td>{{ row.entries_count }}</td>
<td>
<a href="{{ url_for('tasks.view_task', task_id=row.task.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-tasks fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Finished Tasks Found</h5>
<p class="text-muted">Try adjusting your filters.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}