Aug 5 - Add new design (switchable)

This commit is contained in:
2026-08-05 14:07:38 -04:00
parent 07379bd915
commit f92d8783bf
15 changed files with 2899 additions and 568 deletions
+13 -511
View File
@@ -1,515 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
{# ══════════════════════════════════════════════════════════════════════════
base.html — layout dispatcher (design A/B test)
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
Every page template still does {% extends "base.html" %} exactly as before.
This file no longer holds any markup; it forwards to whichever shell the
current user has selected:
/* ── Shared list-page filter panel (matches dashboard blue theme) ── */
.filter-panel {
background: #eaf2fc; /* light blue tint */
border: 1px solid #bcd2ee;
border-left: 4px solid #4a90d9; /* dashboard blue accent */
border-radius: 12px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: #2f6db3;
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
/* Give every input/select a clear border so they stand out on the tint */
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #9db8db;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 .18rem rgba(74, 144, 217, .25);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
jqc_layout == 'layouts/classic.html' → the original top-navbar design
jqc_layout == 'layouts/modern.html' → the new sidebar design
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-lg-block">
<a class="nav-link position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
`jqc_layout` is injected by the inject_ui_theme() context processor in
app/__init__.py, driven by users.ui_theme (see app/routes/ui.py).
<!-- User menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
Jinja resolves {% block %} overrides through the whole inheritance chain,
so child templates need no change at all.
══════════════════════════════════════════════════════════════════════════ #}
{% extends jqc_layout %}
+527
View File
@@ -0,0 +1,527 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
/* ── Shared list-page filter panel (matches dashboard blue theme) ── */
.filter-panel {
background: #eaf2fc; /* light blue tint */
border: 1px solid #bcd2ee;
border-left: 4px solid #4a90d9; /* dashboard blue accent */
border-radius: 12px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: #2f6db3;
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
/* Give every input/select a clear border so they stand out on the tint */
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #9db8db;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 .18rem rgba(74, 144, 217, .25);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-lg-block">
<a class="nav-link position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
<!-- User menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
{# ── Design switcher (classic → modern) ── #}
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="modern">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-stars me-1"></i>Try the New Design
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+479
View File
@@ -0,0 +1,479 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{# theme_modern.css loads LAST so it wins over theme.css tokens #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme_modern.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles (shared with the classic layout) ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: var(--jqc-brand);
background-color: #f0f6fa;
}
.notif-item:hover { background-color: #e9f0f8; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Shared list-page filter panel (modern tint) ── */
.filter-panel {
background: #ffffff;
border: 1px solid var(--jqc-border);
border-left: 4px solid var(--jqc-brand);
border-radius: 14px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--jqc-brand);
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #cfd9e0;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: var(--jqc-brand);
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .20);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body class="jqc-modern">
{% if current_user.is_authenticated %}
<!-- ══════════════════════════ TOP BAR ══════════════════════════ -->
<header class="jqc-topbar">
<button class="jqc-hamburger d-lg-none" type="button" id="jqcSidebarToggle" aria-label="Menu">
<i class="bi bi-list"></i>
</button>
<a class="jqc-brand" href="{{ url_for('dashboard.index') }}">
<span class="jqc-brand-mark">JQC</span>
<span class="jqc-brand-sub">By L.T. Services, Inc</span>
</a>
<form class="jqc-search" method="GET" action="{{ url_for('inspections.index') }}" role="search">
<i class="bi bi-search"></i>
<input type="text" name="inspection_id" class="form-control"
placeholder="Search inspection number" aria-label="Search inspection number">
</form>
<div class="jqc-topbar-actions">
<!-- ── Notification Bell ── -->
<div class="dropdown">
<a class="jqc-icon-btn position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
<!-- ── User avatar menu ── -->
<div class="dropdown">
<a class="jqc-avatar" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
title="{{ current_user.display_name }}">
{{ (current_user.display_name.split() | map('first') | join)[:2] | upper }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li class="px-3 py-2 border-bottom">
<div class="fw-semibold" style="font-size:.9rem;">{{ current_user.display_name }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ current_user.role.replace('_',' ')|title }}</div>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
{# ── Design switcher (modern → classic) ── #}
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="classic">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-arrow-counterclockwise me-1"></i>Back to Classic Design
</button>
</form>
</li>
{% if current_user.role == 'admin' %}
<li>
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
</a>
</li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</div>
</div>
</header>
<!-- ══════════════════════════ SIDEBAR ══════════════════════════ -->
<aside class="jqc-sidebar" id="jqcSidebar">
<nav class="jqc-nav">
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}"
href="{{ url_for('dashboard.index') }}">
<i class="bi bi-grid"></i><span>Dashboard</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('inspections.') or request.endpoint.startswith('scheduled_inspections.')) }}"
href="{{ url_for('inspections.index') }}">
<i class="bi bi-clipboard-check"></i><span>Inspections</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}"
href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart-fill"></i><span>Reports &amp; Analytics</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}"
href="{{ url_for('issues.index') }}">
<i class="bi bi-exclamation-triangle"></i><span>Issues</span>
</a>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
<i class="bi bi-patch-check"></i><span>Verify</span>
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="jqc-nav-badge">{{ pending_verification_count }}</span>
{% endif %}
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}"
href="{{ url_for('projects.index') }}">
<i class="bi bi-file-earmark-text"></i><span>Contract</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}"
href="{{ url_for('facilities.list_facilities') }}">
<i class="bi bi-buildings"></i><span>Facility</span>
</a>
{% if current_user.role in ['admin', 'director'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}"
href="{{ url_for('templates.index') }}">
<i class="bi bi-list-check"></i><span>Templates</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}"
href="{{ url_for('customers.index') }}">
<i class="bi bi-people"></i><span>Customer</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('support.') or request.endpoint == 'ui.support_center') }}"
href="{{ url_for('ui.support_center') }}">
<i class="bi bi-life-preserver"></i><span>Supports</span>
{% if open_support_tickets_count > 0 %}
<span class="jqc-nav-badge">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
href="#jqcAdminMenu" role="button" aria-expanded="{{ 'true' if admin_active else 'false' }}">
<i class="bi bi-shield-lock"></i><span>Admin</span>
<i class="bi bi-chevron-down jqc-nav-caret"></i>
</a>
<div class="collapse {{ 'show' if admin_active }}" id="jqcAdminMenu">
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</div>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'ui.about' }}" href="{{ url_for('ui.about') }}">
<i class="bi bi-info-circle"></i><span>About Us</span>
</a>
</nav>
<div class="jqc-sidebar-foot">
<form method="POST" action="{{ url_for('ui.switch_theme') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="classic">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="jqc-switch-btn" title="Switch back to the classic design">
<i class="bi bi-arrow-left-right"></i> Classic Design
</button>
</form>
</div>
</aside>
<div class="jqc-sidebar-backdrop d-lg-none" id="jqcSidebarBackdrop"></div>
{% endif %}
<!-- ══════════════════════════ MAIN ══════════════════════════ -->
<main class="{{ 'jqc-main' if current_user.is_authenticated else '' }}">
<div class="container-fluid {{ '' if current_user.is_authenticated else 'mt-4' }}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
// ── Sidebar off-canvas toggle (mobile / tablet portrait) ──────────────
(function () {
var btn = document.getElementById('jqcSidebarToggle');
var sidebar = document.getElementById('jqcSidebar');
var backdrop = document.getElementById('jqcSidebarBackdrop');
if (!btn || !sidebar) return;
function close() {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('show');
}
btn.addEventListener('click', function () {
sidebar.classList.toggle('open');
if (backdrop) backdrop.classList.toggle('show');
});
if (backdrop) backdrop.addEventListener('click', close);
})();
</script>
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
const badgeDesktop = document.getElementById('notif-count-badge');
const listDesktop = document.getElementById('notif-list');
function updateBadge(count) {
[badgeDesktop].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
if (deskOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
['notifDropdown'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+408
View File
@@ -0,0 +1,408 @@
{% 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" style="text-align:left;">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>
{# ── 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('scheduled_inspections.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>
{# ── 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('scheduled_inspections.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>
<td class="small">{{ s.next_due_date.strftime('%b %d, %Y') }}</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('scheduled_inspections.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('scheduled_inspections.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 %}
{# ── 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 current_user.role != 'inspector' %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% 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('%b %d, %Y') }}</small></td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != '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>
{# ── Inspector activity today (admin / director / PM) ─────────────────── #}
{% if inspector_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-people"></i></span>Inspector Activity Today
</div>
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}"
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>Inspector</th>
<th class="text-center" style="width:150px;">Submitted Today</th>
<th style="width:220px;"></th>
</tr>
</thead>
<tbody>
{% for row in inspector_activity %}
<tr>
<td>{{ row.name }}</td>
<td class="text-center">
{% if row.count > 0 %}
<span class="badge bg-success">{{ row.count }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<div class="progress" style="height:6px;">
{% set max_count = inspector_activity | map(attribute='count') | max %}
{% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %}
<div class="progress-bar bg-success" style="width:{{ pct }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── 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>
<div class="row g-3">
{% for f in customer_facilities %}
<div class="col-12 col-sm-6 col-lg-4">
<div class="border rounded-3 p-3 h-100 d-flex flex-column">
<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>
</div>
{% endif %}
{% endblock %}
+271
View File
@@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}Facilities{% endblock %}
{#
MODERN facilities page (design A/B test — slide 5 of JQC_design).
The four hub cards are new; everything below them is the original grouped
facility list, delete modal and JS, unchanged — no functionality removed.
#}
{% block content %}
<div class="jqc-page-head">
<div class="jqc-page-title">Facilities</div>
<div class="jqc-page-sub text-center">Manage facility records, statistics and QR access</div>
</div>
<div class="row g-4 mb-4">
{% if current_user.role != 'inspector' %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('facilities.facility_qr_print_all') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-qr-code"></i></span>
<div>
<div class="jqc-hub-title">Print QR Code</div>
<div class="jqc-hub-text">Generate and print scannable QR codes for every facility entrance and asset.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="#facility-list">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-buildings"></i></span>
<div>
<div class="jqc-hub-title">Facilities Information</div>
<div class="jqc-hub-text">View addresses, contacts, contracts and service details in one place.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('reports.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-pie-chart"></i></span>
<div>
<div class="jqc-hub-title">Facilities Statistics</div>
<div class="jqc-hub-text">Track inspection scores and issue trends by location over time.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('templates.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-gear"></i></span>
<div>
<div class="jqc-hub-title">Customize</div>
<div class="jqc-hub-text">Configure inspection templates, checklist items and scoring for your facilities.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{# ── Original facility list (unchanged) ───────────────────────────────── #}
<div id="facility-list" class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0" style="font-size:1.4rem;font-weight:800;">
<i class="bi bi-building"></i> All Facilities
</h2>
<div>
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.facility_qr_print_all') }}"
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
<i class="bi bi-qr-code"></i> Print All QR Codes
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility
</a>
{% endif %}
</div>
</div>
{% if grouped %}
{% for group_key, group in grouped.items() %}
{# ── Contract group header ────────────────────────────────────────────── #}
{% set collapse_id = 'contract-' ~ loop.index %}
<div class="mb-4">
<div class="d-flex align-items-center mb-2">
<button class="btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2 fw-semibold fs-5"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ collapse_id }}"
aria-expanded="false"
aria-controls="{{ collapse_id }}">
<i class="bi bi-chevron-down contract-chevron" style="transition: transform .2s; transform: rotate(-90deg);"></i>
{% if group.project %}
<i class="bi bi-briefcase text-primary"></i>
{{ group.project.name }}
{% else %}
<i class="bi bi-dash-circle text-secondary"></i>
<span class="text-secondary">No Contract Assigned</span>
{% endif %}
</button>
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
class="btn btn-sm btn-outline-secondary ms-2"
title="View Contract">
<i class="bi bi-arrow-right-circle"></i>
</a>
{% endif %}
</div>
{# ── Collapsible card grid ─────────────────────────────────────────── #}
<div class="collapse" id="{{ collapse_id }}">
<div class="row">
{% for facility in group.facilities %}
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card shadow-sm h-100">
<div class="card-body py-2 px-3">
<div class="mb-1" style="font-size:.875rem;font-weight:600;line-height:1.3;">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="text-decoration-none">
{{ facility.name }}
</a>
{% if not facility.active %}
<span class="badge bg-secondary" style="font-size:.7rem;">Inactive</span>
{% endif %}
</div>
{% if facility.address %}
<p class="card-text text-muted mb-1" style="font-size:.78rem;">
<i class="bi bi-geo-alt"></i> {{ facility.address }}
</p>
{% endif %}
<div class="mt-1">
<small class="text-muted" style="font-size:.78rem;">
<i class="bi bi-diagram-3"></i> {{ facility.areas.count() }} areas
</small>
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 py-2 px-3">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
{% if current_user.role == 'admin' %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-auto"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-facility-id="{{ facility.id }}"
data-facility-name="{{ facility.name }}"
data-inspection-count="{{ facility.inspections.count() }}">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No facilities configured yet.
</div>
{% endif %}
{% if current_user.role == 'admin' %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete:</p>
<p class="fw-bold fs-5" id="modalFacilityName"></p>
<div id="modalWarningBlock" class="alert alert-danger d-none">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this facility.</strong> It has existing inspection records.
Please remove all associated inspections first.
</div>
<div id="modalConfirmBlock">
<p class="text-muted mb-0">This action is <strong>irreversible</strong>. All areas associated with this facility will also be deleted.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteFacilityForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Rotate chevron on collapse toggle ────────────────────────────────
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (btn) {
const target = document.querySelector(btn.getAttribute('data-bs-target'));
if (!target) return;
const chevron = btn.querySelector('.contract-chevron');
target.addEventListener('hide.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(-90deg)';
});
target.addEventListener('show.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(0deg)';
});
});
{% if current_user.role == 'admin' %}
// ── Delete modal wiring ──────────────────────────────────────────────
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const facilityId = button.getAttribute('data-facility-id');
const facilityName = button.getAttribute('data-facility-name');
const inspectionCount = parseInt(button.getAttribute('data-inspection-count'));
document.getElementById('modalFacilityName').textContent = facilityName;
document.getElementById('deleteFacilityForm').action = '/facilities/' + facilityId + '/delete';
const warningBlock = document.getElementById('modalWarningBlock');
const confirmBlock = document.getElementById('modalConfirmBlock');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (inspectionCount > 0) {
warningBlock.classList.remove('d-none');
confirmBlock.classList.add('d-none');
confirmBtn.disabled = true;
} else {
warningBlock.classList.add('d-none');
confirmBlock.classList.remove('d-none');
confirmBtn.disabled = false;
}
});
{% endif %}
});
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}About Us{% endblock %}
{# About Us — new page (design A/B test). Linked from the modern sidebar. #}
{% block content %}
<div class="jqc-page-head">
<div class="jqc-page-title">About Us</div>
<div class="jqc-page-sub text-center">Janitorial Quality Control by L.T. Services, Inc.</div>
</div>
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>L.T. Services, Inc.
</div>
<p class="mb-3">
L.T. Services, Inc. provides commercial janitorial services to public and private
facilities. Quality is verified in the field, not assumed — every contract is backed
by scheduled inspections, documented findings and tracked resolution.
</p>
<p class="mb-0 text-muted">
JQC is our in-house quality control platform. Inspectors work from an offline-capable
iPad app; managers, contract staff and customers work from this web portal. Both share
one record of every inspection, issue and photo.
</p>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-check2-square"></i></span>What JQC Covers
</div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-clipboard-check"></i>Facility inspections &amp; scoring</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-calendar2-week"></i>Recurring inspection schedules</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-exclamation-triangle"></i>Issue tracking with SLA deadlines</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-qr-code"></i>QR reporting from any facility</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-bar-chart"></i>Reports, scorecards and exports</span></div>
</div>
</div>
<div class="col-12">
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact &amp; Support
</div>
<p class="mb-3">
Questions about an inspection, an issue on your site, or access to the portal —
start in the Support Center and we will route it to the right person.
</p>
<a href="{{ url_for('ui.support_center') }}" class="btn btn-primary">
<i class="bi bi-life-preserver me-1"></i>Go to Support
</a>
</div>
</div>
</div>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}Support{% endblock %}
{#
Support Center — new page (design A/B test, slide 6 of JQC_design).
Linked from the modern sidebar. Every card either opens an existing route or
expands an inline how-to, so nothing here dead-ends.
#}
{% block content %}
<div class="jqc-page-head">
<div class="jqc-page-title">Support</div>
<div class="jqc-page-sub text-center">JQC Features — find answers and how-to guides</div>
</div>
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
<div class="row g-3 mb-4">
{% if current_user.role == 'customer' %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-dots"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Ask a Question</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Requests</div>
</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Support Requests</div>
</div>
{% if open_support_tickets_count > 0 %}
<div class="jqc-hub-text">{{ open_support_tickets_count }} open</div>
{% endif %}
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-square-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Chat Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_knowledge') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-journal-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Knowledge Base</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── How-to guides ─────────────────────────────────────────────────────── #}
{% set guides = [
('bi-clipboard-check', 'How to create a new inspection',
'Inspections → New Inspection. Pick the contract, facility, area and template, then Start. The checklist opens straight away and saves as you go — you can leave and resume from Inspections → In Progress.'),
('bi-search', 'How to follow up on an inspection',
'Open the inspection and use Request Follow-up. It moves to the Follow-up list, notifies the inspector, and stays there until a re-inspection is submitted against it.'),
('bi-calendar2-week', 'How to schedule an inspection',
'Inspections → Schedule. Choose facility, template, inspector and how often it repeats. Recurring schedules roll their due date forward automatically once the inspection is submitted.'),
('bi-exclamation-triangle', 'How to Flag For Attention',
'While executing an inspection, use Flag Issue on any failing item. Set severity and who handles it (janitorial crew, facility staff or an outside vendor) — the SLA clock starts from that moment.'),
('bi-qr-code', 'How QR code works',
"Every facility and area has a QR code. Scanning it opens that location's public page — anyone on site can report a problem without an account, and the request lands in Issues."),
('bi-file-earmark-text','Send a request without the app',
'Point the on-site contact at the facility QR code, or forward them the public facility link. Their submission arrives as an unassigned issue for triage.'),
('bi-search', 'How to search',
'Use the search box in the top bar for an inspection number. For anything broader, each list page has filters for contract, facility, inspector, status, date range and score.'),
('bi-chat-dots', 'How to comment',
'Open any inspection or issue and use the comment box at the bottom. Comments are visible to staff; sharing one with the customer is an explicit choice on the comment itself.'),
('bi-bar-chart', 'Create & print inspection reports',
'Reports & Analytics → filter by date, contract, facility or inspector → Apply. Export to CSV, or use the PDF export on an individual inspection or facility scorecard.'),
('bi-clock', 'What does SLA At Risk mean?',
'The issue is approaching its resolution deadline for its severity but has not passed it yet. Treat it as the last window to close the issue on time.'),
('bi-alarm', 'What does an SLA Alert mean?',
'The issue has passed its resolution deadline for its severity. It stays flagged until resolved and shows on the dashboard SLA card.'),
] %}
<div class="row g-3">
{% for icon, title, body in guides %}
<div class="col-12 col-md-6 col-xl-4">
<div class="jqc-hub-card" role="button" data-bs-toggle="collapse"
data-bs-target="#guide{{ loop.index }}" aria-expanded="false">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi {{ icon }}"></i></span>
<div class="jqc-hub-title" style="font-size:1.02rem;">{{ title }}</div>
</div>
<div class="collapse" id="guide{{ loop.index }}">
<div class="jqc-hub-text mt-3">{{ body }}</div>
</div>
</div>
</div>
{% endfor %}
<div class="col-12 col-md-6 col-xl-4">
<div class="jqc-hub-card dark">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
<div>
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
<span class="badge bg-light text-dark mt-2">Under construction</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Design Vote Tally{% endblock %}
{# Admin-only: which design are active users currently keeping? #}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
<div class="text-muted">Which web portal design each active user is currently using.</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Classic design</div>
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
<div class="text-muted small">
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">New design</div>
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
<div class="text-muted small">
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total active users</div>
<div class="fs-2 fw-bold">{{ total }}</div>
<div class="text-muted small">Every account defaults to classic</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
</thead>
<tbody>
{% for role, theme, count in by_role %}
<tr>
<td>{{ role.replace('_',' ')|title }}</td>
<td>{{ 'New design' if theme == 'modern' else 'Classic design' }}</td>
<td class="text-end fw-semibold">{{ count }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center text-muted py-4">No active users.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}