Mar 03 2026: implemented SLA issue tracking & enhanced Dashboard
This commit is contained in:
@@ -73,6 +73,13 @@ def create_app(config_name='default'):
|
||||
app.jinja_env.globals['csrf_token'] = generate_csrf
|
||||
app.jinja_env.globals['enumerate'] = enumerate
|
||||
|
||||
# SLA helpers available in all templates
|
||||
from app.utils.sla import sla_status, sla_deadline, sla_hours_remaining, SLA_HOURS
|
||||
app.jinja_env.globals['sla_status'] = sla_status
|
||||
app.jinja_env.globals['sla_deadline'] = sla_deadline
|
||||
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
|
||||
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
|
||||
|
||||
# ── Inject unread notification count into every template context ──────
|
||||
# This powers the red badge on the navbar bell icon without requiring
|
||||
# individual routes to pass the count manually.
|
||||
|
||||
+95
-55
@@ -1,90 +1,125 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from app import db # ADD THIS LINE
|
||||
from app import db
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.sla import sla_status, SLA_HOURS
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def index():
|
||||
# Get today's date range
|
||||
today_start = now_eastern().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
# Statistics for today
|
||||
if current_user.role == 'inspector':
|
||||
today_inspections = Inspection.query.filter(
|
||||
Inspection.inspector_id == current_user.id,
|
||||
is_inspector = current_user.role == 'inspector'
|
||||
is_privileged = current_user.role in ['admin', 'supervisor']
|
||||
|
||||
# ── Today's stats ─────────────────────────────────────────────────────
|
||||
base_q = Inspection.query
|
||||
if is_inspector:
|
||||
base_q = base_q.filter(Inspection.inspector_id == current_user.id)
|
||||
|
||||
today_inspections = base_q.filter(
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
completed_today = Inspection.query.filter(
|
||||
Inspection.inspector_id == current_user.id,
|
||||
completed_today = base_q.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
open_issues = Issue.query.join(Inspection).filter(
|
||||
Inspection.inspector_id == current_user.id,
|
||||
Issue.status.in_(['open', 'in_progress'])
|
||||
).count()
|
||||
# ── Open issues ────────────────────────────────────────────────────────
|
||||
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
open_issues_q = open_issues_q.join(
|
||||
Inspection, Issue.inspection_id == Inspection.id
|
||||
).filter(Inspection.inspector_id == current_user.id)
|
||||
open_issues = open_issues_q.count()
|
||||
|
||||
else:
|
||||
today_inspections = Inspection.query.filter(
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end
|
||||
).count()
|
||||
|
||||
completed_today = Inspection.query.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= today_start,
|
||||
Inspection.inspection_date < today_end
|
||||
).count()
|
||||
|
||||
open_issues = Issue.query.filter(
|
||||
Issue.status.in_(['open', 'in_progress'])
|
||||
).count()
|
||||
|
||||
# Calculate average score (last 30 days)
|
||||
thirty_days_ago = now_eastern() - timedelta(days=30)
|
||||
avg_score_query = Inspection.query.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= thirty_days_ago
|
||||
)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
avg_score_query = avg_score_query.filter(Inspection.inspector_id == current_user.id)
|
||||
|
||||
avg_score = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
# ── Average score (last 30 days) ───────────────────────────────────────
|
||||
score_q = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= thirty_days_ago,
|
||||
*([Inspection.inspector_id == current_user.id] if current_user.role == 'inspector' else [])
|
||||
).scalar()
|
||||
)
|
||||
if is_inspector:
|
||||
score_q = score_q.filter(Inspection.inspector_id == current_user.id)
|
||||
avg_score = score_q.scalar()
|
||||
|
||||
# Recent inspections
|
||||
recent_inspections_query = Inspection.query.order_by(Inspection.inspection_date.desc())
|
||||
if current_user.role == 'inspector':
|
||||
recent_inspections_query = recent_inspections_query.filter(Inspection.inspector_id == current_user.id)
|
||||
recent_inspections = recent_inspections_query.limit(5).all()
|
||||
# ── Recent inspections ─────────────────────────────────────────────────
|
||||
recent_q = Inspection.query.order_by(Inspection.inspection_date.desc())
|
||||
if is_inspector:
|
||||
recent_q = recent_q.filter(Inspection.inspector_id == current_user.id)
|
||||
recent_inspections = recent_q.limit(5).all()
|
||||
|
||||
# System statistics (admin/supervisor only)
|
||||
total_facilities = Facility.query.filter_by(active=True).count() if current_user.role in ['admin', 'supervisor'] else 0
|
||||
total_templates = InspectionTemplate.query.count() if current_user.role in ['admin', 'supervisor'] else 0
|
||||
# ── System stats (admin/supervisor) ───────────────────────────────────
|
||||
total_facilities = Facility.query.filter_by(active=True).count() if is_privileged else 0
|
||||
total_templates = InspectionTemplate.query.count() if is_privileged else 0
|
||||
total_users = User.query.count() if current_user.role == 'admin' else 0
|
||||
|
||||
return render_template('dashboard.html',
|
||||
# ── SLA summary (open + in_progress issues only) ──────────────────────
|
||||
all_open_issues = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])).all()
|
||||
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')
|
||||
|
||||
# ── Score trend (last 30 days, grouped by day) ────────────────────────
|
||||
trend_rows = (
|
||||
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,
|
||||
)
|
||||
.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:
|
||||
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
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
@@ -92,5 +127,10 @@ def index():
|
||||
recent_inspections = recent_inspections,
|
||||
total_facilities = total_facilities,
|
||||
total_templates = total_templates,
|
||||
total_users=total_users
|
||||
total_users = total_users,
|
||||
sla_breached = sla_breached,
|
||||
sla_at_risk = sla_at_risk,
|
||||
trend_labels = trend_labels,
|
||||
trend_data = trend_data,
|
||||
facility_perf = facility_perf,
|
||||
)
|
||||
+12
-2
@@ -14,6 +14,7 @@ from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.notifications import notify
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.sla import sla_status
|
||||
|
||||
bp = Blueprint('issues', __name__, url_prefix='/issues')
|
||||
|
||||
@@ -51,12 +52,19 @@ def index():
|
||||
|
||||
severity_filter = request.args.get('severity', '')
|
||||
status_filter = request.args.get('status', '')
|
||||
sla_filter = request.args.get('sla', '')
|
||||
if severity_filter:
|
||||
q = q.filter(Issue.severity == severity_filter)
|
||||
if status_filter:
|
||||
q = q.filter(Issue.status == status_filter)
|
||||
|
||||
issues = q.paginate(page=page, per_page=25, error_out=False)
|
||||
# SLA filter — applied in Python after DB query since SLA is computed
|
||||
issues_paged = q.paginate(page=page, per_page=25, error_out=False)
|
||||
|
||||
if sla_filter:
|
||||
filtered_items = [i for i in issues_paged.items if sla_status(i) == sla_filter]
|
||||
else:
|
||||
filtered_items = issues_paged.items
|
||||
|
||||
# Build a set of issue IDs the current user is following so the template
|
||||
# can render the following badge and inline unfollow button without an
|
||||
@@ -67,9 +75,11 @@ def index():
|
||||
}
|
||||
|
||||
return render_template('issues/list.html',
|
||||
issues=issues,
|
||||
issues=issues_paged,
|
||||
issue_items=filtered_items,
|
||||
severity_filter=severity_filter,
|
||||
status_filter=status_filter,
|
||||
sla_filter=sla_filter,
|
||||
followed_ids=followed_ids)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{#
|
||||
_sla_badge.html — Reusable SLA status badge macro.
|
||||
|
||||
Usage:
|
||||
{% from '_sla_badge.html' import sla_badge %}
|
||||
{{ sla_badge(issue) }}
|
||||
#}
|
||||
|
||||
{% macro sla_badge(issue) %}
|
||||
{% set status = sla_status(issue) %}
|
||||
{% if status == 'breached' %}
|
||||
<span class="badge sla-breached" title="SLA breached — past deadline">
|
||||
<i class="bi bi-alarm me-1"></i>SLA Breached
|
||||
</span>
|
||||
{% elif status == 'at_risk' %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<span class="badge sla-at-risk" title="SLA at risk — {{ hrs }}h remaining">
|
||||
<i class="bi bi-alarm me-1"></i>At Risk{% if hrs is not none %} · {{ hrs }}h{% endif %}
|
||||
</span>
|
||||
{% elif status == 'ok' %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<span class="badge sla-ok" title="Within SLA — {{ hrs }}h remaining">
|
||||
<i class="bi bi-check-circle me-1"></i>On Track{% if hrs is not none %} · {{ hrs }}h{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{# resolved issues show nothing #}
|
||||
{% endmacro %}
|
||||
+196
-88
@@ -1,112 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h2>Welcome, {{ current_user.username }}!</h2>
|
||||
<p class="text-muted">
|
||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %}">
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="mb-0">Welcome, {{ current_user.username }}!</h2>
|
||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %} mt-1">
|
||||
{{ current_user.role|title }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-4">
|
||||
{# ── Top stat cards ─────────────────────────────────────────────────────── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-white bg-primary h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-title text-white-50">Today's Inspections</h6>
|
||||
<h2 class="mb-0">{{ today_inspections }}</h2>
|
||||
<div class="small text-white-50 fw-semibold">Today's Inspections</div>
|
||||
<div class="fs-2 fw-bold">{{ today_inspections }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-clipboard-data" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||
<i class="bi bi-clipboard-data" style="font-size:2.5rem;opacity:.25;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-white bg-success h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-title text-white-50">Completed Today</h6>
|
||||
<h2 class="mb-0">{{ completed_today }}</h2>
|
||||
<div class="small text-white-50 fw-semibold">Completed Today</div>
|
||||
<div class="fs-2 fw-bold">{{ completed_today }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-check-circle" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||
<i class="bi bi-check-circle" style="font-size:2.5rem;opacity:.25;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-white bg-warning h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-title text-white-50">Open Issues</h6>
|
||||
<h2 class="mb-0">{{ open_issues }}</h2>
|
||||
<div class="small text-white-50 fw-semibold">Open Issues</div>
|
||||
<div class="fs-2 fw-bold">{{ open_issues }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-exclamation-triangle" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||
<i class="bi bi-exclamation-triangle" style="font-size:2.5rem;opacity:.25;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-white bg-info h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="card-title text-white-50">Avg Score (30d)</h6>
|
||||
<h2 class="mb-0">{{ avg_score if avg_score else '--' }}%</h2>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-graph-up" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||
</div>
|
||||
<div class="small text-white-50 fw-semibold">Avg Score (30d)</div>
|
||||
<div class="fs-2 fw-bold">{{ avg_score if avg_score else '--' }}{% if avg_score %}%{% endif %}</div>
|
||||
</div>
|
||||
<i class="bi bi-graph-up" style="font-size:2.5rem;opacity:.25;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
|
||||
{% if sla_breached > 0 or sla_at_risk > 0 %}
|
||||
<div class="row g-3 mb-4">
|
||||
{% if sla_breached > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<a href="{{ url_for('issues.index', sla='breached') }}" class="text-decoration-none">
|
||||
<div class="card border-danger h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small text-danger fw-semibold">SLA Breached</div>
|
||||
<div class="fs-2 fw-bold text-danger">{{ sla_breached }}</div>
|
||||
</div>
|
||||
<i class="bi bi-alarm text-danger" style="font-size:2.5rem;opacity:.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sla_at_risk > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<a href="{{ url_for('issues.index', sla='at_risk') }}" class="text-decoration-none">
|
||||
<div class="card border-warning h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small text-warning fw-semibold">SLA At Risk</div>
|
||||
<div class="fs-2 fw-bold text-warning">{{ sla_at_risk }}</div>
|
||||
</div>
|
||||
<i class="bi bi-alarm text-warning" style="font-size:2.5rem;opacity:.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── System quick stats (admin/supervisor) ──────────────────────────────── #}
|
||||
{% if current_user.role in ['admin', 'supervisor'] %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-building text-primary" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-2">{{ total_facilities }}</h3>
|
||||
<p class="text-muted mb-0">Active Facilities</p>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}">
|
||||
<div class="card shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<i class="bi bi-building text-primary" style="font-size:2rem;"></i>
|
||||
<div class="fs-4 fw-bold mt-1">{{ total_facilities }}</div>
|
||||
<div class="text-muted small">Active Facilities</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-file-earmark-text text-success" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-2">{{ total_templates }}</h3>
|
||||
<p class="text-muted mb-0">Templates</p>
|
||||
<div class="col-6 col-md-{% if current_user.role == 'admin' %}4{% else %}6{% endif %}">
|
||||
<div class="card shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<i class="bi bi-file-earmark-text text-success" style="font-size:2rem;"></i>
|
||||
<div class="fs-4 fw-bold mt-1">{{ total_templates }}</div>
|
||||
<div class="text-muted small">Templates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-people text-warning" style="font-size: 2.5rem;"></i>
|
||||
<h3 class="mt-2">{{ total_users }}</h3>
|
||||
<p class="text-muted mb-0">Users</p>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<i class="bi bi-people text-warning" style="font-size:2rem;"></i>
|
||||
<div class="fs-4 fw-bold mt-1">{{ total_users }}</div>
|
||||
<div class="text-muted small">Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,13 +130,67 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Recent Activity</h5>
|
||||
{# ── Score trend chart + Facility performance ───────────────────────────── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-lg-{% if current_user.role in ['admin','supervisor'] 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', 'supervisor'] 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>
|
||||
|
||||
{# ── Recent activity ─────────────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-clock-history me-1"></i>Recent Activity
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_inspections %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
@@ -129,30 +199,30 @@
|
||||
<th>Date</th>
|
||||
<th>Facility</th>
|
||||
<th>Area</th>
|
||||
<th>Inspector</th>
|
||||
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %}
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inspection in recent_inspections %}
|
||||
<tr>
|
||||
<td>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ inspection.facility.name }}</td>
|
||||
<td>{{ inspection.area.name if inspection.area else 'N/A' }}</td>
|
||||
<td>{{ inspection.inspector.username }}</td>
|
||||
{% for insp in recent_inspections %}
|
||||
<tr style="cursor:pointer;" onclick="window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
|
||||
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>{{ insp.facility.name }}</td>
|
||||
<td>{{ insp.area.name if insp.area else '—' }}</td>
|
||||
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.username }}</td>{% endif %}
|
||||
<td>
|
||||
{% if inspection.overall_score %}
|
||||
<span class="badge bg-{% if inspection.overall_score >= 90 %}success{% elif inspection.overall_score >= 70 %}warning{% else %}danger{% endif %}">
|
||||
{{ inspection.overall_score }}%
|
||||
{% if insp.overall_score %}
|
||||
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
|
||||
{{ insp.overall_score }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">--</span>
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if inspection.status == 'completed' %}success{% elif inspection.status == 'flagged' %}danger{% else %}secondary{% endif %}">
|
||||
{{ inspection.status|title }}
|
||||
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
|
||||
{{ insp.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -161,12 +231,50 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i> No recent inspections to display.
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if trend_labels %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<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 %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% from '_sla_badge.html' import sla_badge %}
|
||||
{% block title %}Issues{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||
{% if current_user.role in ['admin','supervisor'] %}
|
||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Log Issue
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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">Severity</label>
|
||||
<select name="severity" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['critical','high','medium','low'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['open','in_progress','resolved'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">SLA</label>
|
||||
<select name="sla" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
|
||||
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
|
||||
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>On Track</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if issue_items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Reported</th>
|
||||
<th>Severity</th>
|
||||
<th>Facility / Area</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>SLA</th>
|
||||
<th>Assigned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issue_items %}
|
||||
{% set is_following = issue.id in followed_ids %}
|
||||
<tr>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ issue.area.facility.name }}<br>
|
||||
<small class="text-muted">{{ issue.area.name }}</small>
|
||||
</td>
|
||||
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ sla_badge(issue) }}</td>
|
||||
<td>
|
||||
{% if issue.assigned_user %}{{ issue.assigned_user.username }}
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{# Following badge + inline unfollow #}
|
||||
{% if is_following %}
|
||||
<span class="badge bg-primary me-1" title="You are following this issue">
|
||||
<i class="bi bi-bell-fill"></i> Following
|
||||
</span>
|
||||
<form method="post"
|
||||
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
|
||||
class="d-inline"
|
||||
title="Unfollow this issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, severity=severity_filter, status=status_filter) }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||
title="Unfollow">
|
||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
{% else %}
|
||||
<i class="bi bi-eye"></i> View
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if issues.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == issues.page }}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('issues.index', page=p, severity=severity_filter, status=status_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No issues found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,225 @@
|
||||
{% extends "base.html" %}
|
||||
{% from '_sla_badge.html' import sla_badge %}
|
||||
{% block title %}Issue #{{ issue.id }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning' if issue.severity == 'medium' else 'secondary' }}
|
||||
text-{{ 'white' if issue.severity in ['critical','high','low'] else 'dark' }}">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issue #{{ issue.id }} — {{ issue.severity|title }} Severity</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-light text-dark">{{ issue.status|replace('_',' ')|title }}</span>
|
||||
{{ sla_badge(issue) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Reported</dt>
|
||||
<dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
|
||||
{% set deadline = sla_deadline(issue) %}
|
||||
{% if deadline and issue.status != 'resolved' %}
|
||||
<dt class="col-sm-3">SLA Deadline</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{ deadline.strftime('%Y-%m-%d %H:%M') }}
|
||||
{{ sla_badge(issue) }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">Facility</dt>
|
||||
<dd class="col-sm-9">{{ issue.area.facility.name }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Area</dt>
|
||||
<dd class="col-sm-9">{{ issue.area.name }}</dd>
|
||||
|
||||
{% if issue.inspection %}
|
||||
<dt class="col-sm-3">Inspection</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a href="{{ url_for('inspections.view', inspection_id=issue.inspection_id) }}">#{{ issue.inspection_id }}</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">Assigned To</dt>
|
||||
<dd class="col-sm-9">{{ issue.assigned_user.username if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||
|
||||
{% if issue.resolved_at %}
|
||||
<dt class="col-sm-3">Resolved</dt>
|
||||
<dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
<h6>Description</h6>
|
||||
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
|
||||
|
||||
{% if issue.photo_path %}
|
||||
<hr>
|
||||
<h6>Photo Evidence</h6>
|
||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=issue.photo_path) }}" class="img-fluid rounded" style="max-height:300px;">
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.result_notes or issue.result_photos %}
|
||||
<hr>
|
||||
<h6><i class="bi bi-clipboard2-check text-success"></i> Resolution Details</h6>
|
||||
{% if issue.result_notes %}
|
||||
<p class="mb-2" style="white-space:pre-wrap;">{{ issue.result_notes }}</p>
|
||||
{% endif %}
|
||||
{% if issue.result_photos %}
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
{% for photo in issue.result_photos %}
|
||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=photo) }}"
|
||||
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
|
||||
alt="Result photo">
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Update History ─────────────────────────────────────────────────── #}
|
||||
{% if comments %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0" id="update-history"><i class="bi bi-clock-history"></i> Update History</h6>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for c in comments %}
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-semibold text-dark">
|
||||
<i class="bi bi-person-circle"></i> {{ c.author.username }}
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }} rounded-pill" style="font-size:.65rem;">
|
||||
{{ c.status_at_time|replace('_',' ')|title }}
|
||||
</span>
|
||||
<small class="text-muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-0 text-secondary" style="white-space:pre-wrap; font-size:.9rem;">{{ c.body }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
|
||||
{# ── Follow / Unfollow ──────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body d-flex align-items-center justify-content-between py-2">
|
||||
<div>
|
||||
<i class="bi bi-bell{{ '-fill text-primary' if is_following else ' text-muted' }} me-1"></i>
|
||||
<span class="fw-semibold" style="font-size:.9rem;">
|
||||
{% if is_following %}Following{% else %}Not following{% endif %}
|
||||
</span>
|
||||
<span class="text-muted ms-2" style="font-size:.8rem;">
|
||||
{{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
{% if is_following %}
|
||||
<form method="post" action="{{ url_for('issues.unfollow', issue_id=issue.id) }}" class="mb-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-bell-slash"></i> Unfollow
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" class="mb-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-bell"></i> Follow
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Update Form ────────────────────────────────────────────────────── #}
|
||||
{% set can_edit = current_user.role in ['admin','supervisor'] or issue.assigned_to == current_user.id %}
|
||||
{% if can_edit %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
{{ form.status(class="form-select") }}
|
||||
</div>
|
||||
{% if current_user.role in ['admin','supervisor'] %}
|
||||
<div class="mb-3">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
{{ form.update_notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.update_notes(class="form-control", rows=3, placeholder="Optional update notes…") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.result_notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.result_notes(class="form-control", rows=3,
|
||||
placeholder="Describe what was done to resolve this issue…",
|
||||
value=issue.result_notes or '') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Result Photos</label>
|
||||
<input type="file" name="result_photos" id="result_photos"
|
||||
class="form-control" accept="image/*" multiple>
|
||||
<div class="form-text">Attach one or more photos showing the resolution.</div>
|
||||
{% if issue.result_photos %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">{{ issue.result_photos|length }} photo(s) already uploaded</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Save Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||
</a>
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Immediate bell refresh after a successful update ──────────────────
|
||||
// After form submit + redirect, Flask flashes 'Issue updated.' and the
|
||||
// URL stays at /issues/<id>. We detect the flash message presence and
|
||||
// call the global fetchNotifications() defined in base.html so the bell
|
||||
// badge updates instantly without waiting for the 60-second poll cycle.
|
||||
if (document.querySelector('.alert-success')) {
|
||||
if (typeof fetchNotifications === 'function') {
|
||||
fetchNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-scroll to Update History after save ──────────────────────────
|
||||
// If there's a success flash AND comments exist, scroll the history
|
||||
// section into view so the newly added comment is immediately visible.
|
||||
if (document.querySelector('.alert-success')) {
|
||||
var historyEl = document.getElementById('update-history');
|
||||
if (historyEl) {
|
||||
historyEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
sla.py
|
||||
------
|
||||
Issue SLA (Service Level Agreement) helpers.
|
||||
|
||||
SLA thresholds define the maximum number of hours an issue of a given
|
||||
severity may remain unresolved before it is considered breached.
|
||||
|
||||
Statuses
|
||||
--------
|
||||
ok — within the allowed window
|
||||
at_risk — past 75% of the allowed window but not yet breached
|
||||
breached — past the deadline
|
||||
None — issue is already resolved; SLA no longer applies
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
# ── Configurable thresholds (hours) ──────────────────────────────────────────
|
||||
SLA_HOURS = {
|
||||
'critical': 4,
|
||||
'high': 24,
|
||||
'medium': 72,
|
||||
'low': 168, # 7 days
|
||||
}
|
||||
|
||||
# Fraction of the window at which an issue becomes "at risk"
|
||||
AT_RISK_THRESHOLD = 0.75
|
||||
|
||||
|
||||
def sla_deadline(issue):
|
||||
"""
|
||||
Return the datetime by which the issue must be resolved,
|
||||
or None if the severity is not recognised.
|
||||
"""
|
||||
hours = SLA_HOURS.get(issue.severity)
|
||||
if hours is None:
|
||||
return None
|
||||
return issue.reported_at + timedelta(hours=hours)
|
||||
|
||||
|
||||
def sla_status(issue):
|
||||
"""
|
||||
Return one of: 'ok', 'at_risk', 'breached', or None.
|
||||
|
||||
None is returned when the issue is already resolved — SLA no longer
|
||||
applies. None is also returned for unrecognised severity values.
|
||||
"""
|
||||
if issue.status == 'resolved':
|
||||
return None
|
||||
|
||||
hours = SLA_HOURS.get(issue.severity)
|
||||
if hours is None:
|
||||
return None
|
||||
|
||||
deadline = issue.reported_at + timedelta(hours=hours)
|
||||
at_risk_at = issue.reported_at + timedelta(hours=hours * AT_RISK_THRESHOLD)
|
||||
now = now_eastern()
|
||||
|
||||
if now >= deadline:
|
||||
return 'breached'
|
||||
if now >= at_risk_at:
|
||||
return 'at_risk'
|
||||
return 'ok'
|
||||
|
||||
|
||||
def sla_hours_remaining(issue):
|
||||
"""
|
||||
Return the number of hours remaining before the SLA deadline.
|
||||
Negative values indicate the deadline has already passed.
|
||||
Returns None for resolved issues or unrecognised severities.
|
||||
"""
|
||||
if issue.status == 'resolved':
|
||||
return None
|
||||
deadline = sla_deadline(issue)
|
||||
if deadline is None:
|
||||
return None
|
||||
delta = deadline - now_eastern()
|
||||
return round(delta.total_seconds() / 3600, 1)
|
||||
Reference in New Issue
Block a user