06/08 Add inspectors' KPI, performance
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user