diff --git a/app/routes/reports.py b/app/routes/reports.py index ef503fe..b805390 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -6,7 +6,7 @@ from app.utils.time_utils import now_eastern from flask import (Blueprint, render_template, request, Response, stream_with_context, abort) from flask_login import login_required, current_user -from sqlalchemy import func +from sqlalchemy import func, literal_column from sqlalchemy.orm import joinedload from app import db from app.models.inspection import Inspection, InspectionTemplate @@ -22,6 +22,16 @@ bp = Blueprint('reports', __name__, url_prefix='/reports') logger = logging.getLogger(__name__) +def _fmt_minutes(mins): + """Format an integer minute value as '45m' or '1h 30m'. Returns None if input is None.""" + if mins is None: + return None + mins = int(round(float(mins))) + if mins >= 60: + return f'{mins // 60}h {mins % 60}m' + return f'{mins}m' + + def _date_range(): """Parse ?start= and ?end= query params; default to last 30 days.""" end_default = now_eastern() @@ -589,10 +599,18 @@ def inspector_performance(): Inspection.inspector_id, func.count(Inspection.id).label('completed'), func.avg(Inspection.overall_score).label('avg_score'), + func.avg( + func.timestampdiff( + literal_column('MINUTE'), + Inspection.inspection_date, + Inspection.completed_at, + ) + ).label('avg_minutes'), ).filter( Inspection.inspection_date >= start, Inspection.inspection_date <= end, Inspection.status == 'completed', + Inspection.completed_at.isnot(None), ).group_by(Inspection.inspector_id).all() issue_rows = db.session.query( @@ -614,7 +632,14 @@ def inspector_performance(): ).group_by(Inspection.inspector_id).all() total_map = {r.inspector_id: {'total': r.total, 'facilities': r.facilities} for r in total_rows} - completed_map = {r.inspector_id: {'completed': r.completed, 'avg_score': r.avg_score} for r in completed_rows} + completed_map = { + r.inspector_id: { + 'completed': r.completed, + 'avg_score': r.avg_score, + 'avg_minutes': r.avg_minutes, + } + for r in completed_rows + } issues_map = {r.inspector_id: r.count for r in issue_rows} followups_map = {r.inspector_id: r.count for r in followup_rows} @@ -630,9 +655,10 @@ def inspector_performance(): for u in active_inspectors: t = total_map.get(u.id, {}) c = completed_map.get(u.id, {}) - tot = t.get('total', 0) + tot = t.get('total', 0) comp = c.get('completed', 0) avg = round(float(c['avg_score']), 1) if c.get('avg_score') else None + avg_mins = round(float(c['avg_minutes'])) if c.get('avg_minutes') else None inspector_stats.append({ 'id': u.id, 'display_name': u.display_name, @@ -640,6 +666,8 @@ def inspector_performance(): 'completed': comp, 'completion_rate': round(comp / tot * 100) if tot else 0, 'avg_score': avg, + 'avg_minutes': avg_mins, + 'avg_time': _fmt_minutes(avg_mins), 'issues_flagged': issues_map.get(u.id, 0), 'follow_ups': followups_map.get(u.id, 0), 'facilities': t.get('facilities', 0), @@ -647,6 +675,15 @@ def inspector_performance(): inspector_stats.sort(key=lambda x: (x['avg_score'] is None, -(x['avg_score'] or 0))) + # Team average score for benchmarking (vs. avg column) + _scores = [s['avg_score'] for s in inspector_stats if s['avg_score'] is not None] + team_avg_score = round(sum(_scores) / len(_scores), 1) if _scores else None + for s in inspector_stats: + if s['avg_score'] is not None and team_avg_score is not None: + s['vs_avg'] = round(s['avg_score'] - team_avg_score, 1) + else: + s['vs_avg'] = None + # ── Individual drill-down ───────────────────────────────────────────── selected_inspector = None selected_kpis = None @@ -696,6 +733,7 @@ def inspector_performance(): return render_template('reports/inspector_performance.html', start=start, end=end, inspector_stats=inspector_stats, + team_avg_score=team_avg_score, selected_inspector=selected_inspector, selected_kpis=selected_kpis, trend_data=trend_data, diff --git a/app/templates/reports/inspector_performance.html b/app/templates/reports/inspector_performance.html index 283f3ad..d3b04c5 100644 --- a/app/templates/reports/inspector_performance.html +++ b/app/templates/reports/inspector_performance.html @@ -100,6 +100,11 @@ Completed Completion Rate Avg Score + + vs. Avg + {% if team_avg_score %}({{ '%.1f'|format(team_avg_score) }}%){% endif %} + + Avg Time Issues Flagged Follow-ups Facilities @@ -133,6 +138,22 @@ {% endif %} + + {% if s.vs_avg is not none %} + {% if s.vs_avg > 0 %} + +{{ '%.1f'|format(s.vs_avg) }}% + {% elif s.vs_avg < 0 %} + {{ '%.1f'|format(s.vs_avg) }}% + {% else %} + 0.0% + {% endif %} + {% else %} + + {% endif %} + + + {{ s.avg_time or '—' }} + {% if s.issues_flagged > 0 %} {{ s.issues_flagged }} @@ -184,11 +205,12 @@ ('Total Inspections', selected_kpis.total, 'primary', 'bi-clipboard-data'), ('Completed', selected_kpis.completed, 'success', 'bi-check-circle'), ('Avg Score', (('%.1f'|format(selected_kpis.avg_score)) + '%') if selected_kpis.avg_score else '—', 'info', 'bi-graph-up'), + ('Avg Completion Time',selected_kpis.avg_time or '—', 'info', 'bi-stopwatch'), ('Issues Flagged', selected_kpis.issues_flagged, 'danger', 'bi-flag'), ('Follow-ups Req.', selected_kpis.follow_ups, 'warning', 'bi-arrow-repeat'), ('Facilities Covered', selected_kpis.facilities, 'primary', 'bi-building'), ] %} -
+

{{ label }}