mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 15:49:44 -06:00
Merge pull request #189 from DRYTRIX/Feat-Project-Dashboard-Overview
feat: Add comprehensive project dashboard with analytics and visualiz…
This commit is contained in:
@@ -425,6 +425,185 @@ def view_project(project_id):
|
||||
resp.headers['Expires'] = '0'
|
||||
return resp
|
||||
|
||||
@projects_bp.route('/projects/<int:project_id>/dashboard')
|
||||
@login_required
|
||||
def project_dashboard(project_id):
|
||||
"""Project dashboard with comprehensive analytics and visualizations"""
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
# Track page view
|
||||
from app import track_page_view
|
||||
track_page_view("project_dashboard")
|
||||
|
||||
# Get time period filter (default to all time)
|
||||
from datetime import datetime, timedelta
|
||||
period = request.args.get('period', 'all')
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
if period == 'week':
|
||||
start_date = datetime.now() - timedelta(days=7)
|
||||
elif period == 'month':
|
||||
start_date = datetime.now() - timedelta(days=30)
|
||||
elif period == '3months':
|
||||
start_date = datetime.now() - timedelta(days=90)
|
||||
elif period == 'year':
|
||||
start_date = datetime.now() - timedelta(days=365)
|
||||
|
||||
# === Budget vs Actual ===
|
||||
budget_data = {
|
||||
'budget_amount': float(project.budget_amount) if project.budget_amount else 0,
|
||||
'consumed_amount': project.budget_consumed_amount,
|
||||
'remaining_amount': float(project.budget_amount or 0) - project.budget_consumed_amount,
|
||||
'percentage': round((project.budget_consumed_amount / float(project.budget_amount or 1)) * 100, 1) if project.budget_amount else 0,
|
||||
'threshold_exceeded': project.budget_threshold_exceeded,
|
||||
'estimated_hours': project.estimated_hours or 0,
|
||||
'actual_hours': project.actual_hours,
|
||||
'remaining_hours': (project.estimated_hours or 0) - project.actual_hours,
|
||||
'hours_percentage': round((project.actual_hours / (project.estimated_hours or 1)) * 100, 1) if project.estimated_hours else 0
|
||||
}
|
||||
|
||||
# === Task Statistics ===
|
||||
all_tasks = project.tasks.all()
|
||||
task_stats = {
|
||||
'total': len(all_tasks),
|
||||
'by_status': {},
|
||||
'completed': 0,
|
||||
'in_progress': 0,
|
||||
'todo': 0,
|
||||
'completion_rate': 0,
|
||||
'overdue': 0
|
||||
}
|
||||
|
||||
for task in all_tasks:
|
||||
status = task.status
|
||||
task_stats['by_status'][status] = task_stats['by_status'].get(status, 0) + 1
|
||||
if status == 'done':
|
||||
task_stats['completed'] += 1
|
||||
elif status == 'in_progress':
|
||||
task_stats['in_progress'] += 1
|
||||
elif status == 'todo':
|
||||
task_stats['todo'] += 1
|
||||
if task.is_overdue:
|
||||
task_stats['overdue'] += 1
|
||||
|
||||
if task_stats['total'] > 0:
|
||||
task_stats['completion_rate'] = round((task_stats['completed'] / task_stats['total']) * 100, 1)
|
||||
|
||||
# === Team Member Contributions ===
|
||||
user_totals = project.get_user_totals(start_date=start_date, end_date=end_date)
|
||||
|
||||
# Get time entries per user with additional stats
|
||||
from app.models import User
|
||||
team_contributions = []
|
||||
for user_data in user_totals:
|
||||
username = user_data['username']
|
||||
total_hours = user_data['total_hours']
|
||||
|
||||
# Get user object
|
||||
user = User.query.filter(
|
||||
db.or_(
|
||||
User.username == username,
|
||||
User.full_name == username
|
||||
)
|
||||
).first()
|
||||
|
||||
if user:
|
||||
# Count entries for this user
|
||||
entry_count = project.time_entries.filter(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.end_time.isnot(None)
|
||||
)
|
||||
if start_date:
|
||||
entry_count = entry_count.filter(TimeEntry.start_time >= start_date)
|
||||
if end_date:
|
||||
entry_count = entry_count.filter(TimeEntry.start_time <= end_date)
|
||||
entry_count = entry_count.count()
|
||||
|
||||
# Count tasks assigned to this user
|
||||
task_count = project.tasks.filter_by(assigned_to=user.id).count()
|
||||
|
||||
team_contributions.append({
|
||||
'username': username,
|
||||
'total_hours': total_hours,
|
||||
'entry_count': entry_count,
|
||||
'task_count': task_count,
|
||||
'percentage': round((total_hours / project.total_hours * 100), 1) if project.total_hours > 0 else 0
|
||||
})
|
||||
|
||||
# Sort by total hours descending
|
||||
team_contributions.sort(key=lambda x: x['total_hours'], reverse=True)
|
||||
|
||||
# === Recent Activity ===
|
||||
recent_activities = Activity.query.filter(
|
||||
Activity.entity_type.in_(['project', 'task', 'time_entry']),
|
||||
db.or_(
|
||||
Activity.entity_id == project_id,
|
||||
db.and_(
|
||||
Activity.entity_type == 'task',
|
||||
Activity.entity_id.in_([t.id for t in all_tasks])
|
||||
)
|
||||
)
|
||||
).order_by(Activity.created_at.desc()).limit(20).all()
|
||||
|
||||
# Filter to only project-related activities
|
||||
project_activities = []
|
||||
for activity in recent_activities:
|
||||
if activity.entity_type == 'project' and activity.entity_id == project_id:
|
||||
project_activities.append(activity)
|
||||
elif activity.entity_type == 'task':
|
||||
# Check if task belongs to this project
|
||||
task = Task.query.get(activity.entity_id)
|
||||
if task and task.project_id == project_id:
|
||||
project_activities.append(activity)
|
||||
|
||||
# === Time Tracking Timeline (last 30 days) ===
|
||||
from sqlalchemy import func
|
||||
timeline_data = []
|
||||
if start_date or period != 'all':
|
||||
timeline_start = start_date or (datetime.now() - timedelta(days=30))
|
||||
|
||||
# Group time entries by date
|
||||
daily_hours = db.session.query(
|
||||
func.date(TimeEntry.start_time).label('date'),
|
||||
func.sum(TimeEntry.duration_seconds).label('total_seconds')
|
||||
).filter(
|
||||
TimeEntry.project_id == project_id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= timeline_start
|
||||
).group_by(func.date(TimeEntry.start_time)).order_by('date').all()
|
||||
|
||||
timeline_data = [
|
||||
{
|
||||
'date': str(date),
|
||||
'hours': round(total_seconds / 3600, 2)
|
||||
}
|
||||
for date, total_seconds in daily_hours
|
||||
]
|
||||
|
||||
# === Cost Breakdown ===
|
||||
cost_data = {
|
||||
'total_costs': project.total_costs,
|
||||
'billable_costs': project.total_billable_costs,
|
||||
'by_category': {}
|
||||
}
|
||||
|
||||
if hasattr(ProjectCost, 'get_costs_by_category'):
|
||||
cost_breakdown = ProjectCost.get_costs_by_category(project_id, start_date, end_date)
|
||||
cost_data['by_category'] = cost_breakdown
|
||||
|
||||
return render_template(
|
||||
'projects/dashboard.html',
|
||||
project=project,
|
||||
budget_data=budget_data,
|
||||
task_stats=task_stats,
|
||||
team_contributions=team_contributions,
|
||||
recent_activities=project_activities[:10],
|
||||
timeline_data=timeline_data,
|
||||
cost_data=cost_data,
|
||||
period=period
|
||||
)
|
||||
|
||||
@projects_bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_or_permission_required('edit_projects')
|
||||
|
||||
501
app/templates/projects/dashboard.html
Normal file
501
app/templates/projects/dashboard.html
Normal file
@@ -0,0 +1,501 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">
|
||||
<i class="fas fa-arrow-left mr-1"></i>{{ _('Back to Project') }}
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<i class="fas fa-chart-line text-primary"></i>
|
||||
<span>{{ project.name }}</span>
|
||||
{% if project.code_display %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ project.code_display }}</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Project Dashboard & Analytics') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Time Period Filter -->
|
||||
<div class="mt-4 md:mt-0">
|
||||
<select id="periodFilter" class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-lg px-4 py-2" onchange="window.location.href='?period='+this.value">
|
||||
<option value="all" {% if period == 'all' %}selected{% endif %}>{{ _('All Time') }}</option>
|
||||
<option value="week" {% if period == 'week' %}selected{% endif %}>{{ _('Last 7 Days') }}</option>
|
||||
<option value="month" {% if period == 'month' %}selected{% endif %}>{{ _('Last 30 Days') }}</option>
|
||||
<option value="3months" {% if period == '3months' %}selected{% endif %}>{{ _('Last 3 Months') }}</option>
|
||||
<option value="year" {% if period == 'year' %}selected{% endif %}>{{ _('Last Year') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<!-- Total Hours -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Total Hours') }}</p>
|
||||
<p class="text-3xl font-bold mt-2">{{ "%.1f"|format(project.total_hours) }}</p>
|
||||
{% if budget_data.estimated_hours > 0 %}
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ _('of') }} {{ "%.0f"|format(budget_data.estimated_hours) }} {{ _('estimated') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-4xl text-blue-500">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Consumed -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Budget Used') }}</p>
|
||||
<p class="text-3xl font-bold mt-2">{{ "%.0f"|format(budget_data.consumed_amount) }}</p>
|
||||
{% if budget_data.budget_amount > 0 %}
|
||||
<p class="text-xs {% if budget_data.threshold_exceeded %}text-red-500{% else %}text-text-muted-light dark:text-text-muted-dark{% endif %} mt-1">
|
||||
{{ "%.1f"|format(budget_data.percentage) }}% {{ _('of budget') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-4xl {% if budget_data.threshold_exceeded %}text-red-500{% else %}text-green-500{% endif %}">
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Completion -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Tasks Complete') }}</p>
|
||||
<p class="text-3xl font-bold mt-2">{{ task_stats.completed }}/{{ task_stats.total }}</p>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ "%.1f"|format(task_stats.completion_rate) }}% {{ _('completion') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-4xl text-purple-500">
|
||||
<i class="fas fa-tasks"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Size -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Team Members') }}</p>
|
||||
<p class="text-3xl font-bold mt-2">{{ team_contributions|length }}</p>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('contributing') }}</p>
|
||||
</div>
|
||||
<div class="text-4xl text-orange-500">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Budget vs Actual Chart -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-chart-bar text-primary"></i>
|
||||
{{ _('Budget vs. Actual') }}
|
||||
</h2>
|
||||
{% if budget_data.budget_amount > 0 %}
|
||||
<div class="h-64">
|
||||
<canvas id="budgetChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Budget') }}</p>
|
||||
<p class="text-lg font-semibold">{{ "%.2f"|format(budget_data.budget_amount) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Remaining') }}</p>
|
||||
<p class="text-lg font-semibold {% if budget_data.remaining_amount < 0 %}text-red-500{% else %}text-green-500{% endif %}">
|
||||
{{ "%.2f"|format(budget_data.remaining_amount) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-info-circle text-4xl mb-4"></i>
|
||||
<p>{{ _('No budget set for this project') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Task Status Distribution -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-chart-pie text-primary"></i>
|
||||
{{ _('Task Status Distribution') }}
|
||||
</h2>
|
||||
{% if task_stats.total > 0 %}
|
||||
<div class="h-64">
|
||||
<canvas id="taskStatusChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
|
||||
{% for status, count in task_stats.by_status.items() %}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: {{ {'todo': '#6B7280', 'in_progress': '#3B82F6', 'review': '#F59E0B', 'done': '#10B981', 'cancelled': '#EF4444'}.get(status, '#6B7280') }}"></div>
|
||||
<span>{{ status.replace('_', ' ').title() }}: {{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-tasks text-4xl mb-4"></i>
|
||||
<p>{{ _('No tasks created yet') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Team Contributions Chart -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-user-friends text-primary"></i>
|
||||
{{ _('Team Member Contributions') }}
|
||||
</h2>
|
||||
{% if team_contributions %}
|
||||
<div class="h-64">
|
||||
<canvas id="teamChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
{% for member in team_contributions[:5] %}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
<span>{{ member.username }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ "%.1f"|format(member.total_hours) }}h</span>
|
||||
<span class="font-semibold">{{ "%.1f"|format(member.percentage) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-users text-4xl mb-4"></i>
|
||||
<p>{{ _('No time entries recorded yet') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Time Tracking Timeline -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-chart-line text-primary"></i>
|
||||
{{ _('Time Tracking Timeline') }}
|
||||
</h2>
|
||||
{% if timeline_data %}
|
||||
<div class="h-64">
|
||||
<canvas id="timelineChart"></canvas>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-calendar-alt text-4xl mb-4"></i>
|
||||
<p>{{ _('Select a time period to view timeline') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity and Team Details -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-history text-primary"></i>
|
||||
{{ _('Recent Activity') }}
|
||||
</h2>
|
||||
{% if recent_activities %}
|
||||
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||||
{% for activity in recent_activities %}
|
||||
<div class="flex items-start gap-3 p-3 bg-bg-light dark:bg-bg-dark rounded-lg">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<i class="{{ activity.get_icon() }}"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm">
|
||||
<span class="font-medium">{{ activity.user.display_name if activity.user.full_name else activity.user.username }}</span>
|
||||
{{ activity.description or (activity.action + ' ' + activity.entity_type) }}
|
||||
</p>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ activity.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center py-12 text-text-muted-light dark:text-text-muted-dark">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-history text-4xl mb-4"></i>
|
||||
<p>{{ _('No recent activity') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Team Member Details -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-users text-primary"></i>
|
||||
{{ _('Team Member Details') }}
|
||||
</h2>
|
||||
{% if team_contributions %}
|
||||
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||||
{% for member in team_contributions %}
|
||||
<div class="p-4 bg-bg-light dark:bg-bg-dark rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-medium">{{ member.username }}</h3>
|
||||
<span class="text-sm font-semibold text-primary">{{ "%.1f"|format(member.total_hours) }}h</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
<div>
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ member.entry_count }} {{ _('entries') }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-tasks mr-1"></i>
|
||||
{{ member.task_count }} {{ _('tasks') }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-percentage mr-1"></i>
|
||||
{{ "%.1f"|format(member.percentage) }}%
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<div class="mt-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="bg-primary h-2 rounded-full" style="width: {{ member.percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center py-12 text-text-muted-light dark:text-text-muted-dark">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-user-slash text-4xl mb-4"></i>
|
||||
<p>{{ _('No team members have logged time yet') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
{% if task_stats.overdue > 0 %}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-2xl"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-red-800 dark:text-red-200">{{ _('Attention Required') }}</h3>
|
||||
<p class="text-sm text-red-700 dark:text-red-300">
|
||||
{{ task_stats.overdue }} {{ _('task(s) are overdue') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Chart.js Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
{% autoescape false %}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme-aware colors
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const textColor = isDark ? '#E5E7EB' : '#1F2937';
|
||||
const gridColor = isDark ? '#374151' : '#E5E7EB';
|
||||
|
||||
// Common chart options
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: textColor },
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: textColor },
|
||||
grid: { color: gridColor }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{% if budget_data.budget_amount > 0 %}
|
||||
// Budget Chart
|
||||
const budgetCtx = document.getElementById('budgetChart').getContext('2d');
|
||||
new Chart(budgetCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [{{ _("Budget")|tojson }}, {{ _("Consumed")|tojson }}, {{ _("Remaining")|tojson }}],
|
||||
datasets: [{
|
||||
label: {{ _("Amount")|tojson }},
|
||||
data: [
|
||||
{{ budget_data.budget_amount }},
|
||||
{{ budget_data.consumed_amount }},
|
||||
{{ budget_data.remaining_amount }}
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.5)',
|
||||
{% if budget_data.threshold_exceeded %}'rgba(239, 68, 68, 0.5)'{% else %}'rgba(16, 185, 129, 0.5)'{% endif %},
|
||||
{% if budget_data.remaining_amount < 0 %}'rgba(239, 68, 68, 0.5)'{% else %}'rgba(34, 197, 94, 0.5)'{% endif %}
|
||||
],
|
||||
borderColor: [
|
||||
'rgb(59, 130, 246)',
|
||||
{% if budget_data.threshold_exceeded %}'rgb(239, 68, 68)'{% else %}'rgb(16, 185, 129)'{% endif %},
|
||||
{% if budget_data.remaining_amount < 0 %}'rgb(239, 68, 68)'{% else %}'rgb(34, 197, 94)'{% endif %}
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
...commonOptions.plugins,
|
||||
legend: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if task_stats.total > 0 %}
|
||||
// Task Status Chart
|
||||
const taskStatusCtx = document.getElementById('taskStatusChart').getContext('2d');
|
||||
new Chart(taskStatusCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [
|
||||
{% for status in task_stats.by_status.keys() %}
|
||||
{{ status.replace("_", " ").title()|tojson }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
data: [
|
||||
{% for count in task_stats.by_status.values() %}
|
||||
{{ count }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
backgroundColor: [
|
||||
{% for status in task_stats.by_status.keys() %}
|
||||
{{ {'todo': "'rgba(107, 114, 128, 0.8)'", 'in_progress': "'rgba(59, 130, 246, 0.8)'", 'review': "'rgba(245, 158, 11, 0.8)'", 'done': "'rgba(16, 185, 129, 0.8)'", 'cancelled': "'rgba(239, 68, 68, 0.8)'"}.get(status, "'rgba(107, 114, 128, 0.8)'") }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: textColor }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if team_contributions %}
|
||||
// Team Contributions Chart
|
||||
const teamCtx = document.getElementById('teamChart').getContext('2d');
|
||||
new Chart(teamCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [
|
||||
{% for member in team_contributions[:10] %}
|
||||
{{ member.username|tojson }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
label: {{ _("Hours")|tojson }},
|
||||
data: [
|
||||
{% for member in team_contributions[:10] %}
|
||||
{{ member.total_hours }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.5)',
|
||||
borderColor: 'rgb(139, 92, 246)',
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
...commonOptions.plugins,
|
||||
legend: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if timeline_data %}
|
||||
// Timeline Chart
|
||||
const timelineCtx = document.getElementById('timelineChart').getContext('2d');
|
||||
new Chart(timelineCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
{% for entry in timeline_data %}
|
||||
{{ entry.date|tojson }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
label: {{ _("Hours")|tojson }},
|
||||
data: [
|
||||
{% for entry in timeline_data %}
|
||||
{{ entry.hours }}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
...commonOptions.plugins,
|
||||
legend: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
{% endautoescape %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
</div>
|
||||
{% if current_user.is_admin or has_any_permission(['edit_projects', 'archive_projects']) %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('projects.project_dashboard', project_id=project.id) }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg mt-4 md:mt-0 flex items-center gap-2">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
{{ _('Dashboard') }}
|
||||
</a>
|
||||
{% if current_user.is_admin or has_permission('edit_projects') %}
|
||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Project') }}</a>
|
||||
{% endif %}
|
||||
|
||||
496
docs/features/PROJECT_DASHBOARD.md
Normal file
496
docs/features/PROJECT_DASHBOARD.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Project Dashboard Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Project Dashboard provides a comprehensive, visual overview of project performance, progress, and team contributions. It aggregates key metrics and presents them through interactive charts and visualizations, making it easy to track project health at a glance.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Key Metrics Overview
|
||||
- **Total Hours**: Real-time tracking of all logged hours on the project
|
||||
- **Budget Used**: Visual representation of consumed budget vs. allocated budget
|
||||
- **Task Completion**: Percentage of tasks completed with completion rate
|
||||
- **Team Size**: Number of team members actively contributing to the project
|
||||
|
||||
### 2. Budget vs. Actual Visualization
|
||||
- **Budget Tracking**: Compare budgeted amount against actual consumption
|
||||
- **Hours Comparison**: Estimated hours vs. actual hours worked
|
||||
- **Threshold Warnings**: Visual alerts when budget threshold is exceeded
|
||||
- **Remaining Budget**: Calculate and display remaining budget
|
||||
- **Interactive Bar Chart**: Visual representation using Chart.js
|
||||
|
||||
### 3. Task Status Distribution
|
||||
- **Status Breakdown**: Visual pie chart showing tasks by status (Todo, In Progress, Review, Done, Cancelled)
|
||||
- **Completion Rate**: Overall task completion percentage
|
||||
- **Overdue Tasks**: Count and highlight overdue tasks
|
||||
- **Color-coded Status**: Easy-to-understand visual indicators
|
||||
|
||||
### 4. Team Member Contributions
|
||||
- **Hours Breakdown**: Time contributed by each team member
|
||||
- **Percentage Distribution**: Visual representation of team effort distribution
|
||||
- **Entry Counts**: Number of time entries per team member
|
||||
- **Task Assignments**: Number of tasks assigned to each member
|
||||
- **Interactive Horizontal Bar Chart**: Compare team member contributions
|
||||
|
||||
### 5. Time Tracking Timeline
|
||||
- **Daily Hours Tracking**: Line chart showing hours logged over time
|
||||
- **Period Filtering**: View timeline for different time periods
|
||||
- **Trend Analysis**: Visualize work patterns and project velocity
|
||||
- **Interactive Line Chart**: Hover to see specific day details
|
||||
|
||||
### 6. Recent Activity Feed
|
||||
- **Activity Log**: Real-time feed of recent project activities
|
||||
- **User Actions**: Track who did what and when
|
||||
- **Entity-specific Actions**: Project, task, and time entry activities
|
||||
- **Timestamp Display**: Clear chronological ordering of events
|
||||
- **Icon Indicators**: Visual icons for different activity types
|
||||
|
||||
### 7. Time Period Filtering
|
||||
- **All Time**: View entire project history
|
||||
- **Last 7 Days**: Focus on recent week's activities
|
||||
- **Last 30 Days**: Monthly project view
|
||||
- **Last 3 Months**: Quarterly overview
|
||||
- **Last Year**: Annual performance review
|
||||
|
||||
## Dashboard Sections
|
||||
|
||||
### Top Navigation
|
||||
- **Back to Project**: Easy navigation back to project detail page
|
||||
- **Project Name & Code**: Clear project identification
|
||||
- **Period Filter**: Dropdown to select time period
|
||||
|
||||
### Metrics Cards (4 Cards)
|
||||
1. **Total Hours Card**
|
||||
- Large number display of total hours
|
||||
- Estimated hours comparison
|
||||
- Blue clock icon
|
||||
|
||||
2. **Budget Used Card**
|
||||
- Budget consumption amount
|
||||
- Percentage of total budget
|
||||
- Green/Red indicator based on threshold
|
||||
- Dollar sign icon
|
||||
|
||||
3. **Tasks Complete Card**
|
||||
- Completed vs. total tasks
|
||||
- Completion percentage
|
||||
- Purple tasks icon
|
||||
|
||||
4. **Team Members Card**
|
||||
- Number of contributing members
|
||||
- Orange users icon
|
||||
|
||||
### Visualization Charts
|
||||
|
||||
#### Budget vs. Actual Chart
|
||||
- **Type**: Bar Chart
|
||||
- **Data**: Budget, Consumed, Remaining
|
||||
- **Colors**: Blue for budget, Green/Red for consumed, Green/Red for remaining
|
||||
- **Shows**: When budget is exceeded with visual warnings
|
||||
|
||||
#### Task Status Distribution Chart
|
||||
- **Type**: Doughnut Chart
|
||||
- **Data**: Count of tasks by status
|
||||
- **Colors**:
|
||||
- Gray: Todo
|
||||
- Blue: In Progress
|
||||
- Orange: Review
|
||||
- Green: Done
|
||||
- Red: Cancelled
|
||||
- **Legend**: Bottom position with status labels
|
||||
|
||||
#### Team Contributions Chart
|
||||
- **Type**: Horizontal Bar Chart
|
||||
- **Data**: Hours per team member
|
||||
- **Colors**: Purple theme
|
||||
- **Shows**: Top 10 contributors
|
||||
|
||||
#### Time Tracking Timeline Chart
|
||||
- **Type**: Line Chart
|
||||
- **Data**: Daily hours over selected period
|
||||
- **Colors**: Blue with gradient fill
|
||||
- **Shows**: Work pattern and trends
|
||||
|
||||
### Team Member Details Section
|
||||
Shows detailed breakdown for each team member:
|
||||
- Name and total hours
|
||||
- Number of time entries
|
||||
- Number of assigned tasks
|
||||
- Percentage of total project time
|
||||
- Visual progress bar
|
||||
|
||||
### Recent Activity Section
|
||||
Displays up to 10 recent activities:
|
||||
- User avatar/icon
|
||||
- Action description
|
||||
- Timestamp
|
||||
- Color-coded by action type
|
||||
|
||||
## Navigation
|
||||
|
||||
### Accessing the Dashboard
|
||||
|
||||
1. **From Project View**
|
||||
- Navigate to any project
|
||||
- Click the purple "Dashboard" button in the header
|
||||
- Located next to the "Edit Project" button
|
||||
|
||||
2. **Direct URL**
|
||||
- `/projects/<project_id>/dashboard`
|
||||
|
||||
### Permissions
|
||||
- All authenticated users can view project dashboards
|
||||
- No special permissions required
|
||||
- Same access level as project view
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Scenario 1: Project Manager Monitoring Progress
|
||||
A project manager wants to check if the project is on track:
|
||||
1. Navigate to project dashboard
|
||||
2. Check key metrics cards for overview
|
||||
3. Review budget chart for financial health
|
||||
4. Check task completion chart for progress
|
||||
5. Review timeline to ensure consistent work pace
|
||||
6. Check team contributions for resource utilization
|
||||
|
||||
### Scenario 2: Client Reporting
|
||||
Preparing a client report:
|
||||
1. Open project dashboard
|
||||
2. Select "Last Month" from period filter
|
||||
3. Screenshot key metrics
|
||||
4. Export budget vs. actual chart
|
||||
5. Document team member contributions
|
||||
6. Include recent activity highlights
|
||||
|
||||
### Scenario 3: Sprint Planning
|
||||
Planning next sprint based on team capacity:
|
||||
1. View team contributions section
|
||||
2. Analyze each member's current workload
|
||||
3. Check timeline for work patterns
|
||||
4. Review task completion rates
|
||||
5. Allocate tasks based on contribution percentages
|
||||
|
||||
### Scenario 4: Budget Review
|
||||
Monitoring budget utilization:
|
||||
1. Check budget used percentage in metrics card
|
||||
2. Review budget vs. actual chart
|
||||
3. Calculate remaining budget
|
||||
4. Check if threshold is exceeded
|
||||
5. Review timeline to understand burn rate
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Route
|
||||
```python
|
||||
@projects_bp.route('/projects/<int:project_id>/dashboard')
|
||||
@login_required
|
||||
def project_dashboard(project_id):
|
||||
"""Project dashboard with comprehensive analytics and visualizations"""
|
||||
```
|
||||
|
||||
### Data Aggregation
|
||||
|
||||
#### Budget Data
|
||||
```python
|
||||
budget_data = {
|
||||
'budget_amount': float(project.budget_amount),
|
||||
'consumed_amount': project.budget_consumed_amount,
|
||||
'remaining_amount': budget_amount - consumed_amount,
|
||||
'percentage': (consumed_amount / budget_amount) * 100,
|
||||
'threshold_exceeded': project.budget_threshold_exceeded,
|
||||
'estimated_hours': project.estimated_hours,
|
||||
'actual_hours': project.actual_hours,
|
||||
'remaining_hours': estimated_hours - actual_hours,
|
||||
'hours_percentage': (actual_hours / estimated_hours) * 100
|
||||
}
|
||||
```
|
||||
|
||||
#### Task Statistics
|
||||
```python
|
||||
task_stats = {
|
||||
'total': count of all tasks,
|
||||
'by_status': dictionary of status counts,
|
||||
'completed': count of done tasks,
|
||||
'in_progress': count of in-progress tasks,
|
||||
'todo': count of todo tasks,
|
||||
'completion_rate': (completed / total) * 100,
|
||||
'overdue': count of overdue tasks
|
||||
}
|
||||
```
|
||||
|
||||
#### Team Contributions
|
||||
```python
|
||||
team_contributions = [
|
||||
{
|
||||
'username': member username,
|
||||
'total_hours': hours worked,
|
||||
'entry_count': number of entries,
|
||||
'task_count': assigned tasks,
|
||||
'percentage': (member_hours / project_hours) * 100
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Frontend Libraries
|
||||
|
||||
#### Chart.js 4.4.0
|
||||
Used for all visualizations:
|
||||
- Budget chart (Bar)
|
||||
- Task status (Doughnut)
|
||||
- Team contributions (Horizontal Bar)
|
||||
- Timeline (Line)
|
||||
|
||||
#### Tailwind CSS
|
||||
Responsive layout with dark mode support:
|
||||
- Grid system for responsive cards
|
||||
- Dark mode classes
|
||||
- Hover effects and transitions
|
||||
|
||||
### Database Queries
|
||||
|
||||
Dashboard performs optimized queries to fetch:
|
||||
1. Project details and budget info
|
||||
2. All tasks with status counts
|
||||
3. Time entries grouped by user
|
||||
4. Time entries grouped by date
|
||||
5. Recent activities filtered by project
|
||||
|
||||
### Performance Considerations
|
||||
- Data is aggregated on the backend
|
||||
- Charts render client-side with Chart.js
|
||||
- Caching recommended for large projects
|
||||
- Pagination considered for large activity lists
|
||||
|
||||
## API Response Format
|
||||
|
||||
While the dashboard is primarily a web view, the underlying data structure is:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": {
|
||||
"id": 1,
|
||||
"name": "Example Project",
|
||||
"code": "EXAM"
|
||||
},
|
||||
"budget_data": {
|
||||
"budget_amount": 5000.0,
|
||||
"consumed_amount": 3500.0,
|
||||
"remaining_amount": 1500.0,
|
||||
"percentage": 70.0,
|
||||
"threshold_exceeded": false
|
||||
},
|
||||
"task_stats": {
|
||||
"total": 20,
|
||||
"completed": 12,
|
||||
"in_progress": 5,
|
||||
"todo": 3,
|
||||
"completion_rate": 60.0,
|
||||
"overdue": 1
|
||||
},
|
||||
"team_contributions": [
|
||||
{
|
||||
"username": "john_doe",
|
||||
"total_hours": 45.5,
|
||||
"entry_count": 23,
|
||||
"task_count": 8,
|
||||
"percentage": 35.2
|
||||
}
|
||||
],
|
||||
"timeline_data": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"hours": 8.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Project Managers
|
||||
1. **Regular Monitoring**: Check dashboard daily or weekly
|
||||
2. **Budget Tracking**: Set up budget thresholds appropriately
|
||||
3. **Team Balance**: Monitor contribution distribution
|
||||
4. **Early Warnings**: Act on budget threshold warnings
|
||||
5. **Documentation**: Export charts for reports
|
||||
|
||||
### For Team Leads
|
||||
1. **Resource Planning**: Use contribution data for allocation
|
||||
2. **Velocity Tracking**: Monitor timeline patterns
|
||||
3. **Task Management**: Keep task statuses updated
|
||||
4. **Team Health**: Ensure balanced workload distribution
|
||||
|
||||
### For Developers
|
||||
1. **Data Updates**: Ensure time entries are logged consistently
|
||||
2. **Task Updates**: Keep task statuses current
|
||||
3. **Budget Awareness**: Check budget consumption regularly
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Dashboard Shows No Data
|
||||
**Issue**: Dashboard displays empty states for all charts
|
||||
**Solutions**:
|
||||
- Verify project has time entries
|
||||
- Check that tasks are created
|
||||
- Ensure budget is set (if using budget features)
|
||||
- Verify period filter isn't excluding all data
|
||||
|
||||
### Budget Chart Not Displaying
|
||||
**Issue**: Budget section shows "No budget set"
|
||||
**Solutions**:
|
||||
- Edit project and set budget_amount
|
||||
- Set hourly_rate if using hourly billing
|
||||
- Ensure budget_threshold_percent is configured
|
||||
|
||||
### Team Contributions Empty
|
||||
**Issue**: No team members shown
|
||||
**Solutions**:
|
||||
- Verify time entries exist for the project
|
||||
- Check that time entries have end_time (completed)
|
||||
- Ensure user assignments are correct
|
||||
|
||||
### Charts Not Rendering
|
||||
**Issue**: Canvas elements visible but no charts
|
||||
**Solutions**:
|
||||
- Check browser console for JavaScript errors
|
||||
- Verify Chart.js is loading correctly
|
||||
- Check browser compatibility (modern browsers required)
|
||||
- Clear browser cache
|
||||
|
||||
### Period Filter Not Working
|
||||
**Issue**: Selecting different periods shows same data
|
||||
**Solutions**:
|
||||
- Check URL parameter is changing (?period=week)
|
||||
- Verify date filtering logic in backend
|
||||
- Ensure time entry dates are within selected period
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
1. **Export Functionality**: Export dashboard as PDF report
|
||||
2. **Custom Date Ranges**: Allow custom start/end date selection
|
||||
3. **Milestone Tracking**: Visual milestone progress indicators
|
||||
4. **Cost Integration**: Include project costs in visualizations
|
||||
5. **Comparative Analysis**: Compare against similar projects
|
||||
6. **Predictive Analytics**: Project completion date estimation
|
||||
7. **Alerts & Notifications**: Configurable dashboard alerts
|
||||
8. **Widget Customization**: Allow users to customize dashboard layout
|
||||
9. **Mobile Optimization**: Enhanced mobile dashboard view
|
||||
10. **Real-time Updates**: WebSocket-based live data updates
|
||||
|
||||
### Enhancement Requests
|
||||
To request new dashboard features, please:
|
||||
1. Open an issue on GitHub
|
||||
2. Describe the use case
|
||||
3. Provide mockups if possible
|
||||
4. Tag with "feature-request" and "dashboard"
|
||||
|
||||
## Related Features
|
||||
|
||||
- [Project Management](PROJECT_COSTS_FEATURE.md)
|
||||
- [Task Management](../TASK_MANAGEMENT_README.md)
|
||||
- [Time Tracking](../QUICK_REFERENCE_GUIDE.md)
|
||||
- [Team Collaboration](FAVORITE_PROJECTS_FEATURE.md)
|
||||
- [Reporting](../QUICK_WINS_UI.md)
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
Location: `tests/test_project_dashboard.py`
|
||||
- Dashboard access and authentication
|
||||
- Data calculation accuracy
|
||||
- Period filtering
|
||||
- Edge cases (no data, missing budget)
|
||||
|
||||
### Smoke Tests
|
||||
Location: `tests/smoke_test_project_dashboard.py`
|
||||
- Dashboard loads successfully
|
||||
- All sections render
|
||||
- Charts display correctly
|
||||
- Navigation works
|
||||
- Period filter functions
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Run all dashboard tests
|
||||
pytest tests/test_project_dashboard.py -v
|
||||
|
||||
# Run smoke tests only
|
||||
pytest tests/smoke_test_project_dashboard.py -v
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/test_project_dashboard.py --cov=app.routes.projects
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Features
|
||||
- **Keyboard Navigation**: Full keyboard support
|
||||
- **Screen Reader Support**: Proper ARIA labels
|
||||
- **Color Contrast**: WCAG AA compliant
|
||||
- **Focus Indicators**: Clear focus states
|
||||
- **Alternative Text**: Descriptive alt text for visualizations
|
||||
|
||||
### Recommendations
|
||||
- Use screen reader to announce chart data
|
||||
- Provide data table alternatives for charts
|
||||
- Ensure all interactive elements are keyboard accessible
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- Chrome 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
### Required Features
|
||||
- ES6 JavaScript support
|
||||
- Canvas API for Chart.js
|
||||
- CSS Grid and Flexbox
|
||||
- Fetch API
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Dashboard requires login
|
||||
- Project access follows existing permissions
|
||||
- No special dashboard permissions
|
||||
|
||||
### Data Privacy
|
||||
- Only project team members see dashboard
|
||||
- Activity feed respects privacy settings
|
||||
- No external data sharing
|
||||
|
||||
### Performance
|
||||
- Query optimization for large datasets
|
||||
- Client-side rendering for charts
|
||||
- Caching strategies for repeated access
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check [Troubleshooting](#troubleshooting) section
|
||||
- Review [GitHub Issues](https://github.com/yourusername/TimeTracker/issues)
|
||||
- Contact project maintainers
|
||||
- Review test files for examples
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.0 (2024-10)
|
||||
- Initial release of Project Dashboard
|
||||
- Budget vs. Actual visualization
|
||||
- Task status distribution chart
|
||||
- Team member contributions
|
||||
- Time tracking timeline
|
||||
- Recent activity feed
|
||||
- Period filtering
|
||||
- Responsive design with dark mode
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: October 2024
|
||||
**Feature Status**: ✅ Active
|
||||
**Requires**: TimeTracker v1.0+
|
||||
|
||||
359
tests/smoke_test_project_dashboard.py
Normal file
359
tests/smoke_test_project_dashboard.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Smoke tests for Project Dashboard feature.
|
||||
Quick validation tests to ensure the dashboard is working at a basic level.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, date
|
||||
from decimal import Decimal
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, Client, Task, TimeEntry, Activity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create and configure a test application instance."""
|
||||
app = create_app({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'WTF_CSRF_ENABLED': False
|
||||
})
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create a test Flask client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(app):
|
||||
"""Create a test user."""
|
||||
with app.app_context():
|
||||
user = User(username='testuser', role='user', email='test@example.com')
|
||||
user.set_password('testpass123')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
yield user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_obj(app):
|
||||
"""Create a test client."""
|
||||
with app.app_context():
|
||||
client = Client(name='Test Client', description='A test client')
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_with_data(app, test_client_obj, user):
|
||||
"""Create a project with some sample data."""
|
||||
with app.app_context():
|
||||
# Create project
|
||||
project = Project(
|
||||
name='Dashboard Test Project',
|
||||
client_id=test_client_obj.id,
|
||||
description='A test project',
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00'),
|
||||
budget_amount=Decimal('5000.00')
|
||||
)
|
||||
project.estimated_hours = 50.0
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
# Add some tasks
|
||||
task1 = Task(
|
||||
project_id=project.id,
|
||||
name='Test Task 1',
|
||||
status='todo',
|
||||
priority='high',
|
||||
created_by=user.id,
|
||||
assigned_to=user.id
|
||||
)
|
||||
task2 = Task(
|
||||
project_id=project.id,
|
||||
name='Test Task 2',
|
||||
status='done',
|
||||
priority='medium',
|
||||
created_by=user.id,
|
||||
assigned_to=user.id,
|
||||
completed_at=datetime.now()
|
||||
)
|
||||
db.session.add_all([task1, task2])
|
||||
|
||||
# Add time entries
|
||||
now = datetime.now()
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
task_id=task1.id,
|
||||
start_time=now - timedelta(hours=4),
|
||||
end_time=now,
|
||||
duration_seconds=14400, # 4 hours
|
||||
billable=True
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
# Add activity
|
||||
Activity.log(
|
||||
user_id=user.id,
|
||||
action='created',
|
||||
entity_type='project',
|
||||
entity_id=project.id,
|
||||
entity_name=project.name,
|
||||
description=f'Created project "{project.name}"'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
yield project
|
||||
|
||||
|
||||
def login(client, username='testuser', password='testpass123'):
|
||||
"""Helper function to log in a user."""
|
||||
return client.post('/auth/login', data={
|
||||
'username': username,
|
||||
'password': password
|
||||
}, follow_redirects=True)
|
||||
|
||||
|
||||
class TestProjectDashboardSmoke:
|
||||
"""Smoke tests for project dashboard functionality."""
|
||||
|
||||
def test_dashboard_page_loads(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard page loads without errors"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200, "Dashboard page should load successfully"
|
||||
assert b'Dashboard' in response.data or b'dashboard' in response.data.lower()
|
||||
|
||||
def test_dashboard_requires_authentication(self, client, project_with_data):
|
||||
"""Smoke test: Dashboard requires user to be logged in"""
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 302, "Should redirect to login"
|
||||
|
||||
def test_dashboard_shows_project_name(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard displays the project name"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert project_with_data.name.encode() in response.data
|
||||
|
||||
def test_dashboard_shows_key_metrics(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard displays key metrics cards"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check for key metrics
|
||||
assert b'Total Hours' in response.data or b'total hours' in response.data.lower()
|
||||
assert b'Budget' in response.data or b'budget' in response.data.lower()
|
||||
assert b'Tasks' in response.data or b'tasks' in response.data.lower()
|
||||
assert b'Team' in response.data or b'team' in response.data.lower()
|
||||
|
||||
def test_dashboard_shows_charts(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard includes chart canvases"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check for chart elements
|
||||
assert b'canvas' in response.data or b'Chart' in response.data
|
||||
|
||||
def test_dashboard_shows_budget_visualization(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard shows budget vs actual section"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'Budget vs. Actual' in response.data or b'Budget' in response.data
|
||||
|
||||
def test_dashboard_shows_task_statistics(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard shows task statistics"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'Task' in response.data
|
||||
# Should show task counts
|
||||
assert b'2' in response.data # We created 2 tasks
|
||||
|
||||
def test_dashboard_shows_team_contributions(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard shows team member contributions"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'Team Member' in response.data or b'Contributions' in response.data
|
||||
|
||||
def test_dashboard_shows_recent_activity(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard shows recent activity section"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'Recent Activity' in response.data or b'Activity' in response.data
|
||||
|
||||
def test_dashboard_has_back_link(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard has link back to project view"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'Back to Project' in response.data
|
||||
assert f'/projects/{project_with_data.id}'.encode() in response.data
|
||||
|
||||
def test_dashboard_period_filter_works(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard period filter functions"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
# Test each period filter
|
||||
for period in ['all', 'week', 'month', '3months', 'year']:
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard?period={period}')
|
||||
assert response.status_code == 200, f"Dashboard should load with period={period}"
|
||||
|
||||
def test_dashboard_period_filter_dropdown(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard has period filter dropdown"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'periodFilter' in response.data or b'All Time' in response.data
|
||||
|
||||
def test_project_view_has_dashboard_link(self, client, user, project_with_data):
|
||||
"""Smoke test: Project view page has link to dashboard"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}')
|
||||
assert response.status_code == 200
|
||||
assert b'Dashboard' in response.data
|
||||
assert f'/projects/{project_with_data.id}/dashboard'.encode() in response.data
|
||||
|
||||
def test_dashboard_handles_no_data_gracefully(self, client, user, test_client_obj):
|
||||
"""Smoke test: Dashboard handles project with no data"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
# Create empty project
|
||||
empty_project = Project(
|
||||
name='Empty Project',
|
||||
client_id=test_client_obj.id
|
||||
)
|
||||
db.session.add(empty_project)
|
||||
db.session.commit()
|
||||
|
||||
response = client.get(f'/projects/{empty_project.id}/dashboard')
|
||||
assert response.status_code == 200, "Dashboard should load even with no data"
|
||||
|
||||
def test_dashboard_shows_hours_worked(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard displays hours worked"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
# Should show 4.0 hours (from our test data)
|
||||
assert b'4.0' in response.data
|
||||
|
||||
def test_dashboard_shows_budget_amount(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard displays budget amount"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
# Should show budget of 5000
|
||||
assert b'5000' in response.data
|
||||
|
||||
def test_dashboard_calculates_completion_rate(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard calculates task completion rate"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
# With 1 done out of 2 tasks, should show 50%
|
||||
assert b'50' in response.data or b'completion' in response.data.lower()
|
||||
|
||||
def test_dashboard_shows_team_member_name(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard shows team member username"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert user.username.encode() in response.data
|
||||
|
||||
def test_dashboard_handles_invalid_period(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard handles invalid period parameter gracefully"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard?period=invalid')
|
||||
assert response.status_code == 200, "Should still load with invalid period"
|
||||
|
||||
def test_dashboard_404_for_nonexistent_project(self, client, user):
|
||||
"""Smoke test: Dashboard returns 404 for non-existent project"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get('/projects/99999/dashboard')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_dashboard_chart_js_loaded(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard loads Chart.js library"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'chart.js' in response.data.lower() or b'Chart' in response.data
|
||||
|
||||
def test_dashboard_responsive_layout(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard uses responsive grid layout"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
# Check for responsive grid classes
|
||||
assert b'grid' in response.data or b'lg:grid-cols' in response.data
|
||||
|
||||
def test_dashboard_dark_mode_compatible(self, client, user, project_with_data):
|
||||
"""Smoke test: Dashboard has dark mode styling"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
|
||||
response = client.get(f'/projects/{project_with_data.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
# Check for dark mode classes
|
||||
assert b'dark:' in response.data
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
505
tests/test_project_dashboard.py
Normal file
505
tests/test_project_dashboard.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Comprehensive tests for Project Dashboard functionality.
|
||||
|
||||
This module tests:
|
||||
- Project dashboard route and access
|
||||
- Budget vs actual data calculations
|
||||
- Task statistics aggregation
|
||||
- Team member contributions
|
||||
- Recent activity tracking
|
||||
- Timeline data generation
|
||||
- Period filtering
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, Client, Task, TimeEntry, Activity, ProjectCost
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create and configure a test application instance."""
|
||||
app = create_app({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'WTF_CSRF_ENABLED': False
|
||||
})
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_fixture(app):
|
||||
"""Create a test Flask client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(app):
|
||||
"""Create a test user."""
|
||||
with app.app_context():
|
||||
user = User(username='testuser', role='user', email='test@example.com')
|
||||
user.set_password('testpass123')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user2(app):
|
||||
"""Create a second test user."""
|
||||
with app.app_context():
|
||||
user = User(username='testuser2', role='user', email='test2@example.com', full_name='Test User 2')
|
||||
user.set_password('testpass123')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_admin(app):
|
||||
"""Create a test admin user."""
|
||||
with app.app_context():
|
||||
admin = User(username='admin', role='admin', email='admin@example.com')
|
||||
admin.set_password('adminpass123')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
return admin.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(app):
|
||||
"""Create a test client."""
|
||||
with app.app_context():
|
||||
client = Client(name='Test Client', description='A test client')
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
return client.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(app, test_client):
|
||||
"""Create a test project with budget."""
|
||||
with app.app_context():
|
||||
project = Project(
|
||||
name='Dashboard Test Project',
|
||||
client_id=test_client,
|
||||
description='A test project for dashboard',
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00'),
|
||||
budget_amount=Decimal('5000.00')
|
||||
)
|
||||
project.estimated_hours = 50.0
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project_with_data(app, test_project, test_user, test_user2):
|
||||
"""Create a test project with tasks and time entries."""
|
||||
with app.app_context():
|
||||
project = db.session.get(Project, test_project)
|
||||
|
||||
# Create tasks with different statuses
|
||||
task1 = Task(
|
||||
project_id=project.id,
|
||||
name='Task 1 - Todo',
|
||||
status='todo',
|
||||
priority='high',
|
||||
created_by=test_user,
|
||||
assigned_to=test_user
|
||||
)
|
||||
task2 = Task(
|
||||
project_id=project.id,
|
||||
name='Task 2 - In Progress',
|
||||
status='in_progress',
|
||||
priority='medium',
|
||||
created_by=test_user,
|
||||
assigned_to=test_user2
|
||||
)
|
||||
task3 = Task(
|
||||
project_id=project.id,
|
||||
name='Task 3 - Done',
|
||||
status='done',
|
||||
priority='low',
|
||||
created_by=test_user,
|
||||
assigned_to=test_user,
|
||||
completed_at=datetime.now()
|
||||
)
|
||||
task4 = Task(
|
||||
project_id=project.id,
|
||||
name='Task 4 - Overdue',
|
||||
status='todo',
|
||||
priority='urgent',
|
||||
due_date=date.today() - timedelta(days=5),
|
||||
created_by=test_user,
|
||||
assigned_to=test_user
|
||||
)
|
||||
|
||||
db.session.add_all([task1, task2, task3, task4])
|
||||
|
||||
# Create time entries for both users
|
||||
now = datetime.now()
|
||||
|
||||
# User 1: 10 hours across 3 entries
|
||||
entry1 = TimeEntry(
|
||||
user_id=test_user,
|
||||
project_id=project.id,
|
||||
task_id=task1.id,
|
||||
start_time=now - timedelta(days=2, hours=4),
|
||||
end_time=now - timedelta(days=2),
|
||||
duration_seconds=14400, # 4 hours
|
||||
billable=True
|
||||
)
|
||||
entry2 = TimeEntry(
|
||||
user_id=test_user,
|
||||
project_id=project.id,
|
||||
task_id=task3.id,
|
||||
start_time=now - timedelta(days=1, hours=3),
|
||||
end_time=now - timedelta(days=1),
|
||||
duration_seconds=10800, # 3 hours
|
||||
billable=True
|
||||
)
|
||||
entry3 = TimeEntry(
|
||||
user_id=test_user,
|
||||
project_id=project.id,
|
||||
start_time=now - timedelta(hours=3),
|
||||
end_time=now,
|
||||
duration_seconds=10800, # 3 hours
|
||||
billable=True
|
||||
)
|
||||
|
||||
# User 2: 5 hours across 2 entries
|
||||
entry4 = TimeEntry(
|
||||
user_id=test_user2,
|
||||
project_id=project.id,
|
||||
task_id=task2.id,
|
||||
start_time=now - timedelta(days=1, hours=3),
|
||||
end_time=now - timedelta(days=1),
|
||||
duration_seconds=10800, # 3 hours
|
||||
billable=True
|
||||
)
|
||||
entry5 = TimeEntry(
|
||||
user_id=test_user2,
|
||||
project_id=project.id,
|
||||
start_time=now - timedelta(hours=2),
|
||||
end_time=now,
|
||||
duration_seconds=7200, # 2 hours
|
||||
billable=True
|
||||
)
|
||||
|
||||
db.session.add_all([entry1, entry2, entry3, entry4, entry5])
|
||||
|
||||
# Create some activities
|
||||
Activity.log(
|
||||
user_id=test_user,
|
||||
action='created',
|
||||
entity_type='project',
|
||||
entity_id=project.id,
|
||||
entity_name=project.name,
|
||||
description=f'Created project "{project.name}"'
|
||||
)
|
||||
|
||||
Activity.log(
|
||||
user_id=test_user,
|
||||
action='created',
|
||||
entity_type='task',
|
||||
entity_id=task1.id,
|
||||
entity_name=task1.name,
|
||||
description=f'Created task "{task1.name}"'
|
||||
)
|
||||
|
||||
Activity.log(
|
||||
user_id=test_user,
|
||||
action='completed',
|
||||
entity_type='task',
|
||||
entity_id=task3.id,
|
||||
entity_name=task3.name,
|
||||
description=f'Completed task "{task3.name}"'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return project.id
|
||||
|
||||
|
||||
def login(client, username='testuser', password='testpass123'):
|
||||
"""Helper function to log in a user."""
|
||||
return client.post('/auth/login', data={
|
||||
'username': username,
|
||||
'password': password
|
||||
}, follow_redirects=True)
|
||||
|
||||
|
||||
class TestProjectDashboardAccess:
|
||||
"""Tests for dashboard access and permissions."""
|
||||
|
||||
def test_dashboard_requires_login(self, app, client_fixture, test_project):
|
||||
"""Test that dashboard requires authentication."""
|
||||
with app.app_context():
|
||||
response = client_fixture.get(f'/projects/{test_project}/dashboard')
|
||||
assert response.status_code == 302 # Redirect to login
|
||||
|
||||
def test_dashboard_accessible_when_logged_in(self, app, client_fixture, test_project, test_user):
|
||||
"""Test that dashboard is accessible when logged in."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_dashboard_404_for_nonexistent_project(self, app, client_fixture, test_user):
|
||||
"""Test that dashboard returns 404 for non-existent project."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get('/projects/99999/dashboard')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestDashboardData:
|
||||
"""Tests for dashboard data calculations and aggregations."""
|
||||
|
||||
def test_budget_data_calculation(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test that budget data is calculated correctly."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that budget-related content is in response
|
||||
assert b'Budget vs. Actual' in response.data
|
||||
|
||||
# Get project and verify calculations
|
||||
project = db.session.get(Project, test_project_with_data)
|
||||
assert project.budget_amount is not None
|
||||
assert project.total_hours > 0
|
||||
|
||||
def test_task_statistics(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test that task statistics are calculated correctly."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify task statistics in response
|
||||
assert b'Task Status Distribution' in response.data
|
||||
assert b'Tasks Complete' in response.data
|
||||
|
||||
# Verify task counts
|
||||
project = db.session.get(Project, test_project_with_data)
|
||||
tasks = project.tasks.all()
|
||||
assert len(tasks) == 4 # We created 4 tasks
|
||||
|
||||
# Check task statuses
|
||||
statuses = [task.status for task in tasks]
|
||||
assert 'todo' in statuses
|
||||
assert 'in_progress' in statuses
|
||||
assert 'done' in statuses
|
||||
|
||||
def test_team_contributions(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test that team member contributions are calculated correctly."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify team contributions section exists
|
||||
assert b'Team Member Contributions' in response.data
|
||||
assert b'Team Members' in response.data
|
||||
|
||||
# Get project and verify user totals
|
||||
project = db.session.get(Project, test_project_with_data)
|
||||
user_totals = project.get_user_totals()
|
||||
assert len(user_totals) == 2 # Two users contributed
|
||||
|
||||
# Verify hours distribution
|
||||
total_hours = sum([ut['total_hours'] for ut in user_totals])
|
||||
assert total_hours == 15.0 # 10 + 5 hours
|
||||
|
||||
def test_recent_activity(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test that recent activity is displayed correctly."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify recent activity section exists
|
||||
assert b'Recent Activity' in response.data
|
||||
|
||||
# Verify activities exist in database
|
||||
project = db.session.get(Project, test_project_with_data)
|
||||
activities = Activity.query.filter_by(
|
||||
entity_type='project',
|
||||
entity_id=project.id
|
||||
).all()
|
||||
assert len(activities) >= 1
|
||||
|
||||
def test_overdue_tasks_warning(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test that overdue tasks trigger a warning."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify overdue warning is shown
|
||||
assert b'Attention Required' in response.data or b'overdue' in response.data.lower()
|
||||
|
||||
|
||||
class TestDashboardPeriodFiltering:
|
||||
"""Tests for dashboard time period filtering."""
|
||||
|
||||
def test_period_filter_all_time(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test dashboard with 'all time' filter."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard?period=all')
|
||||
assert response.status_code == 200
|
||||
assert b'All Time' in response.data
|
||||
|
||||
def test_period_filter_week(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test dashboard with 'last week' filter."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard?period=week')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_period_filter_month(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test dashboard with 'last month' filter."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard?period=month')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_period_filter_three_months(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test dashboard with '3 months' filter."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard?period=3months')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_period_filter_year(self, app, client_fixture, test_project_with_data, test_user):
|
||||
"""Test dashboard with 'year' filter."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project_with_data}/dashboard?period=year')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestDashboardWithNoData:
|
||||
"""Tests for dashboard behavior with minimal or no data."""
|
||||
|
||||
def test_dashboard_with_no_budget(self, app, client_fixture, test_client, test_user):
|
||||
"""Test dashboard for project without budget."""
|
||||
with app.app_context():
|
||||
# Create project without budget
|
||||
project = Project(
|
||||
name='No Budget Project',
|
||||
client_id=test_client,
|
||||
billable=False
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{project.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'No budget set' in response.data
|
||||
|
||||
def test_dashboard_with_no_tasks(self, app, client_fixture, test_project, test_user):
|
||||
"""Test dashboard for project without tasks."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'No tasks' in response.data or b'0/0' in response.data
|
||||
|
||||
def test_dashboard_with_no_time_entries(self, app, client_fixture, test_project, test_user):
|
||||
"""Test dashboard for project without time entries."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project}/dashboard')
|
||||
assert response.status_code == 200
|
||||
# Should show zero hours
|
||||
project = db.session.get(Project, test_project)
|
||||
assert project.total_hours == 0
|
||||
|
||||
def test_dashboard_with_no_activity(self, app, client_fixture, test_project, test_user):
|
||||
"""Test dashboard for project without activity."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'No recent activity' in response.data or b'Recent Activity' in response.data
|
||||
|
||||
|
||||
class TestDashboardBudgetThreshold:
|
||||
"""Tests for budget threshold warnings."""
|
||||
|
||||
def test_budget_threshold_exceeded_warning(self, app, client_fixture, test_client, test_user):
|
||||
"""Test that budget threshold exceeded triggers warning."""
|
||||
with app.app_context():
|
||||
# Create project with budget
|
||||
project = Project(
|
||||
name='Budget Test Project',
|
||||
client_id=test_client,
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00'),
|
||||
budget_amount=Decimal('500.00'), # Small budget
|
||||
budget_threshold_percent=80
|
||||
)
|
||||
project.estimated_hours = 10.0
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
# Add time entries to exceed threshold
|
||||
now = datetime.now()
|
||||
entry = TimeEntry(
|
||||
user_id=test_user,
|
||||
project_id=project.id,
|
||||
start_time=now - timedelta(hours=6),
|
||||
end_time=now,
|
||||
duration_seconds=21600, # 6 hours = $600, exceeds $500 budget
|
||||
billable=True
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{project.id}/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that budget warning appears
|
||||
project = db.session.get(Project, project.id)
|
||||
assert project.budget_threshold_exceeded
|
||||
|
||||
|
||||
class TestDashboardNavigation:
|
||||
"""Tests for dashboard navigation and links."""
|
||||
|
||||
def test_back_to_project_link(self, app, client_fixture, test_project, test_user):
|
||||
"""Test that dashboard has link back to project view."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project}/dashboard')
|
||||
assert response.status_code == 200
|
||||
assert b'Back to Project' in response.data
|
||||
assert f'/projects/{test_project}'.encode() in response.data
|
||||
|
||||
def test_dashboard_link_in_project_view(self, app, client_fixture, test_project, test_user):
|
||||
"""Test that project view has link to dashboard."""
|
||||
with app.app_context():
|
||||
login(client_fixture)
|
||||
response = client_fixture.get(f'/projects/{test_project}')
|
||||
assert response.status_code == 200
|
||||
assert b'Dashboard' in response.data
|
||||
assert f'/projects/{test_project}/dashboard'.encode() in response.data
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
Reference in New Issue
Block a user