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
│ │ ├── reports/
│ │ │ ├── _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
│ │ │ ├── scorecard.html # Per-facility scorecard (trend, area scores, SLA, open issues) + PDF Summary button
│ │ │ ├── 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 |
| 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`)
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.issue import Issue
from app.models.user import User
from app.models.project import Project
from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope
from app.utils.audit import log_action, ACTION_EXPORT
@@ -141,9 +142,11 @@ def index():
)
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(
Facility.name,
Facility.project_id,
func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id)\
@@ -159,9 +162,16 @@ def index():
fac_score_q = fac_score_q.filter(
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()
# 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
period_len = end - start
prior_end = start
@@ -255,12 +265,24 @@ def index():
inspectors = User.query.filter_by(role='inspector', active=True)\
.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
for row in facility_scores_list:
row['prior_avg'] = prior_scores_map.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',
start=start, end=end,
total_inspections=total_inspections,
@@ -268,6 +290,7 @@ def index():
flagged=flagged,
avg_score=round(float(avg_score), 2) if avg_score else None,
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],
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],
+59 -20
View File
@@ -105,7 +105,17 @@
<div class="row mb-4">
<div class="col-lg-8 mb-3">
<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>
</div>
@@ -129,6 +139,7 @@
<thead class="table-light">
<tr>
<th>Facility</th>
<th>Contract</th>
<th class="text-end">Current Period</th>
<th class="text-end">Prior Period</th>
<th class="text-end">Change</th>
@@ -137,8 +148,9 @@
</thead>
<tbody>
{% 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="text-muted small">{{ row.contract }}</td>
<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' }}">
{{ '%.1f'|format(row.avg_score|float) }}%
@@ -266,25 +278,52 @@ new Chart(document.getElementById('trendChart'), {
}
});
// ── Facility bar chart ────────────────────────────────────────────────────────
new Chart(document.getElementById('facilityChart'), {
type: 'bar',
data: {
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
borderRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
// ── Facility bar chart (filterable by contract) ───────────────────────────────
const FACILITY_SCORES = {{ facility_scores | tojson }};
let facilityChartObj = null;
function _facColors(data) { return data.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED); }
function renderFacilityChart(pid) {
const rows = (!pid)
? FACILITY_SCORES
: FACILITY_SCORES.filter(r => String(r.project_id) === String(pid));
const labels = rows.map(r => r.name);
const data = rows.map(r => r.avg_score);
if (facilityChartObj) {
facilityChartObj.data.labels = labels;
facilityChartObj.data.datasets[0].data = data;
facilityChartObj.data.datasets[0].backgroundColor = _facColors(data);
facilityChartObj.update();
} else {
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 ─────────────────────────────────────────────────────────
const sevData = {{ issue_severity | tojson }};