Jul 9 - Update Report - Avg Score by Facility by contract

This commit is contained in:
2026-07-09 11:55:10 -04:00
parent 40ac335f5a
commit 138e44c47d
3 changed files with 90 additions and 24 deletions
+5 -1
View File
@@ -115,7 +115,7 @@ lt_janitorial_quality_control/
│ │ │ └── issues_view.html # Same photo evidence logic │ │ │ └── issues_view.html # Same photo evidence logic
│ │ ├── reports/ │ │ ├── reports/
│ │ │ ├── _subnav.html # Shared sub-nav include for all report pages │ │ │ ├── _subnav.html # Shared sub-nav include for all report pages
│ │ │ ├── index.html # Overview & Trends (score trend, facility scores, charts) │ │ │ ├── index.html # Overview & Trends (score trend, facility scores + per-Contract filter, charts)
│ │ │ ├── facility.html # Per-facility detail report │ │ │ ├── facility.html # Per-facility detail report
│ │ │ ├── scorecard.html # Per-facility scorecard (trend, area scores, SLA, open issues) + PDF Summary button │ │ │ ├── scorecard.html # Per-facility scorecard (trend, area scores, SLA, open issues) + PDF Summary button
│ │ │ ├── inspector_performance.html # Inspector KPI table + drill-down chart │ │ │ ├── inspector_performance.html # Inspector KPI table + drill-down chart
@@ -1054,6 +1054,10 @@ All report pages include `{% include 'reports/_subnav.html' %}` as the first ele
| Inspector Performance | admin, director | | Inspector Performance | admin, director |
| Scheduled Reports | admin, director, project_manager | | Scheduled Reports | admin, director, project_manager |
### Reports — Overview & Trends: "Avg Score by Facility" Contract filter
The **Avg Score by Facility** card (chart) and the **Facility Score Comparison** table on `reports/index.html` share a **Contract** `<select>` (`#scoreContractFilter`) in the chart card header. It is **client-side only**: `reports.index()` attaches `project_id` + `contract` name to each `facility_scores` row and passes `score_contracts` (distinct `(project_id, name)` present, `0`/"No Contract" for unassigned). Selecting a contract filters both the Chart.js bars (`renderFacilityChart(pid)` mutates the existing chart) and the table rows (`.facility-score-row[data-project-id]`); default "All Contracts" shows everything. It does **not** reload the page or affect the top KPIs — only this section. Contracts shown are already role-scoped (customers/inspectors see only theirs).
### Reports — Phase R1: Issues Aging (`/reports/issues-aging`) ### Reports — Phase R1: Issues Aging (`/reports/issues-aging`)
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `13 days`, `37 days`, `14 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope). Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `13 days`, `37 days`, `14 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
+26 -3
View File
@@ -13,6 +13,7 @@ from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.issue import Issue from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.models.project import Project
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope
from app.utils.audit import log_action, ACTION_EXPORT from app.utils.audit import log_action, ACTION_EXPORT
@@ -141,9 +142,11 @@ def index():
) )
avg_score = _scope_insp(avg_score).scalar() avg_score = _scope_insp(avg_score).scalar()
# Scores by facility (for bar chart) # Scores by facility (for bar chart) — includes project_id so the report
# can group/filter facilities by Contract client-side.
fac_score_q = db.session.query( fac_score_q = db.session.query(
Facility.name, Facility.name,
Facility.project_id,
func.avg(Inspection.overall_score).label('avg_score'), func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'), func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id)\ ).join(Inspection, Facility.id == Inspection.facility_id)\
@@ -159,9 +162,16 @@ def index():
fac_score_q = fac_score_q.filter( fac_score_q = fac_score_q.filter(
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False Facility.id.in_(customer_facility_ids) if customer_facility_ids else False
) )
facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\ facility_scores = fac_score_q.group_by(Facility.id, Facility.name, Facility.project_id)\
.order_by(func.avg(Inspection.overall_score).desc()).all() .order_by(func.avg(Inspection.overall_score).desc()).all()
# Resolve contract names for the facilities present.
_proj_ids = {r.project_id for r in facility_scores if r.project_id}
_proj_names = (
{p.id: p.name for p in Project.query.filter(Project.id.in_(_proj_ids)).all()}
if _proj_ids else {}
)
# Prior-period facility scores for period-over-period delta badges # Prior-period facility scores for period-over-period delta badges
period_len = end - start period_len = end - start
prior_end = start prior_end = start
@@ -255,12 +265,24 @@ def index():
inspectors = User.query.filter_by(role='inspector', active=True)\ inspectors = User.query.filter_by(role='inspector', active=True)\
.order_by(User.full_name, User.username).all() .order_by(User.full_name, User.username).all()
facility_scores_list = [{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores] facility_scores_list = [{
'name': r.name,
'avg_score': round(float(r.avg_score), 2),
'count': r.count,
'project_id': r.project_id or 0,
'contract': _proj_names.get(r.project_id, 'No Contract'),
} for r in facility_scores]
# Attach prior avg and delta to each facility score dict for the template table # Attach prior avg and delta to each facility score dict for the template table
for row in facility_scores_list: for row in facility_scores_list:
row['prior_avg'] = prior_scores_map.get(row['name']) row['prior_avg'] = prior_scores_map.get(row['name'])
row['delta'] = facility_deltas.get(row['name']) row['delta'] = facility_deltas.get(row['name'])
# Distinct contracts present, for the "Avg Score by Facility" contract filter.
score_contracts = sorted(
{(r['project_id'], r['contract']) for r in facility_scores_list},
key=lambda t: (t[1] or '').lower(),
)
return render_template('reports/index.html', return render_template('reports/index.html',
start=start, end=end, start=start, end=end,
total_inspections=total_inspections, total_inspections=total_inspections,
@@ -268,6 +290,7 @@ def index():
flagged=flagged, flagged=flagged,
avg_score=round(float(avg_score), 2) if avg_score else None, avg_score=round(float(avg_score), 2) if avg_score else None,
facility_scores=facility_scores_list, facility_scores=facility_scores_list,
score_contracts=score_contracts,
daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores], daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores],
issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity], issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity],
issue_status=[{'status': r.status, 'count': r.count} for r in issue_status], issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
+59 -20
View File
@@ -105,7 +105,17 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col-lg-8 mb-3"> <div class="col-lg-8 mb-3">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div> <div class="card-header bg-light d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6>
{% if score_contracts %}
<select id="scoreContractFilter" class="form-select form-select-sm" style="max-width:230px;">
<option value="">All Contracts</option>
{% for pid, cname in score_contracts %}
<option value="{{ pid }}">{{ cname }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div> <div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
</div> </div>
</div> </div>
@@ -129,6 +139,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Facility</th> <th>Facility</th>
<th>Contract</th>
<th class="text-end">Current Period</th> <th class="text-end">Current Period</th>
<th class="text-end">Prior Period</th> <th class="text-end">Prior Period</th>
<th class="text-end">Change</th> <th class="text-end">Change</th>
@@ -137,8 +148,9 @@
</thead> </thead>
<tbody> <tbody>
{% for row in facility_scores %} {% for row in facility_scores %}
<tr> <tr class="facility-score-row" data-project-id="{{ row.project_id }}">
<td class="fw-semibold">{{ row.name }}</td> <td class="fw-semibold">{{ row.name }}</td>
<td class="text-muted small">{{ row.contract }}</td>
<td class="text-end"> <td class="text-end">
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}"> <span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(row.avg_score|float) }}% {{ '%.1f'|format(row.avg_score|float) }}%
@@ -266,25 +278,52 @@ new Chart(document.getElementById('trendChart'), {
} }
}); });
// ── Facility bar chart ──────────────────────────────────────────────────────── // ── Facility bar chart (filterable by contract) ───────────────────────────────
new Chart(document.getElementById('facilityChart'), { const FACILITY_SCORES = {{ facility_scores | tojson }};
type: 'bar', let facilityChartObj = null;
data: { function _facColors(data) { return data.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED); }
labels: {{ facility_scores | map(attribute='name') | list | tojson }}, function renderFacilityChart(pid) {
datasets: [{ const rows = (!pid)
label: 'Avg Score (%)', ? FACILITY_SCORES
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }}, : FACILITY_SCORES.filter(r => String(r.project_id) === String(pid));
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }} const labels = rows.map(r => r.name);
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED), const data = rows.map(r => r.avg_score);
borderRadius: 4, if (facilityChartObj) {
}] facilityChartObj.data.labels = labels;
}, facilityChartObj.data.datasets[0].data = data;
options: { facilityChartObj.data.datasets[0].backgroundColor = _facColors(data);
responsive: true, maintainAspectRatio: false, facilityChartObj.update();
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } }, } else {
plugins: { legend: { display: false } } facilityChartObj = new Chart(document.getElementById('facilityChart'), {
type: 'bar',
data: { labels: labels, datasets: [{
label: 'Avg Score (%)', data: data,
backgroundColor: _facColors(data), borderRadius: 4,
}] },
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
} }
}); }
function filterFacilityScoreTable(pid) {
document.querySelectorAll('.facility-score-row').forEach(function (tr) {
const rp = tr.getAttribute('data-project-id');
tr.style.display = (!pid || rp === String(pid)) ? '' : 'none';
});
}
(function () {
renderFacilityChart('');
const sel = document.getElementById('scoreContractFilter');
if (sel) {
sel.addEventListener('change', function () {
renderFacilityChart(this.value);
filterFacilityScoreTable(this.value);
});
}
})();
// ── Severity doughnut ───────────────────────────────────────────────────────── // ── Severity doughnut ─────────────────────────────────────────────────────────
const sevData = {{ issue_severity | tojson }}; const sevData = {{ issue_severity | tojson }};