06/08 Add inspectors' KPI, performance

This commit is contained in:
2026-06-08 14:16:28 -04:00
parent d4c326483c
commit 00266eb32a
4 changed files with 525 additions and 0 deletions
+134
View File
@@ -561,4 +561,138 @@ def export_issues():
stream_with_context(generate()), stream_with_context(generate()),
mimetype='text/csv', mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'} 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,
) )
+16
View File
@@ -12,6 +12,22 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{# ── Sub-nav ── #}
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart me-1"></i>Overview
</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.inspector_performance') }}">
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
</a>
</li>
{% endif %}
</ul>
{# ── Header + date filter ── #} {# ── Header + date filter ── #}
<div class="d-flex justify-content-between align-items-start mb-4"> <div class="d-flex justify-content-between align-items-start mb-4">
<div> <div>
@@ -0,0 +1,375 @@
{% extends "base.html" %}
{% block title %}Inspector Performance{% endblock %}
{% block extra_css %}
<style>
.stat-card { border-left: 4px solid; }
.stat-card.primary { border-color: #0d6efd; }
.stat-card.success { border-color: #198754; }
.stat-card.danger { border-color: #dc3545; }
.stat-card.info { border-color: #0dcaf0; }
.stat-card.warning { border-color: #ffc107; }
.chart-container { position:relative; height:280px; }
.inspector-row { cursor:pointer; }
.inspector-row.table-active td { background-color: #e8f0fe !important; }
</style>
{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}">
<i class="bi bi-bar-chart me-1"></i>Overview
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}">
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
</a>
</li>
</ul>
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2><i class="bi bi-person-lines-fill"></i> Inspector Performance</h2>
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
</div>
</div>
{# ── Date filter ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm"
value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm"
value="{{ end.strftime('%Y-%m-%d') }}">
</div>
{% if selected_id %}
<input type="hidden" name="inspector_id" value="{{ selected_id }}">
{% endif %}
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.inspector_performance') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{% if not inspector_stats %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
No inspections found for the selected date range.
</div>
{% else %}
{# ── Comparison charts ── #}
<div class="row mb-4">
<div class="col-lg-7 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-bar-chart"></i> Avg Score by Inspector</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="scoreChart"></canvas></div></div>
</div>
</div>
<div class="col-lg-5 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-clipboard-data"></i> Inspections Completed</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="countChart"></canvas></div></div>
</div>
</div>
</div>
{# ── Summary table ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-table me-1"></i>All Inspectors — Summary</h6>
<span class="badge bg-secondary">{{ inspector_stats|length }} inspector{{ 's' if inspector_stats|length != 1 }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Inspector</th>
<th class="text-center">Total</th>
<th class="text-center">Completed</th>
<th class="text-center">Completion Rate</th>
<th class="text-center">Avg Score</th>
<th class="text-center">Issues Flagged</th>
<th class="text-center">Follow-ups</th>
<th class="text-center">Facilities</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in inspector_stats %}
<tr class="inspector-row {% if selected_id == s.id %}table-active{% endif %}"
data-inspector-id="{{ s.id }}">
<td class="fw-semibold">
{{ s.display_name }}
</td>
<td class="text-center">{{ s.total }}</td>
<td class="text-center">{{ s.completed }}</td>
<td class="text-center">
<div class="d-flex align-items-center justify-content-center gap-2">
<div class="progress flex-grow-1" style="height:6px;max-width:60px;">
<div class="progress-bar bg-{{ 'success' if s.completion_rate >= 90 else 'warning' if s.completion_rate >= 70 else 'danger' }}"
style="width:{{ s.completion_rate }}%;"></div>
</div>
<span class="small">{{ s.completion_rate }}%</span>
</div>
</td>
<td class="text-center">
{% if s.avg_score %}
<span class="badge bg-{{ 'success' if s.avg_score >= 90 else 'warning text-dark' if s.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(s.avg_score) }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-center">
{% if s.issues_flagged > 0 %}
<span class="badge bg-danger">{{ s.issues_flagged }}</span>
{% else %}
<span class="text-muted">0</span>
{% endif %}
</td>
<td class="text-center">
{% if s.follow_ups > 0 %}
<span class="badge bg-warning text-dark">{{ s.follow_ups }}</span>
{% else %}
<span class="text-muted">0</span>
{% endif %}
</td>
<td class="text-center">{{ s.facilities }}</td>
<td>
{% if selected_id == s.id %}
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-secondary">Close</a>
{% else %}
<a href="{{ url_for('reports.inspector_performance', inspector_id=s.id, start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-primary">Details</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ── Individual inspector drill-down ── #}
{% if selected_inspector and selected_kpis %}
<div class="card shadow-sm border-primary mb-4" id="inspectorDetail">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
</h6>
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-light text-primary">
<i class="bi bi-x-lg"></i>
</a>
</div>
<div class="card-body">
{# KPI stat row #}
<div class="row g-3 mb-4">
{% 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'),
] %}
<div class="col-6 col-md-4 col-lg-2">
<div class="card shadow-sm stat-card {{ color }} h-100">
<div class="card-body py-2 px-3">
<p class="text-muted small mb-1">{{ label }}</p>
<div class="d-flex align-items-center gap-2">
<i class="bi {{ icon }} text-{{ color }}"></i>
<span class="fw-bold">{{ value }}</span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# Score trend chart #}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-graph-up-arrow me-1"></i>Score Trend — {{ selected_inspector.display_name }}</h6>
</div>
<div class="card-body">
{% if trend_data %}
<div class="chart-container"><canvas id="trendChart"></canvas></div>
{% else %}
<p class="text-muted text-center py-3 mb-0">No completed inspections with scores in this period.</p>
{% endif %}
</div>
</div>
</div>
</div>
{# Recent inspections #}
{% if recent_inspections %}
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-list-ul me-1"></i>Recent Inspections</h6>
<span class="badge bg-secondary">{{ recent_inspections|length }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Area</th>
<th>Template</th>
<th class="text-center">Score</th>
<th class="text-center">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ins in recent_inspections %}
<tr>
<td class="small">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
<td class="small">{{ ins.facility.name if ins.facility else '—' }}</td>
<td class="small">{{ ins.area.name if ins.area else '—' }}</td>
<td class="small">{{ ins.template.name if ins.template else '—' }}</td>
<td class="text-center">
{% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">
{{ '%.1f'|format(ins.overall_score|float) }}%
</span>
{% else %}—{% endif %}
</td>
<td class="text-center">
{% set sc = ins.status %}
<span class="badge bg-{{ 'success' if sc == 'completed' else 'warning text-dark' if sc == 'flagged' else 'secondary' }}">
{{ 'Submitted' if sc == 'completed' else sc.replace('_',' ')|title }}
</span>
</td>
<td>
<a href="{{ url_for('inspections.view_inspection', inspection_id=ins.id) }}"
class="btn btn-xs btn-outline-secondary" style="font-size:.75rem;padding:2px 8px;">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}{# end if inspector_stats #}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
Chart.defaults.font.family = "'Segoe UI', system-ui, sans-serif";
Chart.defaults.color = '#6c757d';
const BLUE = '#0d6efd', GREEN = '#198754', RED = '#dc3545',
AMBER = '#ffc107', GRAY = '#adb5bd';
function scoreColor(s) { return s >= 90 ? GREEN : s >= 70 ? AMBER : RED; }
{% if inspector_stats %}
const stats = {{ inspector_stats | tojson }};
// ── Avg score bar chart ───────────────────────────────────────────────────────
new Chart(document.getElementById('scoreChart'), {
type: 'bar',
data: {
labels: stats.map(s => s.display_name),
datasets: [{
label: 'Avg Score (%)',
data: stats.map(s => s.avg_score),
backgroundColor: stats.map(s => s.avg_score ? scoreColor(s.avg_score) : GRAY),
borderRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
// ── Inspection count chart ────────────────────────────────────────────────────
new Chart(document.getElementById('countChart'), {
type: 'bar',
data: {
labels: stats.map(s => s.display_name),
datasets: [
{
label: 'Completed',
data: stats.map(s => s.completed),
backgroundColor: GREEN,
borderRadius: 4,
stack: 'a',
},
{
label: 'Other',
data: stats.map(s => s.total - s.completed),
backgroundColor: GRAY,
borderRadius: 4,
stack: 'a',
},
]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom' } }
}
});
{% endif %}
{% if trend_data %}
// ── Individual inspector score trend ─────────────────────────────────────────
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: {{ trend_data | map(attribute='day') | list | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ trend_data | map(attribute='avg') | list | tojson }},
borderColor: BLUE, backgroundColor: 'rgba(13,110,253,.1)',
tension: .3, fill: true, pointRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
{% endif %}
{% if selected_id %}
// Scroll to detail panel on load
document.addEventListener('DOMContentLoaded', function () {
var el = document.getElementById('inspectorDetail');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
{% endif %}
</script>
{% endblock %}
Binary file not shown.