Jul 9 - Update Report - Avg Score by Facility by contract
This commit is contained in:
@@ -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`, `1–3 days`, `3–7 days`, `1–4 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`, `1–3 days`, `3–7 days`, `1–4 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
@@ -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],
|
||||||
|
|||||||
@@ -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 }};
|
||||||
|
|||||||
Reference in New Issue
Block a user