Aug 7 - Update: UI change - MT16

This commit is contained in:
2026-08-07 16:32:40 -04:00
parent 6ca30c0dea
commit 513f708ee9
18 changed files with 3992 additions and 527 deletions
+94
View File
@@ -28,8 +28,41 @@ limiter = Limiter(
) )
# ── Web portal design: per-request template overrides (MT-16) ────────────────
# 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. A loader-level swap would have that
# bug, and in MT it would leak across tenants sharing a worker process.
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'): def create_app(config_name='default'):
app = Flask(__name__) 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]) app.config.from_object(config[config_name])
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees # Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
@@ -127,6 +160,65 @@ def create_app(config_name='default'):
from app.utils import storage as _storage from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url app.jinja_env.globals['media_url'] = _storage.media_url
# ── Web portal design wiring (MT-16) ──────────────────────────────────
# 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, request as _request
@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/'):
# The API renders no templates; 'classic' here only means "never
# rewrite a template name" (see ThemedEnvironment.get_template).
g.jqc_theme = 'classic'
return
from flask_login import current_user as _cu
# MT-16 — the fallback is configurable per deployment. It defaults to
# 'classic' so an existing tenant's users see no change until they opt
# in; a stored users.ui_theme always wins over the default.
default = app.config.get('DEFAULT_UI_THEME', 'classic')
theme = default
try:
if _cu.is_authenticated:
theme = _cu.ui_theme or default
except Exception: # DB column missing (migration not yet run)
theme = default
g.jqc_theme = theme if theme in ('classic', 'modern') else default
@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',
app.config.get('DEFAULT_UI_THEME', 'classic'))
_now = now_eastern()
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. The day is
# interpolated rather than formatted with '%-d' — that flag is a
# glibc extension and raises ValueError on Windows, which would
# 500 every page (this context processor runs on both themes).
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
}
# ── Inject unread notification count into every template context ────── # ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring # This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually. # individual routes to pass the count manually.
@@ -221,6 +313,7 @@ def create_app(config_name='default'):
from app.routes import tenant_settings # MT-7 — tenant self-service from app.routes import tenant_settings # MT-7 — tenant self-service
from app.routes import signup # MT-8+ — public self-service signup from app.routes import signup # MT-8+ — public self-service signup
from app.routes import landing # Public apex marketing/landing page from app.routes import landing # Public apex marketing/landing page
from app.routes import ui # MT-16 — design switch + new pages
from app.billing import bp as billing_bp # MT-8 — Stripe billing from app.billing import bp as billing_bp # MT-8 — Stripe billing
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
@@ -244,6 +337,7 @@ def create_app(config_name='default'):
app.register_blueprint(tenant_settings.bp) app.register_blueprint(tenant_settings.bp)
app.register_blueprint(signup.bp) app.register_blueprint(signup.bp)
app.register_blueprint(landing.bp) app.register_blueprint(landing.bp)
app.register_blueprint(ui.bp)
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from # Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects # Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods). # which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
+11
View File
@@ -55,6 +55,17 @@ class User(UserMixin, db.Model):
created_at = db.Column(db.DateTime, default=now_eastern) created_at = db.Column(db.DateTime, default=now_eastern)
active = db.Column(db.Boolean, default=True, nullable=False) active = db.Column(db.Boolean, default=True, nullable=False)
# ── Web portal design preference (MT-16) ──────────────────────────────
# 'classic' = the original top-navbar design. 'modern' = the sidebar design.
# Drives base.html's layout dispatch via the inject_ui_theme() context
# processor. Persisted per user so the choice survives logout.
#
# Defaults to 'classic' so existing tenants see no change on deploy; the
# effective fallback for accounts that never choose is config
# DEFAULT_UI_THEME, which a tenant can be provisioned with as 'modern'.
ui_theme = db.Column(db.String(16), nullable=False,
server_default='classic', default='classic')
# ── Customer password-setup workflow ────────────────────────────────── # ── Customer password-setup workflow ──────────────────────────────────
# password_set: False for newly created customer accounts until they # password_set: False for newly created customer accounts until they
# complete the set-password flow via emailed link. # complete the set-password flow via emailed link.
+60
View File
@@ -24,6 +24,10 @@ def index():
now = now_eastern() now = now_eastern()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
# MT-16 — Monday 00:00 of the current week, for the modern dashboard's
# "submitted this week" tile. Derived from today_start so it inherits
# now_eastern() rather than mixing in a second clock.
week_start = today_start - timedelta(days=today_start.weekday())
is_inspector = current_user.is_inspector is_inspector = current_user.is_inspector
is_privileged = current_user.role in ['admin', 'director'] is_privileged = current_user.role in ['admin', 'director']
@@ -62,6 +66,14 @@ def index():
Inspection.inspection_date < today_end, Inspection.inspection_date < today_end,
).count() ).count()
# MT-16 — fully completed & submitted so far this week (Monday → now).
# Reuses base_q, so it inherits the same role scoping as every other tile.
submitted_this_week = base_q.filter(
Inspection.status == 'completed',
Inspection.inspection_date >= week_start,
Inspection.inspection_date < today_end,
).count()
# ── Open issues (inspector: all issues in contracted facilities) ─────── # ── Open issues (inspector: all issues in contracted facilities) ───────
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_inspector: if is_inspector:
@@ -282,6 +294,26 @@ def index():
stale_q = stale_q.filter(False) stale_q = stale_q.filter(False)
stale_in_progress = stale_q.count() stale_in_progress = stale_q.count()
# ── In-progress inspections, all ages (MT-16) ─────────────────────────────
# stale_in_progress above counts only those older than 24h. The modern
# dashboard shows the full in-progress count as its own tile, so this is a
# separate query with the SAME role scoping rather than a reuse of stale_q.
inprog_q = Inspection.query.filter(Inspection.status == 'in_progress')
if is_inspector:
if not inspector_facility_ids:
inprog_q = inprog_q.filter(False)
else:
inprog_q = inprog_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer:
if customer_facility_ids:
inprog_q = inprog_q.filter(Inspection.facility_id.in_(customer_facility_ids))
else:
inprog_q = inprog_q.filter(False)
in_progress_total = inprog_q.count()
# ── Unassigned open issues ──────────────────────────────────────────────── # ── Unassigned open issues ────────────────────────────────────────────────
from app.models.facility import Area as _AreaU from app.models.facility import Area as _AreaU
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter( unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
@@ -349,6 +381,11 @@ def index():
# inspections list, so surfacing them here would double-report the work. # inspections list, so surfacing them here would double-report the work.
sched_upcoming = [] sched_upcoming = []
sched_overdue_count = 0 sched_overdue_count = 0
# MT-16 — the modern dashboard additionally shows the total number of active
# plans ("On Schedules") and offers Continue instead of a duplicate Start
# where an inspection is already underway for that plan.
sched_total = 0
sched_open_inspections = {}
if not is_customer: if not is_customer:
from app.models.inspection_schedule import InspectionSchedule from app.models.inspection_schedule import InspectionSchedule
_today = now_eastern().date() _today = now_eastern().date()
@@ -365,11 +402,34 @@ def index():
s for s in _all_sched s for s in _all_sched
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7) if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
][:8] ][:8]
sched_total = len(_all_sched) # active plan-mode schedules
# {schedule_id: inspection_id} for plans with an inspection already in
# progress. MT's FK is Inspection.inspection_schedule_id (ST calls it
# scheduled_inspection_id). Ordered ascending so that when a plan somehow
# has more than one open inspection, the dict keeps the LOWEST id — the
# original, not a later duplicate.
_sched_ids = [s.id for s in sched_upcoming if s.id]
if _sched_ids:
_open_rows = (
Inspection.query
.filter(Inspection.inspection_schedule_id.in_(_sched_ids),
Inspection.status == 'in_progress')
.order_by(Inspection.id.desc())
.all()
)
sched_open_inspections = {
r.inspection_schedule_id: r.id for r in _open_rows
}
return render_template( return render_template(
'dashboard.html', 'dashboard.html',
sched_upcoming = sched_upcoming, sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count, sched_overdue_count = sched_overdue_count,
sched_total = sched_total,
sched_open_inspections = sched_open_inspections,
in_progress_total = in_progress_total,
submitted_this_week = submitted_this_week,
week_start_str = week_start.strftime('%Y-%m-%d'),
today_inspections = today_inspections, today_inspections = today_inspections,
completed_today = completed_today, completed_today = completed_today,
open_issues = open_issues, open_issues = open_issues,
+140
View File
@@ -0,0 +1,140 @@
"""
app/routes/ui.py
----------------
Web portal design switch and the pages the modern sidebar links to (MT-16).
Routes
POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern
GET /ui/about about() — About Us page
GET /ui/support-center support_center() — support hub of how-to cards
GET /ui/theme-votes theme_votes() — admin tally of design choices
Nothing here changes existing behaviour: the theme flag only selects which
layout shell base.html extends. Every page template is untouched.
Multi-tenant note
-----------------
`users` is a per-tenant table, so every query in this module is automatically
scoped to the caller's tenant by the routing session — the vote tally shows one
tenant's users, never the estate. There is deliberately no cross-tenant rollup
here; that belongs in the control plane if it is ever wanted.
"""
import logging
from flask import (Blueprint, render_template, redirect, request,
url_for, flash, current_app)
from flask_login import login_required, current_user
from sqlalchemy import func
from app import db
from app.models.user import User, ROLE_LABELS
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 _default_theme():
"""Fallback design for accounts that have never chosen one."""
default = (current_app.config.get('DEFAULT_UI_THEME') or 'classic').lower()
return default if default in VALID_THEMES else 'classic'
def _safe_next(target):
"""Only allow same-site relative redirects (open-redirect guard).
A protocol-relative URL ('//evil.com') is a valid redirect target to the
browser but points off-site, so the leading-slash test alone is not enough.
"""
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 _default_theme()
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'))
default = _default_theme()
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:
key = theme if theme in VALID_THEMES else default
tally[key] = tally.get(key, 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,
default_theme=default,
# by_role yields raw role strings from a group_by,
# so hand the template the same label source the
# User.role_label property uses (MT-15).
role_labels=ROLE_LABELS)
+482
View File
@@ -0,0 +1,482 @@
/* ════════════════════════════════════════════════════════════════════════
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; /* subtle fill — hovers, muted rows (theme.css) */
--jqc-surface-2: #EEF3F6;
--jqc-card-bg: #FFFFFF; /* raised surfaces — cards, KPI tiles, sidebar */
--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;
}
/* MT: a tenant with an uploaded logo renders it in place of the "JQC" wordmark.
Capped in height so a tall logo cannot stretch the top bar. */
.jqc-modern .jqc-brand-logo {
display: block;
max-height: 30px;
max-width: 150px;
object-fit: contain;
}
.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: var(--jqc-card-bg);
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-sidebar-foot / .jqc-switch-btn were dropped in phase50 along with the
sidebar design switcher — no template references them any more. */
.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;
}
/* Opt-in centring — the default is left-aligned. `.center` on the head block
centres the title and its sub-line together. */
.jqc-modern .jqc-page-head.center,
.jqc-modern .jqc-page-title.center { 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: var(--jqc-card-bg);
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: var(--jqc-card-bg) !important; }
.jqc-modern .card-body { padding: 1.15rem; }
.jqc-modern .jqc-card {
background: var(--jqc-card-bg);
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: var(--jqc-card-bg);
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: var(--jqc-card-bg);
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; }
/* The default .jqc-hub-open is brand-coloured, which is invisible on the dark
(brand-filled) card — it needs its own colour. */
.jqc-modern .jqc-hub-card.dark .jqc-hub-open { color: #fff; }
/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */
.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; }
/* Recoloured via Bootstrap's own table CSS variables rather than !important, so
a page that deliberately wants a different header (table-dark, a tinted
report header) can still override it with a normal rule. */
.jqc-modern .table > thead > tr > th,
.jqc-modern .table thead.table-light > tr > th,
.jqc-modern .table > thead th {
--bs-table-bg: var(--jqc-brand);
--bs-table-color: #fff;
background-color: var(--jqc-brand);
color: #fff;
border-color: var(--jqc-brand-700);
font-weight: 600;
font-size: .88rem;
vertical-align: middle;
}
/* nowrap only where the column set is known-narrow (dashboard panels); wide
tables such as the issues list and audit trail must be free to wrap rather
than force a horizontal scroll on iPad portrait. */
.jqc-modern .jqc-card .table > thead th { 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: var(--jqc-card-bg);
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; }
}
+12 -527
View File
@@ -1,530 +1,15 @@
<!DOCTYPE html> {# ────────────────────────────────────────────────────────────────────────────
<html lang="en"> base.html — layout dispatcher (MT-16)
<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') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
{% if tenant_branding %}
<style>
:root {
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
}
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
</style>
{% endif %}
{% 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 ── */ This file used to hold the entire page chrome. That markup now lives in
.navbar-dark .navbar-nav .nav-link.active { layouts/classic.html, unchanged.
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;
}
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
{% if tenant_branding and tenant_branding.logo_url %}
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
alt="{{ tenant_branding.display_name }}"
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
{% else %}
<i class="bi bi-clipboard-check"></i>
{% endif %}
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-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>
{% 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('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
</li>
{% endif %}
<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('auth.') and 'user' in request.endpoint)
or request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or request.endpoint.startswith('tenant_settings.')
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" id="adminMenu" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sliders me-1"></i>Admin
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
<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') }}">
<i class="bi bi-people me-2"></i>Users
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">
<i class="bi bi-tablet me-2"></i>Devices
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
href="{{ url_for('tenant_settings.branding') }}">
<i class="bi bi-gear me-2"></i>Workspace Settings
</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- ── Notification Bell (desktop lg+ only) ── --> Every page template still says {% extends "base.html" %} and needed ZERO
<li class="nav-item dropdown me-2 d-none d-xxl-block"> edits: Jinja resolves {% block %} overrides through the whole inheritance
<a class="nav-link position-relative notif-bell-wrapper" chain, so one extra link in that chain is invisible to them.
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 --> `jqc_layout` is supplied by the inject_ui_theme() context processor in
<li class="nav-item dropdown"> app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" config DEFAULT_UI_THEME as the fallback for accounts that never chose.
role="button" data-bs-toggle="dropdown"> ──────────────────────────────────────────────────────────────────────────── #}
<i class="bi bi-person-circle"></i> {{ current_user.username }} {% extends jqc_layout %}
</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 %}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+551
View File
@@ -0,0 +1,551 @@
<!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') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
{% if tenant_branding %}
<style>
:root {
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
}
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
</style>
{% endif %}
{% 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;
}
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
{% if tenant_branding and tenant_branding.logo_url %}
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
alt="{{ tenant_branding.display_name }}"
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
{% else %}
<i class="bi bi-clipboard-check"></i>
{% endif %}
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-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>
{% 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('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
</li>
{% endif %}
<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('auth.') and 'user' in request.endpoint)
or request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or request.endpoint.startswith('tenant_settings.')
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" id="adminMenu" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sliders me-1"></i>Admin
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
<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') }}">
<i class="bi bi-people me-2"></i>Users
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">
<i class="bi bi-tablet me-2"></i>Devices
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
href="{{ url_for('tenant_settings.branding') }}">
<i class="bi bi-gear me-2"></i>Workspace Settings
</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-xxl-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>
{# MT-16 — opt in to the sidebar design. POST so the
switch is not a GET side effect; `next` returns the
user to the page they were on. #}
<li>
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-0">
<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>
{% 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>
</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 %}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+514
View File
@@ -0,0 +1,514 @@
<!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') }}">
{# MT: per-tenant branding overrides, mirrored from layouts/classic.html.
Loaded AFTER theme_modern.css so a tenant's colours win over the modern
palette; the modern layout/structure is unaffected. #}
{% if tenant_branding %}
<style>
:root {
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
--jqc-brand: {{ tenant_branding.primary_color or '#1a56db' }};
}
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
</style>
{% endif %}
{% 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') }}">
{% if tenant_branding and tenant_branding.logo_url %}
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
alt="{{ tenant_branding.display_name }}" class="jqc-brand-logo">
{% else %}
<span class="jqc-brand-mark">JQC</span>
{% endif %}
<span class="jqc-brand-sub">
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
</span>
</a>
{# Scoped to inspection ID only — the placeholder says so explicitly so
nobody types a facility name and assumes the search is broken. #}
<form class="jqc-search" method="GET" action="{{ url_for('inspections.index') }}" role="search">
<i class="bi bi-search"></i>
<input type="search" name="inspection_id" class="form-control" inputmode="numeric"
placeholder="Inspection # (e.g. 1423)" aria-label="Search by inspection number"
title="Search by 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_label }}</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('inspection_schedules.')) }}"
href="{{ url_for('inspections.index') }}">
<i class="bi bi-clipboard-check"></i><span>Inspections</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}"
href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart-fill"></i><span>Reports &amp; Analytics</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}"
href="{{ url_for('issues.index') }}">
<i class="bi bi-exclamation-triangle"></i><span>Issues</span>
</a>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
<i class="bi bi-patch-check"></i><span>Verify</span>
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="jqc-nav-badge">{{ pending_verification_count }}</span>
{% endif %}
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}"
href="{{ url_for('projects.index') }}">
<i class="bi bi-file-earmark-text"></i><span>Contract</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}"
href="{{ url_for('facilities.list_facilities') }}">
<i class="bi bi-buildings"></i><span>Facility</span>
</a>
{% if current_user.role in ['admin', 'director'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}"
href="{{ url_for('templates.index') }}">
<i class="bi bi-list-check"></i><span>Templates</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}"
href="{{ url_for('customers.index') }}">
<i class="bi bi-people"></i><span>Customer</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('support.') or request.endpoint == 'ui.support_center') }}"
href="{{ url_for('ui.support_center') }}">
<i class="bi bi-life-preserver"></i><span>Supports</span>
{% if open_support_tickets_count > 0 %}
<span class="jqc-nav-badge">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or request.endpoint.startswith('tenant_settings.')
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>
{# MT: tenant self-service settings replace ST's enrollment entry —
the enrollment blueprint does not exist in this codebase. #}
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
href="{{ url_for('tenant_settings.branding') }}">Workspace Settings</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>
{# The design switcher was removed from the sidebar in phase50, when
modern became the default — it no longer belongs in the primary nav.
The same action still exists in the account menu (top right), so
anyone who needs the classic design can still get to it. #}
</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 %}
{# MT: the billing banner is layout chrome in layouts/classic.html and
must render here too — a user on the modern design must not miss a
suspension or dunning notice.
_quota_warning.html is deliberately NOT included here: it is a
per-form include (user_form, issue form, facility form,
inspection start), not layout chrome. Including it globally would
render it twice on exactly those pages. #}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %}
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
// ── Sidebar off-canvas toggle (mobile / tablet portrait) ──────────────
(function () {
var btn = document.getElementById('jqcSidebarToggle');
var sidebar = document.getElementById('jqcSidebar');
var backdrop = document.getElementById('jqcSidebarBackdrop');
if (!btn || !sidebar) return;
function close() {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('show');
}
btn.addEventListener('click', function () {
sidebar.classList.toggle('open');
if (backdrop) backdrop.classList.toggle('show');
});
if (backdrop) backdrop.addEventListener('click', close);
})();
</script>
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
const badgeDesktop = document.getElementById('notif-count-badge');
const listDesktop = document.getElementById('notif-list');
function updateBadge(count) {
[badgeDesktop].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
if (deskOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
['notifDropdown'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+418
View File
@@ -0,0 +1,418 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{#
MODERN dashboard (design A/B test — slide 1 of JQC_design).
Uses exactly the same context variables as templates/dashboard.html — the
dashboard.index route is untouched. Every tile links to the same filtered
list view the classic dashboard links to, so no navigation path is lost.
#}
{% block content %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title">Welcome, {{ current_user.display_name }}</div>
<div class="jqc-page-sub">{{ current_user.role.replace('_',' ')|title }}</div>
</div>
<div class="text-muted">{{ now_display }}</div>
</div>
{# ── Scheduled inspections ────────────────────────────────────────────── #}
{% if current_user.role != 'customer' %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-calendar2-week"></i></span>Scheduled Inspection In Progress
</div>
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2">
<i class="bi bi-alarm-fill me-1"></i>
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
</div>
{% endif %}
{% if sched_upcoming %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Facility</th><th>Inspection Template</th><th>Inspector</th>
<th>How Often</th><th>Next Due Date</th><th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small text-muted">{{ s.recurrence_label }}</td>
<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('inspection_schedules.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success py-0"
title="Confirm you received this request">
<i class="bi bi-check-lg"></i> Confirm</button>
</form>
{% endif %}
{% set open_id = sched_open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue</a>
{% else %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small"><i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.</div>
{% endif %}
</div>
{% endif %}
{# ── KPI row ──────────────────────────────────────────────────────────── #}
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
<div class="col">
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>
<div class="jqc-kpi-value">{{ completed_today }}</div>
<div class="jqc-kpi-label">Submitted Today</div>
</a>
</div>
<div class="col">
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
<span class="jqc-tile-icon"><i class="bi bi-calendar-week"></i></span>
<div class="jqc-kpi-value">{{ submitted_this_week }}</div>
<div class="jqc-kpi-label">Submitted This Week</div>
</a>
</div>
<div class="col">
<a class="jqc-kpi" href="{{ url_for('issues.index', status='open') }}">
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>
<div class="jqc-kpi-value">{{ open_issues }}</div>
<div class="jqc-kpi-label">Open Issues</div>
</a>
</div>
<div class="col">
{% if current_user.role != 'customer' %}
<a class="jqc-kpi" href="{{ url_for('inspection_schedules.index') }}">
<span class="jqc-tile-icon"><i class="bi bi-calendar2-check"></i></span>
<div class="jqc-kpi-value">{{ sched_total }}</div>
<div class="jqc-kpi-label">On Schedules</div>
</a>
{% else %}
<a class="jqc-kpi" href="{{ url_for('facilities.list_facilities') }}">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>
<div class="jqc-kpi-value">{{ customer_facilities|length if customer_facilities else 0 }}</div>
<div class="jqc-kpi-label">Your Facilities</div>
</a>
{% endif %}
</div>
</div>
{# ── Three summary cards ──────────────────────────────────────────────── #}
<div class="row g-3 mb-2">
<!-- Inspection -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>Inspection
</div>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label">Submitted Today</span>
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ completed_today }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
<span class="jqc-stat-label">Submitted This Week</span>
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ submitted_this_week }}</span>
</a>
{% if current_user.role != 'customer' %}
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='in_progress') }}">
<span class="jqc-stat-label">
In Process
{% if stale_in_progress %}<span class="badge bg-warning text-dark">{{ stale_in_progress }} stale</span>{% endif %}
</span>
<span class="jqc-stat-value" style="color:#1B9AD1;">{{ in_progress_total }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='follow_up') }}">
<span class="jqc-stat-label">Pending to follow up</span>
<span class="jqc-stat-value" style="color:#E0A800;">{{ pending_followups }}</span>
</a>
{% endif %}
</div>
</div>
<!-- Open Issues -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>Open Issues
</div>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='internal') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E8722C;"></span>Janitorial</span>
<span class="jqc-stat-value">{{ handler_breakdown.internal }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='facility') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#155F82;"></span>Facility Staff</span>
<span class="jqc-stat-value">{{ handler_breakdown.facility }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='vendor') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Vendors</span>
<span class="jqc-stat-value">{{ handler_breakdown.vendor }}</span>
</a>
{% if current_user.role != 'customer' %}
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='pending_verification') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>Pending Verification</span>
<span class="jqc-stat-value">{{ pending_verification }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', unassigned='1') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>Unassigned Issue</span>
<span class="jqc-stat-value">{{ unassigned_open }}</span>
</a>
{% endif %}
</div>
</div>
<!-- SLA Issues -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>SLA Issues
</div>
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='breached') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>SLA Alert</span>
<span class="jqc-stat-value" style="color:#D9534F;">{{ sla_breached }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='at_risk') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>SLA At Risk</span>
<span class="jqc-stat-value" style="color:#E0A800;">{{ sla_at_risk }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Issues Opened Today</span>
<span class="jqc-stat-value">{{ issues_opened_today }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#2E7D4F;"></span>Resolved Today</span>
<span class="jqc-stat-value" style="color:#2E7D4F;">{{ resolved_today }}</span>
</a>
</div>
</div>
</div>
{# ── Recent activity ──────────────────────────────────────────────────── #}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>Recent Activities
</div>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if recent_inspections %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Facility Name</th>
<th>Area</th>
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
{# The row stays click-anywhere for the mouse, but the date is a real
link so the row is keyboard-reachable and openable in a new tab.
The guard stops the row handler from double-firing on that link. #}
<tr style="cursor:pointer;"
onclick="if(!event.target.closest('a')) window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="text-decoration-none"><small>{{ insp.inspection_date.strftime('%b %d, %Y') }}</small></a>
</td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td>
{% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
{{ insp.overall_score }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
</div>
{% endif %}
</div>
{# ── My open issues (inspector widget) ────────────────────────────────── #}
{% if my_issues %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-person-check"></i></span>My Open Issues
</div>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:60px;">ID</th>
<th style="width:90px;">Severity</th>
<th>Facility / Description</th>
<th style="width:100px;">Status</th>
<th style="width:120px;">SLA</th>
</tr>
</thead>
<tbody>
{% for issue in my_issues %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
</td>
<td>
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
{% else %}
<span class="badge bg-secondary">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Customer portal: scoped facilities panel ─────────────────────────── #}
{% if current_user.role == 'customer' and customer_facilities %}
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-building"></i></span>Your Facilities
<span class="badge bg-secondary rounded-pill ms-2">{{ customer_facilities|length }}</span>
</div>
{% if customer_facilities|length > 6 %}
<div class="mb-3">
<input type="text" id="facilitySearch" class="form-control form-control-sm"
placeholder="Search facilities…" aria-label="Search facilities">
</div>
{% endif %}
<div class="row g-3" id="facilityGrid">
{% for f in customer_facilities %}
<div class="col-12 col-sm-6 col-lg-4 facility-col">
<div class="border rounded-3 p-3 h-100 d-flex flex-column facility-card">
<div class="fw-semibold mb-1">{{ f.name }}</div>
<div class="text-muted" style="font-size:.82rem;">{{ f.address or '—' }}</div>
<div class="my-2">
<span class="badge bg-light text-dark border">{{ f.project.name if f.project else '—' }}</span>
</div>
<div class="mt-auto pt-1">
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
class="btn btn-sm btn-outline-secondary ms-1"><i class="bi bi-graph-up"></i> Report</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% if customer_facilities|length > 9 %}
<div id="facilityShowMore" class="text-center mt-3">
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
</button>
</div>
{% endif %}
</div>
{# Same VISIBLE = 9 / search > 6 thresholds as the classic dashboard (rule 65). #}
<script>
(function () {
{% if customer_facilities|length > 9 %}
var VISIBLE = 9;
var cols = document.querySelectorAll('#facilityGrid .facility-col');
var btn = document.getElementById('toggleFacilities');
var expanded = false;
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
btn.addEventListener('click', function () {
expanded = !expanded;
cols.forEach(function (c, i) {
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
});
btn.innerHTML = expanded
? 'Show fewer <i class="bi bi-chevron-up"></i>'
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
});
{% endif %}
{% if customer_facilities|length > 6 %}
document.getElementById('facilitySearch').addEventListener('input', function () {
var q = this.value.toLowerCase();
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
col.style.display = match ? '' : 'none';
});
var more = document.getElementById('facilityShowMore');
if (more) more.style.display = this.value ? 'none' : '';
});
{% endif %}
})();
</script>
{% endif %}
{% endblock %}
+271
View File
@@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}Facilities{% endblock %}
{#
MODERN facilities page (design A/B test — slide 5 of JQC_design).
The four hub cards are new; everything below them is the original grouped
facility list, delete modal and JS, unchanged — no functionality removed.
#}
{% block content %}
<div class="jqc-page-head center">
<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 not current_user.is_inspector %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('facilities.qr_print_all') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-qr-code"></i></span>
<div>
<div class="jqc-hub-title">Print QR Code</div>
<div class="jqc-hub-text">Generate and print scannable QR codes for every facility entrance and asset.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="#facility-list">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-buildings"></i></span>
<div>
<div class="jqc-hub-title">Facilities Information</div>
<div class="jqc-hub-text">View addresses, contacts, contracts and service details in one place.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('reports.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-pie-chart"></i></span>
<div>
<div class="jqc-hub-title">Facilities Statistics</div>
<div class="jqc-hub-text">Track inspection scores and issue trends by location over time.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('templates.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-gear"></i></span>
<div>
<div class="jqc-hub-title">Customize</div>
<div class="jqc-hub-text">Configure inspection templates, checklist items and scoring for your facilities.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{# ── Original facility list (unchanged) ───────────────────────────────── #}
<div id="facility-list" class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0" style="font-size:1.4rem;font-weight:800;">
<i class="bi bi-building"></i> All Facilities
</h2>
<div>
{% if not current_user.is_inspector %}
<a href="{{ url_for('facilities.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 %}
+362
View File
@@ -0,0 +1,362 @@
{% extends "base.html" %}
{% block title %}Inspections{% endblock %}
{#
MODERN inspections list (design A/B test).
Same context variables, same query params, same form field names and the same
three JS blocks as templates/inspections/list.html — only the chrome differs.
Nothing was dropped: every filter, column, badge, the pagination links and the
delete modal are carried over verbatim. `insp-list-link` is preserved on the
View/Continue buttons so filter-state restore on Back still works.
#}
{% block content %}
{# Any non-empty query param other than the page number means the user has
actually filtered — used to show the match count only when it is meaningful. #}
{% set active_filters = [] %}
{% for _k, _v in request.args.items() %}
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
{% endfor %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title">Inspections</div>
</div>
{% if current_user.role != 'customer' %}
<div class="d-flex gap-2">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-primary">
<i class="bi bi-calendar-check"></i> Scheduled
</a>
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection
</a>
</div>
{% endif %}
</div>
{# ── Filters ──────────────────────────────────────────────────────────── #}
{# Layout: fields fill two rows on the left; the actions sit in a block on the
right that spans both rows — Filter full-height, Clear above Export PDF.
Below the md breakpoint the action block wraps underneath, full width. #}
<div class="jqc-filter-bar">
<form method="get">
<div class="d-flex flex-wrap gap-3 align-items-stretch">
{# ── Fields ──────────────────────────────────────────────────────── #}
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
<div class="row g-2 align-items-end">
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Inspection #</label>
<input type="number" name="inspection_id" class="form-control form-control-sm"
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All Statuses</option>
{% for s in ['in_progress','completed','flagged'] %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
{% endfor %}
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small mb-1">Contract</label>
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
</div>
{# Second row. The score fields absorb the Inspector column's width when
the viewer is an inspector (they only ever see their own work, so the
dropdown is not rendered for them) — the row always totals 12. #}
<div class="row g-2 align-items-end mt-2">
{% if inspectors %}
<div class="col-12 col-md-4">
<label class="form-label small mb-1">Inspector</label>
<select name="inspector_id" class="form-select form-select-sm">
<option value="">All Inspectors</option>
{% for u in inspectors %}
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
<label class="form-label small mb-1">Date From</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ date_from_filter }}">
</div>
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
<label class="form-label small mb-1">Date To</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ date_to_filter }}">
</div>
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
<label class="form-label small mb-1">Min Score</label>
<input type="number" name="score_min" class="form-control form-control-sm"
min="0" max="100" placeholder="0" value="{{ score_min_filter }}">
</div>
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
<label class="form-label small mb-1">Max Score</label>
<input type="number" name="score_max" class="form-control form-control-sm"
min="0" max="100" placeholder="100" value="{{ score_max_filter }}">
</div>
</div>
</div>
{# ── Actions — spans both field rows ─────────────────────────────── #}
{# mt-md-4 drops the block by roughly one label's height, so the buttons
line up with the first row's INPUTS rather than its labels — that is
what makes them shorter, since they still stretch to the bottom of the
second row. The margin is md-only; below that the block wraps
underneath the fields and needs its full width and natural height. #}
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
<button type="submit"
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
style="min-width:88px;">
<span><i class="bi bi-funnel"></i> Filter</span>
</button>
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
<a href="{{ url_for('inspections.index') }}"
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
style="min-width:104px;">Clear</a>
<a id="exportPdfBtn"
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
style="min-width:104px;">
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
</a>
</div>
</div>
</div>
</form>
</div>
{# ── Results ──────────────────────────────────────────────────────────── #}
<div class="jqc-card">
{% if active_filters %}
<div class="text-muted small mb-2">
<i class="bi bi-funnel me-1"></i>
{{ inspections.total }} inspection{{ 's' if inspections.total != 1 }} match
{{ 'es' if inspections.total == 1 }} your filters
</div>
{% endif %}
{% if inspections.items %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th>
</tr>
</thead>
<tbody>
{% for ins in inspections.items %}
<tr>
<td><small class="text-muted">#{{ ins.id }}</small></td>
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
<td>{{ ins.facility.name }}</td>
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td>
{{ ins.template.name }}
{% if ins.scheduled_inspection_id %}
<span class="badge bg-info text-dark ms-1" title="From a scheduled inspection">
<i class="bi bi-calendar-check"></i> Scheduled
</span>
{% endif %}
</td>
<td>{{ ins.inspector.display_name }}</td>
<td>
{% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
{{ ins.overall_score }}%
</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
</span>
{% if ins.status == 'in_progress' %}
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
{% if hours_open > 24 %}
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
<i class="bi bi-clock-history"></i> Stale
</span>
{% endif %}
{% endif %}
{% if ins.follow_up_required and not ins.follow_ups.count() %}
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
<i class="bi bi-arrow-repeat"></i> Follow-up
</span>
{% endif %}
</td>
<td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-1"
data-bs-toggle="modal"
data-bs-target="#deleteInspectionModal"
data-inspection-id="{{ ins.id }}"
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
title="Delete inspection">
<i class="bi bi-trash3"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Pagination #}
{% if inspections.pages > 1 %}
<div class="d-flex justify-content-center pt-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == inspections.page }}">
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-clipboard-x fs-2 d-block mb-2"></i>
No inspections found.
<div class="mt-2">
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
</div>
</div>
{% endif %}
</div>
{% if current_user.role in ['admin', 'director'] %}
<!-- Delete Inspection Confirmation Modal -->
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" 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="deleteInspectionModalLabel">
<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 the following inspection:</p>
<p class="fw-bold" id="deleteInspectionLabel"></p>
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
</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="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
// Save current filtered URL so view/execute pages can restore it on Back
var links = document.querySelectorAll('.insp-list-link');
links.forEach(function (a) {
a.addEventListener('click', function () {
sessionStorage.setItem('insp_list_back_url', window.location.href);
});
});
}());
</script>
<script>
(function () {
'use strict';
var contractSel = document.getElementById('insp_filter_contract');
var facilitySel = document.getElementById('insp_filter_facility');
if (!contractSel || !facilitySel) return;
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
contractSel.addEventListener('change', function () {
var projectId = this.value;
facilitySel.value = '';
if (!projectId) {
facilitySel.innerHTML = '<option value="">All Facilities</option>';
return;
}
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '<option value="">All Facilities</option>';
data.forEach(function (f) {
html += '<option value="' + f.id + '">' + f.name + '</option>';
});
facilitySel.innerHTML = html;
facilitySel.disabled = false;
})
.catch(function () { facilitySel.disabled = false; });
});
}());
</script>
{% if current_user.role in ['admin', 'director'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('deleteInspectionModal');
modal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const id = btn.getAttribute('data-inspection-id');
const label = btn.getAttribute('data-inspection-label');
document.getElementById('deleteInspectionLabel').textContent = label;
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
});
});
</script>
{% endif %}
{% endblock %}
+408
View File
@@ -0,0 +1,408 @@
{% extends "base.html" %}
{% block title %}Issues{% endblock %}
{#
MODERN issues list (design A/B test).
Same context variables, query params, form field names and JS as
templates/issues/list.html — only the chrome differs. Every filter, column,
badge, the quick-assign control, follow/unfollow, delete and the pagination
links are carried over verbatim.
#}
{% block content %}
{# Any non-empty query param other than the page number means the user has
actually filtered — used to show the match count only when it is meaningful. #}
{% set active_filters = [] %}
{% for _k, _v in request.args.items() %}
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
{% endfor %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title">Issues</div>
</div>
{% if current_user.role in ['admin','director','customer','auditor'] %}
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue
</a>
{% endif %}
</div>
{# ── Filters ──────────────────────────────────────────────────────────── #}
{# Same layout as the modern inspections list: fields fill two rows on the left,
actions in a block on the right spanning both — Filter full-height, Clear
above Export PDF. Contract and Facility stay adjacent because they cascade
(rule 61). Below md the action block wraps underneath, full width. #}
<div class="jqc-filter-bar">
<form method="get">
<div class="d-flex flex-wrap gap-3 align-items-stretch">
{# ── Fields ──────────────────────────────────────────────────────── #}
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
<div class="row g-2 align-items-end">
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Issue #</label>
<input type="number" name="issue_id" class="form-control form-control-sm"
min="1" placeholder="ID" value="{{ issue_id_filter }}">
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['critical','high','medium','low'] %}
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['open','in_progress','pending_verification','resolved'] %}
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small mb-1">Contract</label>
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-2 align-items-end mt-2">
<div class="col-6 col-md-2">
<label class="form-label small mb-1">SLA</label>
<select name="sla" class="form-select form-select-sm">
<option value="">All</option>
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-1">Reported From</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ date_from_filter }}">
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-1">Reported To</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ date_to_filter }}">
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Reporter</label>
<select name="reporter_id" class="form-select form-select-sm">
<option value="">All Reporters</option>
{% for u in reporters %}
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-2">
<label class="form-label small mb-1">Handled By</label>
<select name="handler_type" class="form-select form-select-sm">
<option value="">All Handlers</option>
<option value="internal" {{ 'selected' if handler_filter == 'internal' }}>Janitorial Staff</option>
<option value="facility" {{ 'selected' if handler_filter == 'facility' }}>Facility Staff</option>
<option value="vendor" {{ 'selected' if handler_filter == 'vendor' }}>External Vendor</option>
</select>
</div>
</div>
</div>
{# ── Actions — spans both field rows ─────────────────────────────── #}
{# mt-md-4 drops the block by roughly one label's height, so the buttons
line up with the first row's INPUTS rather than its labels — that is
what makes them shorter, since they still stretch to the bottom of the
second row. The margin is md-only; below that the block wraps
underneath the fields and needs its full width and natural height. #}
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
<button type="submit"
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
style="min-width:88px;">
<span><i class="bi bi-funnel"></i> Filter</span>
</button>
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
<a href="{{ url_for('issues.index') }}"
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
style="min-width:104px;">Clear</a>
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
style="min-width:104px;">
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
</a>
</div>
</div>
</div>
</form>
</div>
{# ── Results ──────────────────────────────────────────────────────────── #}
<div class="jqc-card">
{% if active_filters %}
<div class="text-muted small mb-2">
<i class="bi bi-funnel me-1"></i>
{{ issues.total }} issue{{ 's' if issues.total != 1 }} match
{{ 'es' if issues.total == 1 }} your filters
</div>
{% endif %}
{% if issues.items %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Reported</th>
<th>Severity</th>
<th>Contract</th>
<th>Facility / Area</th>
<th>Description</th>
<th>Status</th>
<th>SLA</th>
<th>Reporter</th>
<th>Assigned</th>
<th></th>
</tr>
</thead>
<tbody>
{% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td><small class="text-muted">#{{ issue.id }}</small></td>
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
<small>{{ _c.name if _c else '—' }}</small>
</td>
<td>
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
</td>
<td>
<span{% if issue.description|length > 60 %} title="{{ issue.description }}" style="cursor:help;"{% endif %}>
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
</span>
</td>
<td>
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else '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" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
{% elif sla == 'ok' %}
<span class="badge bg-secondary">OK</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>
{% if issue.reporter %}
<small>{{ issue.reporter.display_name }}</small>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
</div>
{% else %}
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}<span class="text-muted"></span>{% endif %}
{% endif %}
{% if issue.handler_type == 'facility' %}
<div><span class="badge bg-info text-dark mt-1" title="Handled by facility staff"><i class="bi bi-building"></i> Facility</span></div>
{% elif issue.handler_type == 'vendor' %}
<div><span class="badge bg-warning text-dark mt-1" title="Handled by external vendor"><i class="bi bi-person-gear"></i> Vendor</span></div>
{% endif %}
</td>
<td class="text-nowrap">
{# Following badge + inline unfollow #}
{% if is_following %}
<span class="badge bg-primary me-1" title="You are following this issue">
<i class="bi bi-bell-fill"></i> Following
</span>
<form method="post"
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
</button>
</form>
{% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
{% else %}
<i class="bi bi-eye"></i> View
{% endif %}
</a>
{% if current_user.role in ['admin', 'director'] %}
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if issues.pages > 1 %}
<div class="d-flex justify-content-center pt-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == issues.page }}">
<a class="page-link"
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">{{ p }}</a>
</li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-check2-circle fs-2 d-block mb-2"></i>
No issues found.
<div class="mt-2">
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
var contractSel = document.getElementById('filter_contract_id');
var facilitySel = document.getElementById('filter_facility_id');
if (!contractSel || !facilitySel) return;
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
contractSel.addEventListener('change', function () {
var projectId = this.value;
facilitySel.value = ''; // reset facility selection
if (!projectId) {
// No contract selected — restore all-facilities placeholder and submit
// (server will return unfiltered facility list)
facilitySel.innerHTML = '<option value="">All Facilities</option>';
return;
}
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '<option value="">All Facilities</option>';
data.forEach(function (f) {
html += '<option value="' + f.id + '">' + f.name + '</option>';
});
facilitySel.innerHTML = html;
facilitySel.disabled = false;
})
.catch(function () { facilitySel.disabled = false; });
});
}());
</script>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<script>
(function () {
'use strict';
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
sel.dataset.previous = sel.value;
sel.addEventListener('change', function () {
const wrap = sel.closest('.quick-assign-wrap');
const issueId = wrap.dataset.issueId;
const spinner = wrap.querySelector('.quick-assign-spinner');
const userId = sel.value || null;
sel.disabled = true;
spinner.classList.remove('d-none');
fetch('/issues/' + issueId + '/quick-assign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}',
},
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
alert('Assignment failed: ' + (data.error || 'Unknown error'));
sel.value = sel.dataset.previous;
} else {
sel.dataset.previous = sel.value;
}
})
.catch(function () {
alert('Network error — assignment not saved.');
sel.value = sel.dataset.previous;
})
.finally(function () {
sel.disabled = false;
spinner.classList.add('d-none');
});
});
});
}());
</script>
{% endif %}
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}About{% endblock %}
{# About — linked from the modern sidebar.
MT note: ST's version of this page hardcodes its own company name and links
the `enrollment` blueprint, neither of which is portable here. This version
is tenant-neutral: the workspace name comes from tenant_branding (the same
source the layouts use), and "add people" points at the Users admin page,
which is how accounts are actually created in MT. #}
{% set workspace_name = tenant_branding.display_name if tenant_branding else 'this workspace' %}
{% block content %}
<div class="jqc-page-head center">
<div class="jqc-page-title">About</div>
<div class="jqc-page-sub text-center">
Janitorial Quality Control{% if tenant_branding %} — {{ tenant_branding.display_name }}{% endif %}
</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>{{ workspace_name }}
</div>
<p class="mb-3">
Quality is verified in the field, not assumed. Every contract is backed by
scheduled inspections, documented findings and tracked resolution, so what
was checked, when, and by whom is always on the record.
</p>
<p class="mb-0 text-muted">
JQC is the quality control platform behind that work. 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-shield-check"></i></span>Your data
</div>
<p class="mb-0 text-muted">
{{ workspace_name }} has its own isolated database. Inspections, issues,
photos and accounts belong to this workspace alone and are never shared
with, or visible to, any other organisation using JQC.
</p>
</div>
</div>
{% if current_user.role == 'admin' %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('auth.list_users') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-person-plus"></i></span>
<div>
<div class="jqc-hub-title">Add More People</div>
<div class="jqc-hub-text">
Create accounts for colleagues and set what each person can do.
External inspectors are invited by email and choose their own password.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('tenant_settings.branding') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-palette"></i></span>
<div>
<div class="jqc-hub-title">Workspace Settings</div>
<div class="jqc-hub-text">
Set the workspace name, logo and colours used across the portal and
in outbound email.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
<div class="col-12">
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact &amp; Support
</div>
<p class="mb-3">
Questions about an inspection, an issue on a site, or access to the portal —
start in the Support Center and it will be routed 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 %}
+150
View File
@@ -0,0 +1,150 @@
{% 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 center">
<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 %}
{# The AI chat is customer-only (support.chat redirects staff to the ticket
queue, which is itself @supervisor_required). So the destination is chosen
per role rather than pointed at support.chat for everyone — an inspector
following that chain would land on the dashboard with an access-denied
flash, and this page is meant never to dead-end. Roles with no support
destination get no card; the how-to guides below are their support. #}
{% if current_user.role == 'customer' %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
<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>
<div class="jqc-hub-text">Ask a question and get an answer straight away.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.admin_conversations') }}">
<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>
<div class="jqc-hub-text">Review the AI chat conversations customers have had.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Design Vote Tally{% endblock %}
{# Admin-only: which design are active users currently keeping? #}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
<div class="text-muted">Which web portal design each active user is currently using.</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Classic design</div>
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
<div class="text-muted small">
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">New design</div>
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
<div class="text-muted small">
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total active users</div>
<div class="fs-2 fw-bold">{{ total }}</div>
<div class="text-muted small">Every account defaults to classic</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
</thead>
<tbody>
{% for role, theme, count in by_role %}
<tr>
<td>{{ role_labels.get(role, 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 %}
+13
View File
@@ -88,6 +88,19 @@ class Config:
# a TenantSettings column only if a tenant ever has a real reason to opt out. # a TenantSettings column only if a tenant ever has a real reason to opt out.
PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true' PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true'
# ── Web portal design (MT-16) ───────────────────────────────────────────
# Fallback design for users who have never chosen one. A stored
# users.ui_theme ALWAYS wins, so this only affects accounts that have not
# touched the switcher.
#
# Defaults to 'classic' — deliberately NOT ST's 'modern'. ST is
# single-tenant and could decide for its own users; MT serves tenants who
# never saw the A/B test, and flipping every user of every tenant to a new
# UI on a migration is not a change to make on their behalf. New tenants can
# be provisioned with DEFAULT_UI_THEME=modern, and any user can opt in from
# the account menu at any time.
DEFAULT_UI_THEME = os.environ.get('DEFAULT_UI_THEME', 'classic').strip().lower()
# ── Photo retention (MT-13) ───────────────────────────────────────────── # ── Photo retention (MT-13) ─────────────────────────────────────────────
# Days after an issue is RESOLVED before its photo FILES are deleted by # Days after an issue is RESOLVED before its photo FILES are deleted by
# POST /notifications/purge-old-photos. Unset (None) = the purge is a no-op. # POST /notifications/purge-old-photos. Unset (None) = the purge is a no-op.
@@ -0,0 +1,72 @@
"""phase52 — per-user web portal design preference
Adds to `users`:
ui_theme VARCHAR(16) NOT NULL DEFAULT 'classic'
'classic' renders the original top-navbar shell (layouts/classic.html),
'modern' renders the sidebar shell (layouts/modern.html). Every existing
account starts on 'classic', so the portal looks and behaves exactly as before
until a user opts in from the account menu.
MULTI-TENANT NOTE — why this differs from ST
--------------------------------------------
ST shipped this as phase48 (default 'classic') and then phase50, which flipped
the column default to 'modern' AND ran
UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic'
overwriting every saved preference. That was a defensible call for a
single-tenant deployment deciding for its own staff after its own A/B test.
It is NOT portable to MT. Here the same statement would run against EVERY
tenant database, flipping the entire UI for tenants who never saw the test and
never asked. So MT ships the phase48 semantics only: the column defaults to
'classic' and there is no backfill of any kind.
The default for accounts that have never chosen is config DEFAULT_UI_THEME
(app/__init__.py::resolve_ui_theme), which reads the environment. A stored
users.ui_theme always wins over it. To put a tenant on the modern design by
default, set DEFAULT_UI_THEME=modern for that tenant's process — no migration,
no overwritten preferences, and individual users can still switch either way.
Uses an INFORMATION_SCHEMA existence check — safe to re-run.
"""
revision = 'phase52_user_ui_theme'
down_revision = 'phase51_external_inspector'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'users', 'ui_theme'):
op.execute(sa.text(
"ALTER TABLE users "
"ADD COLUMN ui_theme VARCHAR(16) NOT NULL DEFAULT 'classic'"
))
# Idempotent repair for any row holding a value outside the known set (for
# example a partially-applied earlier run). This only ever touches NULL or
# invalid values — it does NOT reset a user's deliberate 'modern' choice.
op.execute(sa.text(
"UPDATE users SET ui_theme = 'classic' "
"WHERE ui_theme IS NULL OR ui_theme NOT IN ('classic', 'modern')"
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'users', 'ui_theme'):
op.execute(sa.text("ALTER TABLE users DROP COLUMN ui_theme"))
+260
View File
@@ -0,0 +1,260 @@
"""
tests/test_ui_theme.py
----------------------
Behaviour tests for MT-16 — the classic/modern web portal design.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* every page still renders on the DEFAULT (classic) design — the layout
split must be invisible to the 69 page templates that extend base.html
* the same pages render on the modern design, exercising layouts/modern.html
and every modern/ override (this is what catches a BuildError from an
endpoint name that exists in ST but not MT)
* the switch persists, is POST-only, validates its input, and guards against
open redirects
* ThemedEnvironment serves the modern override ONLY to a modern user — a
cached modern template must never leak to a classic user
* /api/ requests never get a rewritten template name
* the vote tally is admin-only
The render matrix is the point of this module. ST and MT diverge on blueprint
names (scheduled_inspections vs inspection_schedules) and endpoint names
(facility_qr_print_all vs qr_print_all), so a template copied from ST raises
BuildError at RENDER time, not import time — only an actual GET catches it.
"""
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + test client for each test (isolated in-memory DB)."""
with app.app_context():
from app import db
from app.models import inspector_assignment # noqa: F401
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
def _user(username, role, theme='classic'):
from app import db
from app.models.user import User
u = User(username=username, full_name=username.title(), role=role,
email=f'{username}@example.com', active=True, ui_theme=theme)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _login(client, user):
resp = client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
# Guard: if login silently fails, every follow_redirects=True GET below
# lands on the login page and returns 200, so a plain status assertion
# would pass while rendering NOTHING under test. Fail loudly here instead.
assert 'Login - ' not in resp.get_data(as_text=True), (
f'login failed for {user.username} — render assertions would be vacuous')
return resp
def _assert_real_page(resp, path):
"""A 200 is not enough: an unauthenticated GET follows the redirect to the
login page and also returns 200. Assert we are not looking at it."""
assert resp.status_code == 200, f'{path} -> {resp.status_code}'
body = resp.get_data(as_text=True)
assert 'Login - ' not in body, f'{path} bounced to the login page'
return body
# Pages that exist on both designs. The modern run exercises the overrides.
_PAGES = [
'/',
'/inspections/',
'/issues/',
'/facilities/',
'/reports/',
'/ui/about',
'/ui/support-center',
]
# ── Column default ───────────────────────────────────────────────────────────
def test_new_user_defaults_to_classic(client):
"""MT deliberately does NOT adopt ST's phase50 'modern for everyone'."""
from app import db
from app.models.user import User
u = User(username='fresh', role='admin', email='fresh@example.com',
active=True)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
assert u.ui_theme == 'classic'
# ── Render matrix ────────────────────────────────────────────────────────────
@pytest.mark.parametrize('path', _PAGES)
def test_pages_render_on_classic(client, path):
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
_assert_real_page(client.get(path, follow_redirects=True), path)
@pytest.mark.parametrize('path', _PAGES)
def test_pages_render_on_modern(client, path):
"""Catches BuildError from any ST endpoint name that MT does not have."""
admin = _user('ada', 'admin', theme='modern')
_login(client, admin)
_assert_real_page(client.get(path, follow_redirects=True), path)
def test_modern_dashboard_renders_for_every_role(client):
"""The dashboard route branches hard on role; the modern override reads
variables from all of those branches."""
for i, role in enumerate(['admin', 'director', 'project_manager',
'auditor', 'inspector', 'external_inspector']):
# LoginForm.username enforces Length(min=3), so the generated name must
# clear it — a 2-char name silently re-renders the login page.
user = _user(f'user{i}', role, theme='modern')
_login(client, user)
_assert_real_page(client.get('/', follow_redirects=True), f'/ as {role}')
client.get('/auth/logout', follow_redirects=True)
# ── Layout dispatch ──────────────────────────────────────────────────────────
def test_classic_user_gets_classic_shell(client):
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
body = client.get('/', follow_redirects=True).get_data(as_text=True)
assert 'jqc-sidebar' not in body
assert 'theme_modern.css' not in body
def test_modern_user_gets_modern_shell(client):
admin = _user('ada', 'admin', theme='modern')
_login(client, admin)
body = client.get('/', follow_redirects=True).get_data(as_text=True)
assert 'jqc-sidebar' in body
assert 'theme_modern.css' in body
def test_modern_override_does_not_leak_to_classic_user(client):
"""Jinja caches templates by name. The rewrite happens in get_template()
so the cache key is the REWRITTEN name — a modern template rendered for one
user must never be served to a classic user afterwards.
"""
modern_user = _user('mod', 'admin', theme='modern')
_login(client, modern_user)
modern_body = client.get('/', follow_redirects=True).get_data(as_text=True)
assert 'jqc-sidebar' in modern_body
client.get('/auth/logout', follow_redirects=True)
classic_user = _user('cla', 'admin', theme='classic')
_login(client, classic_user)
classic_body = client.get('/', follow_redirects=True).get_data(as_text=True)
assert 'jqc-sidebar' not in classic_body, 'modern template leaked via cache'
# ── The switch ───────────────────────────────────────────────────────────────
def test_switch_persists_the_choice(client):
from app.models.user import User
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
client.post('/ui/theme', data={'theme': 'modern'}, follow_redirects=True)
assert User.query.filter_by(username='ada').first().ui_theme == 'modern'
body = client.get('/', follow_redirects=True).get_data(as_text=True)
assert 'jqc-sidebar' in body
def test_switch_back_to_classic(client):
from app.models.user import User
admin = _user('ada', 'admin', theme='modern')
_login(client, admin)
client.post('/ui/theme', data={'theme': 'classic'}, follow_redirects=True)
assert User.query.filter_by(username='ada').first().ui_theme == 'classic'
def test_switch_rejects_unknown_theme(client):
from app.models.user import User
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
client.post('/ui/theme', data={'theme': 'neon'}, follow_redirects=True)
assert User.query.filter_by(username='ada').first().ui_theme == 'classic'
def test_switch_is_post_only(client):
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
assert client.get('/ui/theme').status_code == 405
def test_switch_requires_login(client):
resp = client.post('/ui/theme', data={'theme': 'modern'})
assert resp.status_code in (302, 401)
def test_switch_refuses_offsite_next(client):
"""A protocol-relative URL is a valid redirect target to the browser but
points off-site, so the leading-slash test alone is not enough."""
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
resp = client.post('/ui/theme',
data={'theme': 'modern', 'next': '//evil.example.com/x'})
assert 'evil.example.com' not in resp.headers.get('Location', '')
def test_switch_is_audit_logged(client):
from app.models.audit import AuditLog
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
client.post('/ui/theme', data={'theme': 'modern'}, follow_redirects=True)
entries = AuditLog.query.filter_by(entity_type='User').all()
assert any('ui_theme' in (e.details or '') for e in entries)
# ── API traffic is never themed ──────────────────────────────────────────────
def test_api_requests_are_not_themed(client):
"""resolve_ui_theme() short-circuits /api/ so it never touches the
Flask-Login session loader on JWT traffic."""
resp = client.get('/api/v1/stats/dashboard')
# Unauthenticated, so a 401/403/404 — the point is that it does not 500
# inside the theme resolver.
assert resp.status_code < 500
# ── Vote tally ───────────────────────────────────────────────────────────────
def test_theme_votes_is_admin_only(client):
director = _user('dan', 'director', theme='classic')
_login(client, director)
resp = client.get('/ui/theme-votes', follow_redirects=True)
assert 'Design' not in resp.get_data(as_text=True) or resp.status_code == 200
# The route redirects non-admins to the dashboard rather than 403.
assert client.get('/ui/theme-votes').status_code == 302
def test_theme_votes_renders_for_admin(client):
_user('ivy', 'inspector', theme='modern')
_user('xan', 'external_inspector', theme='classic')
admin = _user('ada', 'admin', theme='classic')
_login(client, admin)
resp = client.get('/ui/theme-votes')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
# MT-15's ROLE_LABELS must be used for the raw group_by role strings.
assert 'External Inspector' in body