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
+16
View File
@@ -12,6 +12,22 @@
{% endblock %}
{% 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 ── #}
<div class="d-flex justify-content-between align-items-start mb-4">
<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 %}