diff --git a/app/routes/reports.py b/app/routes/reports.py index 354c430..af30b2b 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -561,4 +561,138 @@ def export_issues(): stream_with_context(generate()), mimetype='text/csv', headers={'Content-Disposition': f'attachment; filename="{filename}"'} + ) + + +# ── Inspector Performance ───────────────────────────────────────────────────── + +@bp.route('/inspector-performance') +@login_required +@supervisor_required +def inspector_performance(): + """KPI & performance dashboard for all inspectors — admin/director only.""" + start, end = _date_range() + selected_id = request.args.get('inspector_id', type=int) + + # ── Per-inspector aggregate stats ───────────────────────────────────── + total_rows = db.session.query( + Inspection.inspector_id, + func.count(Inspection.id).label('total'), + func.count(func.distinct(Inspection.facility_id)).label('facilities'), + ).filter( + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + ).group_by(Inspection.inspector_id).all() + + completed_rows = db.session.query( + Inspection.inspector_id, + func.count(Inspection.id).label('completed'), + func.avg(Inspection.overall_score).label('avg_score'), + ).filter( + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + Inspection.status == 'completed', + ).group_by(Inspection.inspector_id).all() + + issue_rows = db.session.query( + Inspection.inspector_id, + func.count(Issue.id).label('count'), + ).join(Issue, Issue.inspection_id == Inspection.id)\ + .filter( + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + ).group_by(Inspection.inspector_id).all() + + followup_rows = db.session.query( + Inspection.inspector_id, + func.count(Inspection.id).label('count'), + ).filter( + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + Inspection.follow_up_required == True, + ).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} + issues_map = {r.inspector_id: r.count for r in issue_rows} + followups_map = {r.inspector_id: r.count for r in followup_rows} + + all_ids = set(total_map.keys()) + active_inspectors = ( + User.query + .filter(User.id.in_(all_ids), User.active == True, User.role == 'inspector') + .order_by(User.full_name, User.username) + .all() + ) if all_ids else [] + + inspector_stats = [] + for u in active_inspectors: + t = total_map.get(u.id, {}) + c = completed_map.get(u.id, {}) + tot = t.get('total', 0) + comp = c.get('completed', 0) + avg = round(float(c['avg_score']), 1) if c.get('avg_score') else None + inspector_stats.append({ + 'id': u.id, + 'display_name': u.display_name, + 'total': tot, + 'completed': comp, + 'completion_rate': round(comp / tot * 100) if tot else 0, + 'avg_score': avg, + 'issues_flagged': issues_map.get(u.id, 0), + 'follow_ups': followups_map.get(u.id, 0), + 'facilities': t.get('facilities', 0), + }) + + inspector_stats.sort(key=lambda x: (x['avg_score'] is None, -(x['avg_score'] or 0))) + + # ── Individual drill-down ───────────────────────────────────────────── + selected_inspector = None + selected_kpis = None + trend_data = [] + recent_inspections = [] + + if selected_id: + selected_inspector = db.session.get(User, selected_id) + if selected_inspector and selected_inspector.role == 'inspector': + selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None) + + trend_rows = db.session.query( + func.date(Inspection.inspection_date).label('day'), + func.avg(Inspection.overall_score).label('avg'), + func.count(Inspection.id).label('count'), + ).filter( + Inspection.inspector_id == selected_id, + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ).group_by(func.date(Inspection.inspection_date))\ + .order_by(func.date(Inspection.inspection_date)).all() + + trend_data = [ + {'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} + for r in trend_rows + ] + + recent_inspections = ( + Inspection.query + .filter( + Inspection.inspector_id == selected_id, + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + ) + .order_by(Inspection.inspection_date.desc()) + .limit(20) + .all() + ) + + return render_template('reports/inspector_performance.html', + start=start, end=end, + inspector_stats=inspector_stats, + selected_inspector=selected_inspector, + selected_kpis=selected_kpis, + trend_data=trend_data, + recent_inspections=recent_inspections, + selected_id=selected_id, ) \ No newline at end of file diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index eb775d9..e9323c5 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -12,6 +12,22 @@ {% endblock %} {% block content %} +{# ── Sub-nav ── #} +
+ {# ── Header + date filter ── #}{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}
+| Inspector | +Total | +Completed | +Completion Rate | +Avg Score | +Issues Flagged | +Follow-ups | +Facilities | ++ |
|---|---|---|---|---|---|---|---|---|
| + {{ s.display_name }} + | +{{ s.total }} | +{{ s.completed }} | +
+
+
+
+
+
+ {{ s.completion_rate }}%
+ |
+ + {% if s.avg_score %} + + {{ '%.1f'|format(s.avg_score) }}% + + {% else %} + — + {% endif %} + | ++ {% if s.issues_flagged > 0 %} + {{ s.issues_flagged }} + {% else %} + 0 + {% endif %} + | ++ {% if s.follow_ups > 0 %} + {{ s.follow_ups }} + {% else %} + 0 + {% endif %} + | +{{ s.facilities }} | ++ {% if selected_id == s.id %} + Close + {% else %} + Details + {% endif %} + | +
{{ label }}
+No completed inspections with scores in this period.
+ {% endif %} +| Date | +Facility | +Area | +Template | +Score | +Status | ++ |
|---|---|---|---|---|---|---|
| {{ ins.inspection_date.strftime('%b %d, %Y') }} | +{{ ins.facility.name if ins.facility else '—' }} | +{{ ins.area.name if ins.area else '—' }} | +{{ ins.template.name if ins.template else '—' }} | ++ {% if ins.overall_score %} + + {{ '%.1f'|format(ins.overall_score|float) }}% + + {% else %}—{% endif %} + | ++ {% set sc = ins.status %} + + {{ 'Submitted' if sc == 'completed' else sc.replace('_',' ')|title }} + + | ++ View + | +