Aug 5 - Add new design (switchable)
This commit is contained in:
@@ -27,8 +27,40 @@ limiter = Limiter(
|
||||
)
|
||||
|
||||
|
||||
# ── Design A/B test: per-request template overrides (phase48) ────────────────
|
||||
# A user on the 'modern' design gets templates/modern/<name>.html in place of
|
||||
# templates/<name>.html whenever that override exists; otherwise the normal
|
||||
# template is used and only the layout shell + CSS differ.
|
||||
#
|
||||
# The rewrite happens in get_template() (not in the loader) so Jinja's template
|
||||
# cache is keyed on the REWRITTEN name — a cached modern template can never be
|
||||
# served to a classic user, or vice versa.
|
||||
from flask.templating import Environment as _FlaskJinjaEnvironment
|
||||
|
||||
|
||||
class ThemedEnvironment(_FlaskJinjaEnvironment):
|
||||
"""Jinja environment that redirects template names to modern/<name>."""
|
||||
|
||||
# Populated once in create_app() by scanning templates/modern/.
|
||||
jqc_modern_templates: set = set()
|
||||
|
||||
def get_template(self, name, parent=None, globals=None):
|
||||
if (isinstance(name, str)
|
||||
and self.jqc_modern_templates
|
||||
and not name.startswith('modern/')):
|
||||
candidate = 'modern/' + name
|
||||
if candidate in self.jqc_modern_templates:
|
||||
from flask import g, has_request_context
|
||||
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
|
||||
name = candidate
|
||||
return super().get_template(name, parent, globals)
|
||||
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
|
||||
# property), so the themed subclass is the one actually instantiated.
|
||||
app.jinja_environment = ThemedEnvironment
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# ── Reverse-proxy awareness (Nginx) ──────────────────────────────────────
|
||||
@@ -111,6 +143,54 @@ def create_app(config_name='default'):
|
||||
from app.utils import storage as _storage
|
||||
app.jinja_env.globals['media_url'] = _storage.media_url
|
||||
|
||||
# ── Design A/B test wiring (phase48) ──────────────────────────────────
|
||||
# Index the modern/ override templates once at boot, so get_template()
|
||||
# never has to touch the filesystem per request.
|
||||
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
|
||||
if not os.path.isabs(_modern_root):
|
||||
_modern_root = os.path.join(app.root_path, _modern_root)
|
||||
_modern_set = set()
|
||||
if os.path.isdir(_modern_root):
|
||||
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
|
||||
for _fn in _filenames:
|
||||
if _fn.endswith('.html'):
|
||||
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
|
||||
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
|
||||
ThemedEnvironment.jqc_modern_templates = _modern_set
|
||||
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
|
||||
|
||||
from flask import g
|
||||
|
||||
@app.before_request
|
||||
def resolve_ui_theme():
|
||||
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
|
||||
# The mobile API renders no templates and authenticates by JWT — skip it
|
||||
# so this never touches the Flask-Login session loader on API traffic.
|
||||
if request.path.startswith('/api/'):
|
||||
g.jqc_theme = 'classic'
|
||||
return
|
||||
from flask_login import current_user as _cu
|
||||
theme = 'classic'
|
||||
try:
|
||||
if _cu.is_authenticated:
|
||||
theme = _cu.ui_theme or 'classic'
|
||||
except Exception: # DB column missing (migration not yet run)
|
||||
theme = 'classic'
|
||||
g.jqc_theme = theme if theme in ('classic', 'modern') else 'classic'
|
||||
|
||||
@app.context_processor
|
||||
def inject_ui_theme():
|
||||
"""Give base.html the shell to extend."""
|
||||
from app.utils.time_utils import now_eastern
|
||||
theme = getattr(g, 'jqc_theme', 'classic')
|
||||
return {
|
||||
'jqc_theme': theme,
|
||||
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
|
||||
else 'layouts/classic.html',
|
||||
# Long-form date shown in the modern dashboard header.
|
||||
'now_display': now_eastern().strftime('%A, %B %-d, %Y'),
|
||||
}
|
||||
|
||||
# ── Inject unread notification count into every template context ──────
|
||||
# This powers the red badge on the navbar bell icon without requiring
|
||||
# individual routes to pass the count manually.
|
||||
@@ -183,6 +263,7 @@ def create_app(config_name='default'):
|
||||
from app.routes import devices # Admin device registry
|
||||
from app.routes import public # Public facility QR pages (no login)
|
||||
from app.routes import scheduled_inspections # Planned/recurring inspections
|
||||
from app.routes import ui # phase48 — design A/B test + new pages
|
||||
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(dashboard.bp)
|
||||
@@ -201,6 +282,7 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(devices.bp)
|
||||
app.register_blueprint(public.bp)
|
||||
app.register_blueprint(scheduled_inspections.bp)
|
||||
app.register_blueprint(ui.bp)
|
||||
|
||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||
|
||||
@@ -25,6 +25,15 @@ class User(UserMixin, db.Model):
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
# ── Web portal design preference (phase48 — design A/B test) ──────────
|
||||
# 'classic' = the original top-navbar design (default for every account).
|
||||
# 'modern' = the sidebar design from the JQC_design deck.
|
||||
# Drives base.html's layout dispatch via the inject_ui_theme() context
|
||||
# processor. Persisted per user so the choice survives logout and can be
|
||||
# tallied as a vote (see /ui/theme-votes).
|
||||
ui_theme = db.Column(db.String(16), nullable=False,
|
||||
server_default='classic', default='classic')
|
||||
|
||||
# ── Customer password-setup workflow ──────────────────────────────────
|
||||
# password_set: False for newly created customer accounts until they
|
||||
# complete the set-password flow via emailed link.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
ui.py
|
||||
-----
|
||||
Web-portal design A/B test (phase48).
|
||||
|
||||
Routes
|
||||
POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern
|
||||
GET /ui/about about() — About Us page (new, modern deck)
|
||||
GET /ui/support-center support_center() — Support hub of how-to cards (new)
|
||||
GET /ui/theme-votes theme_votes() — admin tally of which design users kept
|
||||
|
||||
Nothing here changes existing behaviour: the theme flag only selects which
|
||||
layout shell base.html extends.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, request,
|
||||
url_for, flash)
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.utils.audit import log_action, ACTION_UPDATE
|
||||
|
||||
bp = Blueprint('ui', __name__, url_prefix='/ui')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_THEMES = ('classic', 'modern')
|
||||
|
||||
|
||||
def _safe_next(target: str | None) -> str:
|
||||
"""Only allow same-site relative redirects (open-redirect guard)."""
|
||||
if not target:
|
||||
return url_for('dashboard.index')
|
||||
if target.startswith('/') and not target.startswith('//'):
|
||||
return target
|
||||
return url_for('dashboard.index')
|
||||
|
||||
|
||||
# ── Design switch ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/theme', methods=['POST'])
|
||||
@login_required
|
||||
def switch_theme():
|
||||
"""Persist the user's design choice, then return them to the same page."""
|
||||
theme = (request.form.get('theme') or '').strip().lower()
|
||||
if theme not in VALID_THEMES:
|
||||
flash('Unknown design option.', 'warning')
|
||||
return redirect(_safe_next(request.form.get('next')))
|
||||
|
||||
previous = current_user.ui_theme or 'classic'
|
||||
if previous != theme:
|
||||
current_user.ui_theme = theme
|
||||
db.session.commit()
|
||||
|
||||
# log_action() commits internally — always AFTER the business commit.
|
||||
log_action(
|
||||
action = ACTION_UPDATE,
|
||||
entity_type = 'User',
|
||||
entity_id = current_user.id,
|
||||
entity_label = current_user.username,
|
||||
details = f'ui_theme={previous}→{theme}',
|
||||
)
|
||||
logger.info('UI | theme switch | user=%s | %s -> %s',
|
||||
current_user.username, previous, theme)
|
||||
flash('Now showing the {} design. You can switch back any time from '
|
||||
'the account menu.'.format('new' if theme == 'modern' else 'classic'),
|
||||
'info')
|
||||
|
||||
return redirect(_safe_next(request.form.get('next')))
|
||||
|
||||
|
||||
# ── New pages (linked from the modern sidebar) ───────────────────────────────
|
||||
|
||||
@bp.route('/about')
|
||||
@login_required
|
||||
def about():
|
||||
return render_template('ui/about.html')
|
||||
|
||||
|
||||
@bp.route('/support-center')
|
||||
@login_required
|
||||
def support_center():
|
||||
return render_template('ui/support_center.html')
|
||||
|
||||
|
||||
# ── Admin: which design are people actually keeping? ─────────────────────────
|
||||
|
||||
@bp.route('/theme-votes')
|
||||
@login_required
|
||||
def theme_votes():
|
||||
if current_user.role != 'admin':
|
||||
flash('You do not have permission to view the design vote tally.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
rows = (db.session.query(User.ui_theme, func.count(User.id))
|
||||
.filter(User.active == True) # noqa: E712 — SQL boolean
|
||||
.group_by(User.ui_theme)
|
||||
.all())
|
||||
tally = {t: 0 for t in VALID_THEMES}
|
||||
for theme, count in rows:
|
||||
tally[theme or 'classic'] = tally.get(theme or 'classic', 0) + count
|
||||
total = sum(tally.values())
|
||||
|
||||
by_role = (db.session.query(User.role, User.ui_theme, func.count(User.id))
|
||||
.filter(User.active == True) # noqa: E712
|
||||
.group_by(User.role, User.ui_theme)
|
||||
.order_by(User.role)
|
||||
.all())
|
||||
|
||||
return render_template('ui/theme_votes.html',
|
||||
tally=tally, total=total, by_role=by_role)
|
||||
@@ -0,0 +1,472 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Janitorial QC — MODERN design skin (design A/B test — "modern")
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
Loaded ONLY by templates/layouts/modern.html, and always AFTER
|
||||
theme.css, so every token below overrides the classic one.
|
||||
|
||||
The classic design is completely untouched by this file.
|
||||
|
||||
Palette sampled from the JQC_design deck:
|
||||
brand #155F82 deep teal-blue top bar / table headers
|
||||
brand-700 #0F4A66 hover / pressed
|
||||
brand-050 #DCEBF5 soft icon tiles, active rail rows
|
||||
page #EAEEF1 page background
|
||||
surface #FFFFFF cards
|
||||
ink #1D2A32 headings
|
||||
muted #6B7A85 secondary text
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 1. Tokens ───────────────────────────────────────────────────────────── */
|
||||
body.jqc-modern {
|
||||
--jqc-brand: #155F82;
|
||||
--jqc-brand-700: #0F4A66;
|
||||
--jqc-brand-600: #1B6E93;
|
||||
--jqc-brand-050: #DCEBF5;
|
||||
--jqc-brand-025: #E9F0F8;
|
||||
--jqc-page: #EAEEF1;
|
||||
--jqc-ink: #1D2A32;
|
||||
--jqc-heading: #1D2A32;
|
||||
--jqc-muted: #6B7A85;
|
||||
--jqc-faint: #93A1AB;
|
||||
--jqc-border: #E3E8EC;
|
||||
--jqc-border-2: #CFD9E0;
|
||||
--jqc-surface: #F5F8FA;
|
||||
--jqc-surface-2: #EEF3F6;
|
||||
--jqc-accent: #155F82;
|
||||
--jqc-accent-700: #0F4A66;
|
||||
--jqc-accent-50: #DCEBF5;
|
||||
--jqc-shadow: 0 1px 2px rgba(21, 46, 62, .05), 0 6px 18px rgba(21, 46, 62, .06);
|
||||
--jqc-shadow-md: 0 10px 30px rgba(21, 46, 62, .12);
|
||||
|
||||
--bs-primary: #155F82;
|
||||
--bs-primary-rgb: 21, 95, 130;
|
||||
--bs-link-color: #155F82;
|
||||
--bs-link-color-rgb: 21, 95, 130;
|
||||
--bs-link-hover-color: #0F4A66;
|
||||
--bs-link-hover-color-rgb: 15, 74, 102;
|
||||
--bs-body-bg: #EAEEF1;
|
||||
--bs-body-color: #1D2A32;
|
||||
--bs-border-color: #E3E8EC;
|
||||
--bs-border-radius: .6rem;
|
||||
--bs-border-radius-sm: .45rem;
|
||||
--bs-border-radius-lg: 1rem;
|
||||
--bs-border-radius-xl: 1.15rem;
|
||||
|
||||
--jqc-topbar-h: 72px;
|
||||
--jqc-sidebar-w: 232px;
|
||||
|
||||
background-color: var(--jqc-page);
|
||||
color: var(--jqc-ink);
|
||||
font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* ── 2. Top bar ──────────────────────────────────────────────────────────── */
|
||||
.jqc-modern .jqc-topbar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: var(--jqc-topbar-h);
|
||||
z-index: 1035;
|
||||
background: var(--jqc-brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 20px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .10);
|
||||
}
|
||||
.jqc-modern .jqc-brand {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.jqc-modern .jqc-brand-mark {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.jqc-modern .jqc-brand-sub {
|
||||
display: block;
|
||||
font-size: .68rem;
|
||||
opacity: .82;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.jqc-modern .jqc-search {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
width: min(420px, 42vw);
|
||||
}
|
||||
.jqc-modern .jqc-search i {
|
||||
position: absolute;
|
||||
left: 16px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--jqc-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.jqc-modern .jqc-search .form-control {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
height: 42px;
|
||||
padding-left: 44px;
|
||||
background: #fff;
|
||||
font-size: .92rem;
|
||||
}
|
||||
.jqc-modern .jqc-search .form-control:focus {
|
||||
box-shadow: 0 0 0 .2rem rgba(255, 255, 255, .35);
|
||||
}
|
||||
.jqc-modern .jqc-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.jqc-modern .jqc-icon-btn {
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
transition: background-color .15s;
|
||||
}
|
||||
.jqc-modern .jqc-icon-btn:hover { background: rgba(255, 255, 255, .14); color: #fff; }
|
||||
.jqc-modern .jqc-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--jqc-brand-700);
|
||||
border: 2px solid rgba(255, 255, 255, .85);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: .9rem;
|
||||
letter-spacing: .02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.jqc-modern .jqc-avatar:hover { background: #0b3b53; color: #fff; }
|
||||
.jqc-modern .jqc-hamburger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* ── 3. Sidebar ──────────────────────────────────────────────────────────── */
|
||||
.jqc-modern .jqc-sidebar {
|
||||
position: fixed;
|
||||
top: var(--jqc-topbar-h);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--jqc-sidebar-w);
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--jqc-border);
|
||||
overflow-y: auto;
|
||||
z-index: 1030;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.jqc-modern .jqc-nav { flex: 1 1 auto; }
|
||||
.jqc-modern .jqc-nav-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 13px 18px 13px 22px;
|
||||
color: #43525C;
|
||||
text-decoration: none;
|
||||
font-size: .95rem;
|
||||
font-weight: 500;
|
||||
transition: background-color .15s, color .15s;
|
||||
}
|
||||
.jqc-modern .jqc-nav-link i { font-size: 1.15rem; width: 22px; text-align: center; }
|
||||
.jqc-modern .jqc-nav-link span { flex: 1 1 auto; }
|
||||
.jqc-modern .jqc-nav-link:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-nav-link.active {
|
||||
background: var(--jqc-brand-025);
|
||||
color: var(--jqc-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
.jqc-modern .jqc-nav-link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--jqc-brand);
|
||||
}
|
||||
.jqc-modern .jqc-nav-caret { font-size: .7rem !important; width: auto !important; opacity: .6; }
|
||||
.jqc-modern .jqc-nav-badge {
|
||||
background: #D9534F;
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: .68rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.jqc-modern .jqc-nav-sublink {
|
||||
display: block;
|
||||
padding: 9px 18px 9px 58px;
|
||||
font-size: .88rem;
|
||||
color: #5A6A75;
|
||||
text-decoration: none;
|
||||
}
|
||||
.jqc-modern .jqc-nav-sublink:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-nav-sublink.active { color: var(--jqc-brand); font-weight: 700; }
|
||||
.jqc-modern .jqc-sidebar-foot {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 16px 20px;
|
||||
border-top: 1px solid var(--jqc-border);
|
||||
}
|
||||
.jqc-modern .jqc-switch-btn {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1.5px solid var(--jqc-brand);
|
||||
color: var(--jqc-brand);
|
||||
border-radius: 999px;
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 10px;
|
||||
transition: background-color .15s, color .15s;
|
||||
}
|
||||
.jqc-modern .jqc-switch-btn:hover { background: var(--jqc-brand); color: #fff; }
|
||||
.jqc-modern .jqc-sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 34, 46, .45);
|
||||
z-index: 1029;
|
||||
display: none;
|
||||
}
|
||||
.jqc-modern .jqc-sidebar-backdrop.show { display: block; }
|
||||
|
||||
/* ── 4. Main region ──────────────────────────────────────────────────────── */
|
||||
.jqc-modern .jqc-main {
|
||||
margin-left: var(--jqc-sidebar-w);
|
||||
padding: calc(var(--jqc-topbar-h) + 22px) 10px 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.jqc-modern .jqc-main > .container-fluid { padding-inline: 14px; }
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.jqc-modern .jqc-sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform .2s ease;
|
||||
box-shadow: 0 0 24px rgba(15, 34, 46, .18);
|
||||
}
|
||||
.jqc-modern .jqc-sidebar.open { transform: translateX(0); }
|
||||
.jqc-modern .jqc-main { margin-left: 0; }
|
||||
.jqc-modern .jqc-search { width: auto; flex: 1 1 auto; }
|
||||
.jqc-modern .jqc-brand-sub { display: none; }
|
||||
}
|
||||
|
||||
/* ── 5. Page heading block (used by the rebuilt modern pages) ────────────── */
|
||||
.jqc-modern .jqc-page-head { margin-bottom: 20px; }
|
||||
.jqc-modern .jqc-page-head h1,
|
||||
.jqc-modern .jqc-page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--jqc-ink);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.jqc-modern .jqc-page-sub {
|
||||
color: var(--jqc-muted);
|
||||
font-size: .95rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 6. Cards / surfaces (applies to every page, rebuilt or not) ─────────── */
|
||||
.jqc-modern .card {
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
}
|
||||
.jqc-modern .card-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--jqc-border);
|
||||
color: var(--jqc-ink);
|
||||
font-weight: 700;
|
||||
padding: .9rem 1.15rem;
|
||||
}
|
||||
.jqc-modern .card-header.bg-light,
|
||||
.jqc-modern .card-header.bg-white { background: #fff !important; }
|
||||
.jqc-modern .card-body { padding: 1.15rem; }
|
||||
|
||||
.jqc-modern .jqc-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.jqc-modern .jqc-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--jqc-ink);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Soft square icon tile — the deck's signature element */
|
||||
.jqc-modern .jqc-tile-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 11px;
|
||||
background: var(--jqc-brand-050);
|
||||
color: var(--jqc-brand);
|
||||
font-size: 1.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.jqc-modern .jqc-tile-icon.lg { width: 74px; height: 74px; border-radius: 18px; font-size: 2rem; }
|
||||
|
||||
/* KPI tiles */
|
||||
.jqc-modern .jqc-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 18px 20px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow .15s, transform .15s;
|
||||
}
|
||||
a.jqc-kpi:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-1px); color: inherit; }
|
||||
.jqc-modern .jqc-kpi-value {
|
||||
font-size: 2.1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: var(--jqc-ink);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.jqc-modern .jqc-kpi-label { font-size: .85rem; color: var(--jqc-muted); margin-top: 2px; }
|
||||
|
||||
/* Label / value rows inside summary cards */
|
||||
.jqc-modern .jqc-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 2px;
|
||||
border-bottom: 1px solid var(--jqc-border);
|
||||
text-decoration: none;
|
||||
color: var(--jqc-ink);
|
||||
}
|
||||
.jqc-modern .jqc-stat-row:last-child { border-bottom: none; }
|
||||
.jqc-modern .jqc-stat-row:hover { color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-stat-label { font-size: .95rem; display: flex; align-items: center; gap: 9px; }
|
||||
.jqc-modern .jqc-stat-value { font-size: 1.05rem; font-weight: 800; white-space: nowrap; }
|
||||
.jqc-modern .jqc-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
display: inline-block; flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Hub cards (Facility / Support pages) */
|
||||
.jqc-modern .jqc-hub-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 24px 26px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow .15s, transform .15s;
|
||||
}
|
||||
.jqc-modern .jqc-hub-card:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-2px); color: inherit; }
|
||||
.jqc-modern .jqc-hub-title { font-size: 1.3rem; font-weight: 800; color: var(--jqc-ink); }
|
||||
.jqc-modern .jqc-hub-text { color: var(--jqc-muted); font-size: .93rem; margin-top: 6px; }
|
||||
.jqc-modern .jqc-hub-open { color: var(--jqc-brand); font-weight: 700; font-size: .9rem; margin-top: auto; padding-top: 18px; }
|
||||
.jqc-modern .jqc-hub-card.dark { background: var(--jqc-brand); border-color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-hub-title,
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-hub-text { color: #fff; }
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-tile-icon { background: #fff; }
|
||||
|
||||
/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */
|
||||
.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; }
|
||||
.jqc-modern .table > thead > tr > th,
|
||||
.jqc-modern .table thead.table-light > tr > th,
|
||||
.jqc-modern .table > thead th {
|
||||
background: var(--jqc-brand) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--jqc-brand-700) !important;
|
||||
font-weight: 600;
|
||||
font-size: .88rem;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jqc-modern .table > tbody > tr > td { vertical-align: middle; font-size: .92rem; }
|
||||
.jqc-modern .table-hover > tbody > tr:hover > * { background-color: var(--jqc-brand-025); }
|
||||
.jqc-modern .jqc-table-wrap {
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 8. Buttons, badges, forms ───────────────────────────────────────────── */
|
||||
.jqc-modern .btn { border-radius: .6rem; font-weight: 600; }
|
||||
.jqc-modern .btn-primary {
|
||||
--bs-btn-bg: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
|
||||
--bs-btn-hover-bg: var(--jqc-brand-700); --bs-btn-hover-border-color: var(--jqc-brand-700);
|
||||
--bs-btn-active-bg: var(--jqc-brand-700); --bs-btn-active-border-color: var(--jqc-brand-700);
|
||||
--bs-btn-disabled-bg: var(--jqc-brand); --bs-btn-disabled-border-color: var(--jqc-brand);
|
||||
}
|
||||
.jqc-modern .btn-outline-primary {
|
||||
--bs-btn-color: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
|
||||
--bs-btn-hover-bg: var(--jqc-brand); --bs-btn-hover-border-color: var(--jqc-brand);
|
||||
--bs-btn-active-bg: var(--jqc-brand); --bs-btn-active-border-color: var(--jqc-brand);
|
||||
}
|
||||
.jqc-modern .bg-primary { background-color: var(--jqc-brand) !important; }
|
||||
.jqc-modern .text-primary { color: var(--jqc-brand) !important; }
|
||||
.jqc-modern .badge { border-radius: 999px; font-weight: 700; padding: .35em .7em; }
|
||||
.jqc-modern .form-control,
|
||||
.jqc-modern .form-select {
|
||||
border-radius: .6rem;
|
||||
border-color: var(--jqc-border-2);
|
||||
}
|
||||
.jqc-modern .form-control:focus,
|
||||
.jqc-modern .form-select:focus {
|
||||
border-color: var(--jqc-brand);
|
||||
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .18);
|
||||
}
|
||||
|
||||
/* Filter bar — the rounded pill row from the deck */
|
||||
.jqc-modern .jqc-filter-bar {
|
||||
background: #fff;
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.jqc-modern .jqc-filter-bar .form-control,
|
||||
.jqc-modern .jqc-filter-bar .form-select { border-radius: 999px; padding-inline: 16px; }
|
||||
|
||||
/* ── 9. Alerts / misc ────────────────────────────────────────────────────── */
|
||||
.jqc-modern .alert { border-radius: 12px; border: 1px solid var(--jqc-border); }
|
||||
.jqc-modern .dropdown-menu { border-radius: 12px; border-color: var(--jqc-border); box-shadow: var(--jqc-shadow-md); }
|
||||
.jqc-modern .nav-tabs .nav-link.active { color: var(--jqc-brand); }
|
||||
.jqc-modern .progress-bar.bg-success { background-color: #2E7D4F !important; }
|
||||
|
||||
/* Print: drop the chrome entirely */
|
||||
@media print {
|
||||
.jqc-modern .jqc-topbar,
|
||||
.jqc-modern .jqc-sidebar,
|
||||
.jqc-modern .jqc-sidebar-backdrop { display: none !important; }
|
||||
.jqc-modern .jqc-main { margin-left: 0; padding-top: 0; }
|
||||
}
|
||||
+13
-511
@@ -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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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 %}
|
||||
|
||||
@@ -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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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>
|
||||
@@ -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 & 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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>
|
||||
@@ -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 %}
|
||||
@@ -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 →</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 →</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 →</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 →</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 %}
|
||||
@@ -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 & 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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user