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 ── #}
diff --git a/app/templates/reports/inspector_performance.html b/app/templates/reports/inspector_performance.html new file mode 100644 index 0000000..53a32b1 --- /dev/null +++ b/app/templates/reports/inspector_performance.html @@ -0,0 +1,375 @@ +{% extends "base.html" %} +{% block title %}Inspector Performance{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} + + +{# ── Header ── #} +
+
+

Inspector Performance

+

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+
+ +{# ── Date filter ── #} +
+
+
+
+ + +
+
+ + +
+ {% if selected_id %} + + {% endif %} +
+ + Reset +
+
+
+
+ +{% if not inspector_stats %} +
+ + No inspections found for the selected date range. +
+{% else %} + +{# ── Comparison charts ── #} +
+
+
+
Avg Score by Inspector
+
+
+
+
+
+
Inspections Completed
+
+
+
+
+ +{# ── Summary table ── #} +
+
+
All Inspectors — Summary
+ {{ inspector_stats|length }} inspector{{ 's' if inspector_stats|length != 1 }} +
+
+ + + + + + + + + + + + + + + + {% for s in inspector_stats %} + + + + + + + + + + + + {% endfor %} + +
InspectorTotalCompletedCompletion RateAvg ScoreIssues FlaggedFollow-upsFacilities
+ {{ 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 %} +
+
+
+ +{# ── Individual inspector drill-down ── #} +{% if selected_inspector and selected_kpis %} +
+
+
+ {{ selected_inspector.display_name }} +
+ + + +
+
+ + {# KPI stat row #} +
+ {% for label, value, color, icon in [ + ('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'), + ('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 }}

+
+ + {{ value }} +
+
+
+
+ {% endfor %} +
+ + {# Score trend chart #} +
+
+
+
+
Score Trend — {{ selected_inspector.display_name }}
+
+
+ {% if trend_data %} +
+ {% else %} +

No completed inspections with scores in this period.

+ {% endif %} +
+
+
+
+ + {# Recent inspections #} + {% if recent_inspections %} +
+
+
Recent Inspections
+ {{ recent_inspections|length }} +
+
+ + + + + + + + + + + + + + {% for ins in recent_inspections %} + + + + + + + + + + {% endfor %} + +
DateFacilityAreaTemplateScoreStatus
{{ 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 +
+
+
+ {% endif %} + +
+
+{% endif %} + +{% endif %}{# end if inspector_stats #} + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/docs/JQC_Customer_User_Manual.docx b/docs/JQC_Customer_User_Manual.docx new file mode 100644 index 0000000..485e760 Binary files /dev/null and b/docs/JQC_Customer_User_Manual.docx differ