06/08 Add inspectors' KPI, performance
This commit is contained in:
@@ -562,3 +562,137 @@ def export_issues():
|
|||||||
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,
|
||||||
|
)
|
||||||
@@ -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.
Reference in New Issue
Block a user