422 lines
20 KiB
HTML
422 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Dashboard{% endblock %}
|
|
|
|
{#
|
|
MODERN dashboard (design A/B test — slide 1 of JQC_design).
|
|
|
|
Uses exactly the same context variables as templates/dashboard.html — the
|
|
dashboard.index route is untouched. Every tile links to the same filtered
|
|
list view the classic dashboard links to, so no navigation path is lost.
|
|
#}
|
|
|
|
{% block content %}
|
|
|
|
{# ── Header ───────────────────────────────────────────────────────────── #}
|
|
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
|
<div>
|
|
<div class="jqc-page-title">Welcome, {{ current_user.display_name }}</div>
|
|
<div class="jqc-page-sub">{{ current_user.role.replace('_',' ')|title }}</div>
|
|
</div>
|
|
<div class="text-muted">{{ now_display }}</div>
|
|
</div>
|
|
|
|
{# ── Scheduled inspections ────────────────────────────────────────────── #}
|
|
{% if current_user.role != 'customer' %}
|
|
<div class="jqc-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="jqc-card-title mb-0">
|
|
<span class="jqc-tile-icon"><i class="bi bi-calendar2-week"></i></span>Scheduled Inspection In Progress
|
|
</div>
|
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
|
</div>
|
|
|
|
{% if sched_overdue_count %}
|
|
<div class="alert alert-danger py-2">
|
|
<i class="bi bi-alarm-fill me-1"></i>
|
|
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
|
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if sched_upcoming %}
|
|
<div class="jqc-table-wrap table-responsive">
|
|
<table class="table table-hover align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th>Facility</th><th>Inspection Template</th><th>Inspector</th>
|
|
<th>How Often</th><th>Next Due Date</th><th class="text-end"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for s in sched_upcoming %}
|
|
<tr>
|
|
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
|
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
|
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
|
<td class="small text-muted">{{ s.recurrence_label }}</td>
|
|
{# MT's column is next_run_at (ST calls it next_due_date) — via the
|
|
due_date property, guarded: an active schedule can carry a NULL
|
|
next_run_at, and .strftime() on Undefined/None is a 500. #}
|
|
<td class="small">{{ s.due_date.strftime('%b %d, %Y') if s.due_date else '—' }}</td>
|
|
<td class="text-end text-nowrap">
|
|
{% if s.inspector_id and s.inspector_id == current_user.id %}
|
|
{% if s.is_acknowledged %}
|
|
<span class="badge bg-success" title="You confirmed receipt"><i class="bi bi-check-circle"></i> Confirmed</span>
|
|
{% else %}
|
|
<form method="POST" class="d-inline" action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-success py-0"
|
|
title="Confirm you received this request">
|
|
<i class="bi bi-check-lg"></i> Confirm</button>
|
|
</form>
|
|
{% endif %}
|
|
{% set open_id = sched_open_inspections.get(s.id) %}
|
|
{% if open_id %}
|
|
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
|
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
|
|
<i class="bi bi-pencil-square"></i> Continue</a>
|
|
{% else %}
|
|
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
|
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
|
{% endif %}
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-muted small"><i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ── KPI row ──────────────────────────────────────────────────────────── #}
|
|
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
|
|
<div class="col">
|
|
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
|
|
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>
|
|
<div class="jqc-kpi-value">{{ completed_today }}</div>
|
|
<div class="jqc-kpi-label">Submitted Today</div>
|
|
</a>
|
|
</div>
|
|
<div class="col">
|
|
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
|
|
<span class="jqc-tile-icon"><i class="bi bi-calendar-week"></i></span>
|
|
<div class="jqc-kpi-value">{{ submitted_this_week }}</div>
|
|
<div class="jqc-kpi-label">Submitted This Week</div>
|
|
</a>
|
|
</div>
|
|
<div class="col">
|
|
<a class="jqc-kpi" href="{{ url_for('issues.index', status='open') }}">
|
|
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>
|
|
<div class="jqc-kpi-value">{{ open_issues }}</div>
|
|
<div class="jqc-kpi-label">Open Issues</div>
|
|
</a>
|
|
</div>
|
|
<div class="col">
|
|
{% if current_user.role != 'customer' %}
|
|
<a class="jqc-kpi" href="{{ url_for('inspection_schedules.index') }}">
|
|
<span class="jqc-tile-icon"><i class="bi bi-calendar2-check"></i></span>
|
|
<div class="jqc-kpi-value">{{ sched_total }}</div>
|
|
<div class="jqc-kpi-label">On Schedules</div>
|
|
</a>
|
|
{% else %}
|
|
<a class="jqc-kpi" href="{{ url_for('facilities.list_facilities') }}">
|
|
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>
|
|
<div class="jqc-kpi-value">{{ customer_facilities|length if customer_facilities else 0 }}</div>
|
|
<div class="jqc-kpi-label">Your Facilities</div>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Three summary cards ──────────────────────────────────────────────── #}
|
|
<div class="row g-3 mb-2">
|
|
|
|
<!-- Inspection -->
|
|
<div class="col-12 col-lg-4">
|
|
<div class="jqc-card h-100">
|
|
<div class="jqc-card-title">
|
|
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>Inspection
|
|
</div>
|
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
|
|
<span class="jqc-stat-label">Submitted Today</span>
|
|
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ completed_today }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
|
|
<span class="jqc-stat-label">Submitted This Week</span>
|
|
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ submitted_this_week }}</span>
|
|
</a>
|
|
{% if current_user.role != 'customer' %}
|
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='in_progress') }}">
|
|
<span class="jqc-stat-label">
|
|
In Process
|
|
{% if stale_in_progress %}<span class="badge bg-warning text-dark">{{ stale_in_progress }} stale</span>{% endif %}
|
|
</span>
|
|
<span class="jqc-stat-value" style="color:#1B9AD1;">{{ in_progress_total }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='follow_up') }}">
|
|
<span class="jqc-stat-label">Pending to follow up</span>
|
|
<span class="jqc-stat-value" style="color:#E0A800;">{{ pending_followups }}</span>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Open Issues -->
|
|
<div class="col-12 col-lg-4">
|
|
<div class="jqc-card h-100">
|
|
<div class="jqc-card-title">
|
|
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>Open Issues
|
|
</div>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='internal') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E8722C;"></span>Janitorial</span>
|
|
<span class="jqc-stat-value">{{ handler_breakdown.internal }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='facility') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#155F82;"></span>Facility Staff</span>
|
|
<span class="jqc-stat-value">{{ handler_breakdown.facility }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='vendor') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Vendors</span>
|
|
<span class="jqc-stat-value">{{ handler_breakdown.vendor }}</span>
|
|
</a>
|
|
{% if current_user.role != 'customer' %}
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='pending_verification') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>Pending Verification</span>
|
|
<span class="jqc-stat-value">{{ pending_verification }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', unassigned='1') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>Unassigned Issue</span>
|
|
<span class="jqc-stat-value">{{ unassigned_open }}</span>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SLA Issues -->
|
|
<div class="col-12 col-lg-4">
|
|
<div class="jqc-card h-100">
|
|
<div class="jqc-card-title">
|
|
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>SLA Issues
|
|
</div>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='breached') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>SLA Alert</span>
|
|
<span class="jqc-stat-value" style="color:#D9534F;">{{ sla_breached }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='at_risk') }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>SLA At Risk</span>
|
|
<span class="jqc-stat-value" style="color:#E0A800;">{{ sla_at_risk }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Issues Opened Today</span>
|
|
<span class="jqc-stat-value">{{ issues_opened_today }}</span>
|
|
</a>
|
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}">
|
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#2E7D4F;"></span>Resolved Today</span>
|
|
<span class="jqc-stat-value" style="color:#2E7D4F;">{{ resolved_today }}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Recent activity ──────────────────────────────────────────────────── #}
|
|
<div class="jqc-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="jqc-card-title mb-0">
|
|
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>Recent Activities
|
|
</div>
|
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
|
</div>
|
|
{% if recent_inspections %}
|
|
<div class="jqc-table-wrap table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Facility Name</th>
|
|
<th>Area</th>
|
|
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
|
|
<th>Score</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for insp in recent_inspections %}
|
|
{# The row stays click-anywhere for the mouse, but the date is a real
|
|
link so the row is keyboard-reachable and openable in a new tab.
|
|
The guard stops the row handler from double-firing on that link. #}
|
|
<tr style="cursor:pointer;"
|
|
onclick="if(!event.target.closest('a')) window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
|
|
<td>
|
|
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
|
|
class="text-decoration-none"><small>{{ insp.inspection_date.strftime('%b %d, %Y') }}</small></a>
|
|
</td>
|
|
<td>{{ insp.facility.name }}</td>
|
|
<td>{{ insp.area.name if insp.area else '—' }}</td>
|
|
{% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
|
|
<td>
|
|
{% 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>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
|
|
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<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>
|
|
|
|
{# ── My open issues (inspector widget) ────────────────────────────────── #}
|
|
{% if my_issues %}
|
|
<div class="jqc-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="jqc-card-title mb-0">
|
|
<span class="jqc-tile-icon"><i class="bi bi-person-check"></i></span>My Open Issues
|
|
</div>
|
|
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
|
</div>
|
|
<div class="jqc-table-wrap table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:60px;">ID</th>
|
|
<th style="width:90px;">Severity</th>
|
|
<th>Facility / Description</th>
|
|
<th style="width:100px;">Status</th>
|
|
<th style="width:120px;">SLA</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for issue in my_issues %}
|
|
{% set sla = sla_status(issue) %}
|
|
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
|
<td>
|
|
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
|
|
</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>
|
|
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
|
|
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
|
{{ issue.status|replace('_',' ')|title }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{% if sla == 'breached' %}
|
|
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
|
|
{% elif sla == 'at_risk' %}
|
|
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary">OK</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ── Customer portal: scoped facilities panel ─────────────────────────── #}
|
|
{% if current_user.role == 'customer' and customer_facilities %}
|
|
<div class="jqc-card">
|
|
<div class="jqc-card-title">
|
|
<span class="jqc-tile-icon"><i class="bi bi-building"></i></span>Your Facilities
|
|
<span class="badge bg-secondary rounded-pill ms-2">{{ customer_facilities|length }}</span>
|
|
</div>
|
|
{% if customer_facilities|length > 6 %}
|
|
<div class="mb-3">
|
|
<input type="text" id="facilitySearch" class="form-control form-control-sm"
|
|
placeholder="Search facilities…" aria-label="Search facilities">
|
|
</div>
|
|
{% endif %}
|
|
<div class="row g-3" id="facilityGrid">
|
|
{% for f in customer_facilities %}
|
|
<div class="col-12 col-sm-6 col-lg-4 facility-col">
|
|
<div class="border rounded-3 p-3 h-100 d-flex flex-column facility-card">
|
|
<div class="fw-semibold mb-1">{{ f.name }}</div>
|
|
<div class="text-muted" style="font-size:.82rem;">{{ f.address or '—' }}</div>
|
|
<div class="my-2">
|
|
<span class="badge bg-light text-dark border">{{ f.project.name if f.project else '—' }}</span>
|
|
</div>
|
|
<div class="mt-auto pt-1">
|
|
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
|
|
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
|
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
|
|
class="btn btn-sm btn-outline-secondary ms-1"><i class="bi bi-graph-up"></i> Report</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% if customer_facilities|length > 9 %}
|
|
<div id="facilityShowMore" class="text-center mt-3">
|
|
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
|
|
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Same VISIBLE = 9 / search > 6 thresholds as the classic dashboard (rule 65). #}
|
|
<script>
|
|
(function () {
|
|
{% if customer_facilities|length > 9 %}
|
|
var VISIBLE = 9;
|
|
var cols = document.querySelectorAll('#facilityGrid .facility-col');
|
|
var btn = document.getElementById('toggleFacilities');
|
|
var expanded = false;
|
|
|
|
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
|
|
|
|
btn.addEventListener('click', function () {
|
|
expanded = !expanded;
|
|
cols.forEach(function (c, i) {
|
|
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
|
|
});
|
|
btn.innerHTML = expanded
|
|
? 'Show fewer <i class="bi bi-chevron-up"></i>'
|
|
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
|
|
});
|
|
{% endif %}
|
|
{% if customer_facilities|length > 6 %}
|
|
document.getElementById('facilitySearch').addEventListener('input', function () {
|
|
var q = this.value.toLowerCase();
|
|
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
|
|
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
|
|
col.style.display = match ? '' : 'none';
|
|
});
|
|
var more = document.getElementById('facilityShowMore');
|
|
if (more) more.style.display = this.value ? 'none' : '';
|
|
});
|
|
{% endif %}
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
|
|
{% endblock %}
|