mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-29 08:50:19 -05:00
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:
+89
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user