06/12 Redesign dashboard
This commit is contained in:
+26
-90
@@ -24,7 +24,6 @@ def index():
|
|||||||
now = now_eastern()
|
now = now_eastern()
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
today_end = today_start + timedelta(days=1)
|
today_end = today_start + timedelta(days=1)
|
||||||
thirty_days_ago = now - timedelta(days=30)
|
|
||||||
|
|
||||||
is_inspector = current_user.role == 'inspector'
|
is_inspector = current_user.role == 'inspector'
|
||||||
is_privileged = current_user.role in ['admin', 'director']
|
is_privileged = current_user.role in ['admin', 'director']
|
||||||
@@ -97,26 +96,35 @@ def index():
|
|||||||
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
|
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Average score (last 30 days, inspector: own work in contracted facilities)
|
# ── Issues resolved today ─────────────────────────────────────────────────
|
||||||
score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
|
resolved_today_q = Issue.query.filter(
|
||||||
Inspection.status == 'completed',
|
Issue.status == 'resolved',
|
||||||
Inspection.overall_score.isnot(None),
|
Issue.resolved_at >= today_start,
|
||||||
Inspection.inspection_date >= thirty_days_ago,
|
Issue.resolved_at < today_end,
|
||||||
)
|
)
|
||||||
if is_inspector:
|
if is_inspector:
|
||||||
if not inspector_facility_ids:
|
if not inspector_facility_ids:
|
||||||
score_q = score_q.filter(False)
|
resolved_today_q = resolved_today_q.filter(False)
|
||||||
else:
|
else:
|
||||||
score_q = score_q.filter(
|
from app.models.facility import Area as _Area
|
||||||
Inspection.facility_id.in_(inspector_facility_ids),
|
resolved_today_q = resolved_today_q.outerjoin(
|
||||||
Inspection.inspector_id == current_user.id,
|
_Area, Issue.area_id == _Area.id
|
||||||
)
|
).filter(db.or_(
|
||||||
|
Issue.facility_id.in_(inspector_facility_ids),
|
||||||
|
_Area.facility_id.in_(inspector_facility_ids),
|
||||||
|
))
|
||||||
elif is_customer:
|
elif is_customer:
|
||||||
if customer_facility_ids:
|
if not customer_facility_ids:
|
||||||
score_q = score_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
resolved_today_q = resolved_today_q.filter(False)
|
||||||
else:
|
else:
|
||||||
score_q = score_q.filter(False)
|
from app.models.facility import Area as _Area
|
||||||
avg_score = score_q.scalar()
|
resolved_today_q = resolved_today_q.outerjoin(
|
||||||
|
_Area, Issue.area_id == _Area.id
|
||||||
|
).filter(db.or_(
|
||||||
|
Issue.facility_id.in_(customer_facility_ids),
|
||||||
|
_Area.facility_id.in_(customer_facility_ids),
|
||||||
|
))
|
||||||
|
resolved_today = resolved_today_q.count()
|
||||||
|
|
||||||
# ── Recent inspections ─────────────────────────────────────────────────
|
# ── Recent inspections ─────────────────────────────────────────────────
|
||||||
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
|
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
|
||||||
@@ -199,61 +207,6 @@ def index():
|
|||||||
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
||||||
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
||||||
|
|
||||||
# ── Score trend (last 30 days, grouped by day) ────────────────────────
|
|
||||||
trend_q = (
|
|
||||||
db.session.query(
|
|
||||||
func.date(Inspection.inspection_date).label('day'),
|
|
||||||
func.avg(Inspection.overall_score).label('avg'),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Inspection.status == 'completed',
|
|
||||||
Inspection.overall_score.isnot(None),
|
|
||||||
Inspection.inspection_date >= thirty_days_ago,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if is_inspector:
|
|
||||||
if not inspector_facility_ids:
|
|
||||||
trend_q = trend_q.filter(False)
|
|
||||||
else:
|
|
||||||
trend_q = trend_q.filter(
|
|
||||||
Inspection.facility_id.in_(inspector_facility_ids),
|
|
||||||
Inspection.inspector_id == current_user.id,
|
|
||||||
)
|
|
||||||
elif is_customer:
|
|
||||||
if customer_facility_ids:
|
|
||||||
trend_q = trend_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
|
||||||
else:
|
|
||||||
trend_q = trend_q.filter(False)
|
|
||||||
trend_rows = trend_q.group_by(func.date(Inspection.inspection_date))\
|
|
||||||
.order_by(func.date(Inspection.inspection_date)).all()
|
|
||||||
trend_labels = [str(r.day) for r in trend_rows]
|
|
||||||
trend_data = [round(float(r.avg), 2) for r in trend_rows]
|
|
||||||
|
|
||||||
# ── Facility performance (last 30 days, privileged users only) ─────────
|
|
||||||
facility_perf = []
|
|
||||||
if is_privileged or is_project_manager:
|
|
||||||
perf_rows = (
|
|
||||||
db.session.query(
|
|
||||||
Facility.name,
|
|
||||||
func.count(Inspection.id).label('count'),
|
|
||||||
func.avg(Inspection.overall_score).label('avg'),
|
|
||||||
)
|
|
||||||
.join(Inspection, Inspection.facility_id == Facility.id)
|
|
||||||
.filter(
|
|
||||||
Inspection.status == 'completed',
|
|
||||||
Inspection.overall_score.isnot(None),
|
|
||||||
Inspection.inspection_date >= thirty_days_ago,
|
|
||||||
Facility.active == True,
|
|
||||||
)
|
|
||||||
.group_by(Facility.id, Facility.name)
|
|
||||||
.order_by(func.avg(Inspection.overall_score).desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
facility_perf = [
|
|
||||||
{'name': r.name, 'count': r.count, 'avg': round(float(r.avg), 1)}
|
|
||||||
for r in perf_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── My open issues (inspector dashboard widget) ───────────────────────────
|
# ── My open issues (inspector dashboard widget) ───────────────────────────
|
||||||
# Issues assigned to the current inspector that are not yet resolved,
|
# Issues assigned to the current inspector that are not yet resolved,
|
||||||
# ordered by SLA urgency (breached first, then at-risk, then ok).
|
# ordered by SLA urgency (breached first, then at-risk, then ok).
|
||||||
@@ -270,40 +223,23 @@ def index():
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Facilities list for the trend-by-facility chart selector ────────────
|
|
||||||
if is_privileged or is_project_manager:
|
|
||||||
all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
|
||||||
elif is_inspector and inspector_facility_ids:
|
|
||||||
all_facilities = Facility.query.filter(
|
|
||||||
Facility.id.in_(inspector_facility_ids), Facility.active == True
|
|
||||||
).order_by(Facility.name).all()
|
|
||||||
elif is_customer and customer_facility_ids:
|
|
||||||
all_facilities = Facility.query.filter(
|
|
||||||
Facility.id.in_(customer_facility_ids), Facility.active == True
|
|
||||||
).order_by(Facility.name).all()
|
|
||||||
else:
|
|
||||||
all_facilities = []
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'dashboard.html',
|
'dashboard.html',
|
||||||
today_inspections = today_inspections,
|
today_inspections = today_inspections,
|
||||||
completed_today = completed_today,
|
completed_today = completed_today,
|
||||||
open_issues = open_issues,
|
open_issues = open_issues,
|
||||||
severity_breakdown = severity_breakdown,
|
severity_breakdown = severity_breakdown,
|
||||||
avg_score = round(avg_score, 2) if avg_score else None,
|
resolved_today = resolved_today,
|
||||||
|
pending_followups = pending_followups,
|
||||||
recent_inspections = recent_inspections,
|
recent_inspections = recent_inspections,
|
||||||
total_facilities = total_facilities,
|
total_facilities = total_facilities,
|
||||||
total_templates = total_templates,
|
total_templates = total_templates,
|
||||||
total_users = total_users,
|
total_users = total_users,
|
||||||
sla_breached = sla_breached,
|
sla_breached = sla_breached,
|
||||||
sla_at_risk = sla_at_risk,
|
sla_at_risk = sla_at_risk,
|
||||||
trend_labels = trend_labels,
|
|
||||||
trend_data = trend_data,
|
|
||||||
facility_perf = facility_perf,
|
|
||||||
customer_facilities = customer_facilities,
|
customer_facilities = customer_facilities,
|
||||||
pending_followups = pending_followups,
|
|
||||||
all_facilities = all_facilities,
|
|
||||||
my_issues = my_issues,
|
my_issues = my_issues,
|
||||||
|
today_str = now.strftime('%Y-%m-%d'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-242
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
{# ── Top stat cards ─────────────────────────────────────────────────────── #}
|
{# ── Top stat cards ─────────────────────────────────────────────────────── #}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md">
|
||||||
<a href="{{ url_for('inspections.index') }}" class="text-decoration-none">
|
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||||
<div class="card text-white bg-primary h-100">
|
<div class="card text-white bg-primary h-100">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md">
|
||||||
<a href="{{ url_for('inspections.index', status='completed') }}" class="text-decoration-none">
|
<a href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
|
||||||
<div class="card text-white bg-success h-100">
|
<div class="card text-white bg-success h-100">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md">
|
||||||
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
|
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
|
||||||
<div class="card text-white bg-warning h-100">
|
<div class="card text-white bg-warning h-100">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<div class="fs-2 fw-bold">{{ open_issues }}</div>
|
<div class="fs-2 fw-bold">{{ open_issues }}</div>
|
||||||
{% if open_issues > 0 %}
|
{% if open_issues > 0 %}
|
||||||
<div class="mt-1" style="font-size:.7rem;line-height:1.6;">
|
<div class="mt-1" style="font-size:.7rem;line-height:1.6;">
|
||||||
{% if severity_breakdown.critical > 0 %}<span class="badge bg-danger me-1">{{ severity_breakdown.critical }} critical</span>{% endif %}
|
{% if severity_breakdown.critical > 0 %}<span class="badge bg-danger me-1">{{ severity_breakdown.critical }} crit</span>{% endif %}
|
||||||
{% if severity_breakdown.high > 0 %}<span class="badge bg-danger me-1">{{ severity_breakdown.high }} high</span>{% endif %}
|
{% if severity_breakdown.high > 0 %}<span class="badge bg-danger me-1">{{ severity_breakdown.high }} high</span>{% endif %}
|
||||||
{% if severity_breakdown.medium > 0 %}<span class="badge bg-dark me-1">{{ severity_breakdown.medium }} med</span>{% endif %}
|
{% if severity_breakdown.medium > 0 %}<span class="badge bg-dark me-1">{{ severity_breakdown.medium }} med</span>{% endif %}
|
||||||
{% if severity_breakdown.low > 0 %}<span class="badge bg-secondary me-1">{{ severity_breakdown.low }} low</span>{% endif %}
|
{% if severity_breakdown.low > 0 %}<span class="badge bg-secondary me-1">{{ severity_breakdown.low }} low</span>{% endif %}
|
||||||
@@ -60,35 +60,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md">
|
||||||
<a href="{{ url_for('reports.index') }}" class="text-decoration-none">
|
<a href="{{ url_for('issues.index', status='resolved') }}" class="text-decoration-none">
|
||||||
<div class="card text-white bg-info h-100">
|
<div class="card text-white bg-info h-100">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="small text-white-50 fw-semibold">Avg Score (30d)</div>
|
<div class="small text-white-50 fw-semibold">Resolved Today</div>
|
||||||
<div class="fs-2 fw-bold">{{ avg_score if avg_score else '--' }}{% if avg_score %}%{% endif %}</div>
|
<div class="fs-2 fw-bold">{{ resolved_today }}</div>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-graph-up" style="font-size:2.5rem;opacity:.25;"></i>
|
<i class="bi bi-check2-all" style="font-size:2.5rem;opacity:.25;"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% if current_user.role != 'customer' %}
|
||||||
|
<div class="col-6 col-md">
|
||||||
{# ── Pending Follow-ups alert (non-customer) ────────────────────────────── #}
|
<a href="{{ url_for('inspections.index', status='follow_up') }}" class="text-decoration-none">
|
||||||
{% if pending_followups and pending_followups > 0 and current_user.role != 'customer' %}
|
<div class="card text-white h-100" style="background-color:#7c3aed;">
|
||||||
<div class="row mb-3">
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
<div class="col-12">
|
<div>
|
||||||
<a href="{{ url_for('inspections.index', follow_up='1') }}" class="text-decoration-none">
|
<div class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Follow-ups</div>
|
||||||
<div class="alert alert-warning d-flex align-items-center mb-0 py-2" role="alert">
|
<div class="fs-2 fw-bold">{{ pending_followups }}</div>
|
||||||
<i class="bi bi-arrow-repeat fs-5 me-2"></i>
|
</div>
|
||||||
<strong>{{ pending_followups }}</strong> inspection{{ 's' if pending_followups != 1 else '' }} flagged as requiring a follow-up re-inspection.
|
<i class="bi bi-arrow-repeat" style="font-size:2.5rem;opacity:.25;"></i>
|
||||||
<span class="ms-2 text-muted small">Click to view →</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
|
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
|
||||||
{% if sla_breached > 0 or sla_at_risk > 0 %}
|
{% if sla_breached > 0 or sla_at_risk > 0 %}
|
||||||
@@ -180,101 +180,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{# ── Score trend chart + Facility performance ───────────────────────────── #}
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-lg-{% if current_user.role in ['admin','director'] and facility_perf %}7{% else %}12{% endif %}">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header bg-light fw-semibold">
|
|
||||||
<i class="bi bi-graph-up me-1"></i>Inspection Score Trend (Last 30 Days)
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if trend_labels %}
|
|
||||||
<canvas id="trendChart" height="120"></canvas>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted py-4">
|
|
||||||
<i class="bi bi-bar-chart-line fs-2 d-block mb-2"></i>
|
|
||||||
No completed inspections with scores in the last 30 days.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if current_user.role in ['admin', 'director'] and facility_perf %}
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-header bg-light fw-semibold">
|
|
||||||
<i class="bi bi-building me-1"></i>Facility Performance (30d)
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<table class="table table-sm table-hover mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Facility</th>
|
|
||||||
<th class="text-center">Inspections</th>
|
|
||||||
<th class="text-center">Avg Score</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for f in facility_perf %}
|
|
||||||
<tr>
|
|
||||||
<td class="small">{{ f.name }}</td>
|
|
||||||
<td class="text-center small">{{ f.count }}</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<span class="badge bg-{% if f.avg >= 90 %}success{% elif f.avg >= 70 %}warning{% else %}danger{% endif %}">
|
|
||||||
{{ f.avg }}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── Facility Score Trend (30/60/90 days) ─────────────────────────────────── #}
|
|
||||||
{% if all_facilities %}
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header bg-light fw-semibold d-flex align-items-center flex-wrap gap-2">
|
|
||||||
<i class="bi bi-graph-up-arrow me-1"></i>Facility Score Trend
|
|
||||||
<div class="ms-auto d-flex align-items-center gap-2" style="font-weight:normal;">
|
|
||||||
<select id="facTrendFacility" class="form-select form-select-sm" style="width:auto;min-width:180px;">
|
|
||||||
<option value="">— Select Facility —</option>
|
|
||||||
{% for f in all_facilities %}
|
|
||||||
<option value="{{ f.id }}">{{ f.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
<button type="button" class="btn btn-outline-primary active" data-days="30">30d</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary" data-days="60">60d</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary" data-days="90">90d</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="facTrendEmpty" class="text-center text-muted py-4">
|
|
||||||
<i class="bi bi-building fs-2 d-block mb-2 opacity-25"></i>
|
|
||||||
Select a facility to view its score trend.
|
|
||||||
</div>
|
|
||||||
<div id="facTrendLoading" class="text-center text-muted py-4" style="display:none;">
|
|
||||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
|
||||||
Loading trend data…
|
|
||||||
</div>
|
|
||||||
<canvas id="facTrendChart" height="120" style="display:none;"></canvas>
|
|
||||||
<div id="facTrendNoData" class="text-center text-muted py-4" style="display:none;">
|
|
||||||
<i class="bi bi-bar-chart-line fs-2 d-block mb-2 opacity-25"></i>
|
|
||||||
No completed inspections with scores for this facility in the selected period.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
|
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
|
||||||
{% if my_issues %}
|
{% if my_issues %}
|
||||||
@@ -444,128 +349,4 @@
|
|||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
||||||
|
|
||||||
{% if trend_labels %}
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const ctx = document.getElementById('trendChart').getContext('2d');
|
|
||||||
const labels = {{ trend_labels | tojson }};
|
|
||||||
const data = {{ trend_data | tojson }};
|
|
||||||
|
|
||||||
new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels,
|
|
||||||
datasets: [{
|
|
||||||
label: 'Avg Score (%)',
|
|
||||||
data,
|
|
||||||
borderColor: '#2563eb',
|
|
||||||
backgroundColor: 'rgba(37,99,235,0.08)',
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 4,
|
|
||||||
pointBackgroundColor: '#2563eb',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y + '%' } }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: { min: 0, max: 100, ticks: { callback: v => v + '%' }, grid: { color: 'rgba(0,0,0,.05)' } },
|
|
||||||
x: { grid: { display: false } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── Facility trend chart (AJAX-driven) ── #}
|
|
||||||
{% if all_facilities %}
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const TREND_URL = '{{ url_for("dashboard.facility_trend") }}';
|
|
||||||
const select = document.getElementById('facTrendFacility');
|
|
||||||
const dayBtns = document.querySelectorAll('[data-days]');
|
|
||||||
const elEmpty = document.getElementById('facTrendEmpty');
|
|
||||||
const elLoading = document.getElementById('facTrendLoading');
|
|
||||||
const elCanvas = document.getElementById('facTrendChart');
|
|
||||||
const elNoData = document.getElementById('facTrendNoData');
|
|
||||||
|
|
||||||
let currentDays = 30;
|
|
||||||
let facChart = null;
|
|
||||||
|
|
||||||
function showState(state) {
|
|
||||||
elEmpty.style.display = state === 'empty' ? '' : 'none';
|
|
||||||
elLoading.style.display = state === 'loading' ? '' : 'none';
|
|
||||||
elCanvas.style.display = state === 'chart' ? '' : 'none';
|
|
||||||
elNoData.style.display = state === 'nodata' ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTrend() {
|
|
||||||
const fid = select.value;
|
|
||||||
if (!fid) { showState('empty'); return; }
|
|
||||||
|
|
||||||
showState('loading');
|
|
||||||
try {
|
|
||||||
const r = await fetch(TREND_URL + '?facility_id=' + fid + '&days=' + currentDays);
|
|
||||||
const d = await r.json();
|
|
||||||
|
|
||||||
if (!d.labels || d.labels.length === 0) { showState('nodata'); return; }
|
|
||||||
|
|
||||||
if (facChart) facChart.destroy();
|
|
||||||
|
|
||||||
showState('chart');
|
|
||||||
facChart = new Chart(elCanvas.getContext('2d'), {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: d.labels,
|
|
||||||
datasets: [{
|
|
||||||
label: d.facility + ' — Avg Score (%)',
|
|
||||||
data: d.data,
|
|
||||||
borderColor: '#0d9488',
|
|
||||||
backgroundColor: 'rgba(13,148,136,0.08)',
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 4,
|
|
||||||
pointBackgroundColor: '#0d9488',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: true, position: 'top', labels: { font: { size: 12 } } },
|
|
||||||
tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y + '%' } }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: { min: 0, max: 100, ticks: { callback: v => v + '%' }, grid: { color: 'rgba(0,0,0,.05)' } },
|
|
||||||
x: { grid: { display: false } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Facility trend fetch error:', err);
|
|
||||||
showState('nodata');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select.addEventListener('change', loadTrend);
|
|
||||||
|
|
||||||
dayBtns.forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
dayBtns.forEach(function (b) { b.classList.remove('active'); });
|
|
||||||
btn.classList.add('active');
|
|
||||||
currentDays = parseInt(btn.dataset.days);
|
|
||||||
loadTrend();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user