From 513f708ee9dfc7175dbeef4637cfb9119e4a5df2 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 7 Aug 2026 16:32:40 -0400 Subject: [PATCH] Aug 7 - Update: UI change - MT16 --- app/__init__.py | 94 ++++ app/models/user.py | 11 + app/routes/dashboard.py | 60 ++ app/routes/ui.py | 140 +++++ app/static/css/theme_modern.css | 482 ++++++++++++++++ app/templates/base.html | 539 +----------------- app/templates/layouts/classic.html | 551 +++++++++++++++++++ app/templates/layouts/modern.html | 514 +++++++++++++++++ app/templates/modern/dashboard.html | 418 ++++++++++++++ app/templates/modern/facilities/list.html | 271 +++++++++ app/templates/modern/inspections/list.html | 362 ++++++++++++ app/templates/modern/issues/list.html | 408 ++++++++++++++ app/templates/ui/about.html | 103 ++++ app/templates/ui/support_center.html | 150 +++++ app/templates/ui/theme_votes.html | 71 +++ config.py | 13 + migrations/versions/phase52_user_ui_theme.py | 72 +++ tests/test_ui_theme.py | 260 +++++++++ 18 files changed, 3992 insertions(+), 527 deletions(-) create mode 100644 app/routes/ui.py create mode 100644 app/static/css/theme_modern.css create mode 100644 app/templates/layouts/classic.html create mode 100644 app/templates/layouts/modern.html create mode 100644 app/templates/modern/dashboard.html create mode 100644 app/templates/modern/facilities/list.html create mode 100644 app/templates/modern/inspections/list.html create mode 100644 app/templates/modern/issues/list.html create mode 100644 app/templates/ui/about.html create mode 100644 app/templates/ui/support_center.html create mode 100644 app/templates/ui/theme_votes.html create mode 100644 migrations/versions/phase52_user_ui_theme.py create mode 100644 tests/test_ui_theme.py diff --git a/app/__init__.py b/app/__init__.py index f8946bc..29eb66b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -28,8 +28,41 @@ limiter = Limiter( ) +# ── Web portal design: per-request template overrides (MT-16) ──────────────── +# A user on the 'modern' design gets templates/modern/.html in place of +# templates/.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/.""" + + # Populated once in create_app() by scanning templates/modern/. + jqc_modern_templates: set = set() + + def get_template(self, name, parent=None, globals=None): + if (isinstance(name, str) + and self.jqc_modern_templates + and not name.startswith('modern/')): + candidate = 'modern/' + name + if candidate in self.jqc_modern_templates: + from flask import g, has_request_context + if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern': + name = candidate + return super().get_template(name, parent, globals) + + def create_app(config_name='default'): app = Flask(__name__) + # Must be assigned BEFORE app.jinja_env is first touched (it is a cached + # property), so the themed subclass is the one actually instantiated. + app.jinja_environment = ThemedEnvironment app.config.from_object(config[config_name]) # 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 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 ────── # This powers the red badge on the navbar bell icon without requiring # 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 signup # MT-8+ — public self-service signup 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 app.register_blueprint(auth.bp) @@ -244,6 +337,7 @@ def create_app(config_name='default'): app.register_blueprint(tenant_settings.bp) app.register_blueprint(signup.bp) app.register_blueprint(landing.bp) + app.register_blueprint(ui.bp) # Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from # 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). diff --git a/app/models/user.py b/app/models/user.py index 77f0a99..5e8c901 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -55,6 +55,17 @@ class User(UserMixin, db.Model): created_at = db.Column(db.DateTime, default=now_eastern) active = db.Column(db.Boolean, default=True, nullable=False) + # ── Web portal design preference (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 ────────────────────────────────── # password_set: False for newly created customer accounts until they # complete the set-password flow via emailed link. diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index df389fc..6db7824 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -24,6 +24,10 @@ def index(): now = now_eastern() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) 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_privileged = current_user.role in ['admin', 'director'] @@ -62,6 +66,14 @@ def index(): Inspection.inspection_date < today_end, ).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_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) if is_inspector: @@ -282,6 +294,26 @@ def index(): stale_q = stale_q.filter(False) 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 ──────────────────────────────────────────────── from app.models.facility import Area as _AreaU 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. sched_upcoming = [] 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: from app.models.inspection_schedule import InspectionSchedule _today = now_eastern().date() @@ -365,11 +402,34 @@ def index(): s for s in _all_sched if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7) ][: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( 'dashboard.html', sched_upcoming = sched_upcoming, 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, completed_today = completed_today, open_issues = open_issues, diff --git a/app/routes/ui.py b/app/routes/ui.py new file mode 100644 index 0000000..9fa7439 --- /dev/null +++ b/app/routes/ui.py @@ -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) diff --git a/app/static/css/theme_modern.css b/app/static/css/theme_modern.css new file mode 100644 index 0000000..50a382a --- /dev/null +++ b/app/static/css/theme_modern.css @@ -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; } +} diff --git a/app/templates/base.html b/app/templates/base.html index d1eca28..8eb6db7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,530 +1,15 @@ - - - - - - - - - - {% block title %}Janitorial QC System{% endblock %} - - - - - - - {% if tenant_branding %} - - {% endif %} - {% block extra_css %}{% endblock %} - - - - {% if current_user.is_authenticated %} - - {% endif %} - -
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - - {% include 'billing/_billing_banner.html' %} - - {% block content %}{% endblock %} -
- - - {% block extra_js %}{% endblock %} - - {% if current_user.is_authenticated %} - - {% endif %} - - \ No newline at end of file + `jqc_layout` is supplied by the inject_ui_theme() context processor in + app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with + config DEFAULT_UI_THEME as the fallback for accounts that never chose. + ──────────────────────────────────────────────────────────────────────────── #} +{% extends jqc_layout %} diff --git a/app/templates/layouts/classic.html b/app/templates/layouts/classic.html new file mode 100644 index 0000000..273206e --- /dev/null +++ b/app/templates/layouts/classic.html @@ -0,0 +1,551 @@ + + + + + + + + + + {% block title %}Janitorial QC System{% endblock %} + + + + + + + {% if tenant_branding %} + + {% endif %} + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% include 'billing/_billing_banner.html' %} + + {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + {% if current_user.is_authenticated %} + + {% endif %} + + \ No newline at end of file diff --git a/app/templates/layouts/modern.html b/app/templates/layouts/modern.html new file mode 100644 index 0000000..175b53d --- /dev/null +++ b/app/templates/layouts/modern.html @@ -0,0 +1,514 @@ + + + + + + + + + + {% block title %}Janitorial QC System{% endblock %} + + + + + + {# theme_modern.css loads LAST so it wins over theme.css tokens #} + + {# 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 %} + + {% endif %} + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + + +
+ + + + {% if tenant_branding and tenant_branding.logo_url %} + + {% else %} + JQC + {% endif %} + + {{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }} + + + + {# Scoped to inspection ID only — the placeholder says so explicitly so + nobody types a facility name and assumes the search is broken. #} + + +
+ + + + + +
+
+ + + +
+ {% endif %} + + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% 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 %} +
+
+ + + {% block extra_js %}{% endblock %} + + {% if current_user.is_authenticated %} + + + {% endif %} + + diff --git a/app/templates/modern/dashboard.html b/app/templates/modern/dashboard.html new file mode 100644 index 0000000..03703c5 --- /dev/null +++ b/app/templates/modern/dashboard.html @@ -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 ───────────────────────────────────────────────────────────── #} +
+
+
Welcome, {{ current_user.display_name }}
+
{{ current_user.role.replace('_',' ')|title }}
+
+
{{ now_display }}
+
+ +{# ── Scheduled inspections ────────────────────────────────────────────── #} +{% if current_user.role != 'customer' %} +
+
+
+ Scheduled Inspection In Progress +
+ View all +
+ + {% if sched_overdue_count %} +
+ + {{ sched_overdue_count }} scheduled inspection{{ 's' if sched_overdue_count != 1 }} + {{ 'are' if sched_overdue_count != 1 else 'is' }} overdue. +
+ {% endif %} + + {% if sched_upcoming %} +
+ + + + + + + + + {% for s in sched_upcoming %} + + + + + + + + + {% endfor %} + +
FacilityInspection TemplateInspectorHow OftenNext Due Date
{{ s.facility.name if s.facility else '—' }}{{ s.template.name if s.template else '—' }}{{ s.inspector.display_name if s.inspector else '—' }}{{ s.recurrence_label }}{{ s.next_due_date.strftime('%b %d, %Y') }} + {% if s.inspector_id and s.inspector_id == current_user.id %} + {% if s.is_acknowledged %} + Confirmed + {% else %} +
+ + +
+ {% endif %} + {% set open_id = sched_open_inspections.get(s.id) %} + {% if open_id %} + + Continue + {% else %} + Start + {% endif %} + {% endif %} +
+
+ {% else %} +
No inspections due in the next 7 days.
+ {% endif %} +
+{% endif %} + +{# ── KPI row ──────────────────────────────────────────────────────────── #} + + +{# ── Three summary cards ──────────────────────────────────────────────── #} + + +{# ── Recent activity ──────────────────────────────────────────────────── #} +
+
+
+ Recent Activities +
+ View all +
+ {% if recent_inspections %} +
+ + + + + + + {% if not current_user.is_inspector %}{% endif %} + + + + + + {% 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. #} + + + + + {% if not current_user.is_inspector %}{% endif %} + + + + {% endfor %} + +
DateFacility NameAreaInspectorScoreStatus
+ {{ insp.inspection_date.strftime('%b %d, %Y') }} + {{ insp.facility.name }}{{ insp.area.name if insp.area else '—' }}{{ insp.inspector.display_name }} + {% if insp.overall_score %} + + {{ insp.overall_score }}% + + {% else %} + + {% endif %} + + + {{ 'Submitted' if insp.status == 'completed' else insp.status|title }} + +
+
+ {% else %} +
+ No recent inspections. +
+ {% endif %} +
+ +{# ── My open issues (inspector widget) ────────────────────────────────── #} +{% if my_issues %} +
+
+
+ My Open Issues +
+ View all +
+
+ + + + + + + + + + + + {% for issue in my_issues %} + {% set sla = sla_status(issue) %} + + + + + + + + {% endfor %} + +
IDSeverityFacility / DescriptionStatusSLA
+ #{{ issue.id }} + + + {{ issue.severity|title }} + + +
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
+
+ + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %} + Breached + {% elif sla == 'at_risk' %} + {{ sla_hours_remaining(issue)|abs|round(1) }}h left + {% else %} + OK + {% endif %} +
+
+
+{% endif %} + +{# ── Customer portal: scoped facilities panel ─────────────────────────── #} +{% if current_user.role == 'customer' and customer_facilities %} +
+
+ Your Facilities + {{ customer_facilities|length }} +
+ {% if customer_facilities|length > 6 %} +
+ +
+ {% endif %} +
+ {% for f in customer_facilities %} +
+
+
{{ f.name }}
+
{{ f.address or '—' }}
+
+ {{ f.project.name if f.project else '—' }} +
+ +
+
+ {% endfor %} +
+ {% if customer_facilities|length > 9 %} +
+ +
+ {% endif %} +
+ +{# Same VISIBLE = 9 / search > 6 thresholds as the classic dashboard (rule 65). #} + +{% endif %} + +{% endblock %} diff --git a/app/templates/modern/facilities/list.html b/app/templates/modern/facilities/list.html new file mode 100644 index 0000000..99c9f07 --- /dev/null +++ b/app/templates/modern/facilities/list.html @@ -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 %} +
+
Facilities
+
Manage facility records, statistics and QR access
+
+ + + +{# ── Original facility list (unchanged) ───────────────────────────────── #} +
+

+ All Facilities +

+
+ {% if not current_user.is_inspector %} + + Print All QR Codes + + {% endif %} + {% if current_user.role in ['admin', 'director'] %} + + Add Facility + + {% endif %} +
+
+ +{% if grouped %} + {% for group_key, group in grouped.items() %} + {# ── Contract group header ────────────────────────────────────────────── #} + {% set collapse_id = 'contract-' ~ loop.index %} +
+
+ + {{ group.facilities|length }} + {% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %} + + + + {% endif %} +
+ + {# ── Collapsible card grid ─────────────────────────────────────────── #} +
+
+ {% for facility in group.facilities %} +
+
+
+
+ + {{ facility.name }} + + {% if not facility.active %} + Inactive + {% endif %} +
+ + {% if facility.address %} +

+ {{ facility.address }} +

+ {% endif %} + +
+ + {{ facility.areas.count() }} areas + +
+
+ +
+
+ {% endfor %} +
+
+
+ {% endfor %} + +{% else %} +
+ No facilities configured yet. +
+{% endif %} + +{% if current_user.role == 'admin' %} + + +{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/modern/inspections/list.html b/app/templates/modern/inspections/list.html new file mode 100644 index 0000000..c80ebdf --- /dev/null +++ b/app/templates/modern/inspections/list.html @@ -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 ───────────────────────────────────────────────────────────── #} +
+
+
Inspections
+
+ {% if current_user.role != 'customer' %} + + {% endif %} +
+ +{# ── 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. #} +
+
+
+ + {# ── Fields ──────────────────────────────────────────────────────── #} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {# 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. #} +
+ {% if inspectors %} +
+ + +
+ {% endif %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {# ── 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. #} +
+ + +
+ +
+
+
+ +{# ── Results ──────────────────────────────────────────────────────────── #} +
+ {% if active_filters %} +
+ + {{ inspections.total }} inspection{{ 's' if inspections.total != 1 }} match + {{ 'es' if inspections.total == 1 }} your filters +
+ {% endif %} + + {% if inspections.items %} +
+ + + + + + + + + + {% for ins in inspections.items %} + + + + + + + + + + + + + {% endfor %} + +
#DateContractFacilityAreaTemplateInspectorScoreStatus
#{{ ins.id }}{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}{{ ins.facility.name }}{% if ins.area %}{{ ins.area.name }}{% else %}{% endif %} + {{ ins.template.name }} + {% if ins.scheduled_inspection_id %} + + Scheduled + + {% endif %} + {{ ins.inspector.display_name }} + {% if ins.overall_score %} + + {{ ins.overall_score }}% + + {% else %}{% endif %} + + + {{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }} + + {% if ins.status == 'in_progress' %} + {% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %} + {% if hours_open > 24 %} + + Stale + + {% endif %} + {% endif %} + {% if ins.follow_up_required and not ins.follow_ups.count() %} + + Follow-up + + {% endif %} + + {% if ins.status == 'in_progress' or ins.status == 'flagged' %} + Continue + {% else %} + View + {% endif %} + {% if current_user.role in ['admin', 'director'] %} + + {% endif %} +
+
+ + {# Pagination #} + {% if inspections.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
+ + No inspections found. + +
+ {% endif %} +
+ +{% if current_user.role in ['admin', 'director'] %} + + +{% endif %} +{% endblock %} + +{% block extra_js %} + + + + +{% if current_user.role in ['admin', 'director'] %} + +{% endif %} +{% endblock %} diff --git a/app/templates/modern/issues/list.html b/app/templates/modern/issues/list.html new file mode 100644 index 0000000..4d170a7 --- /dev/null +++ b/app/templates/modern/issues/list.html @@ -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 ───────────────────────────────────────────────────────────── #} +
+
+
Issues
+
+ {% if current_user.role in ['admin','director','customer','auditor'] %} + + Log Issue + + {% endif %} +
+ +{# ── 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. #} +
+
+
+ + {# ── Fields ──────────────────────────────────────────────────────── #} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {# ── 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. #} +
+ + +
+ +
+
+
+ +{# ── Results ──────────────────────────────────────────────────────────── #} +
+ {% if active_filters %} +
+ + {{ issues.total }} issue{{ 's' if issues.total != 1 }} match + {{ 'es' if issues.total == 1 }} your filters +
+ {% endif %} + + {% if issues.items %} +
+ + + + + + + + + + + + + + + + + + {% for issue in issues.items %} + {% set is_following = issue.id in followed_ids %} + {% set sla = sla_status(issue) %} + + + + + + + + + + + + + + {% endfor %} + +
#ReportedSeverityContractFacility / AreaDescriptionStatusSLAReporterAssigned
#{{ issue.id }}{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }} + + {{ issue.severity|title }} + + + {% set _c = issue.resolved_facility.project if issue.resolved_facility else none %} + {{ _c.name if _c else '—' }} + + {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ {{ issue.area.name if issue.area else '—' }} +
+ 60 %} title="{{ issue.description }}" style="cursor:help;"{% endif %}> + {{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %} + + + + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %} + Breached + {% elif sla == 'at_risk' %} + {% set hrs = sla_hours_remaining(issue) %} + {{ hrs|abs|round(1) }}h left + {% elif sla == 'ok' %} + OK + {% else %} + + {% endif %} + + {% if issue.reporter %} + {{ issue.reporter.display_name }} + {% else %}{% endif %} + + {% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %} +
+ + +
+ {% else %} + {% if issue.assigned_user %}{{ issue.assigned_user.display_name }} + {% else %}{% endif %} + {% endif %} + {% if issue.handler_type == 'facility' %} +
Facility
+ {% elif issue.handler_type == 'vendor' %} +
Vendor
+ {% endif %} +
+ {# Following badge + inline unfollow #} + {% if is_following %} + + Following + +
+ + + +
+ {% endif %} + + + {% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %} + Edit + {% else %} + View + {% endif %} + + {% if current_user.role in ['admin', 'director'] %} +
+ + +
+ {% endif %} +
+
+ + {% if issues.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
+ + No issues found. + +
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + + +{% if current_user.role in ['admin', 'director', 'auditor'] %} + +{% endif %} +{% endblock %} diff --git a/app/templates/ui/about.html b/app/templates/ui/about.html new file mode 100644 index 0000000..f9c4f93 --- /dev/null +++ b/app/templates/ui/about.html @@ -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 %} +
+
About
+
+ Janitorial Quality Control{% if tenant_branding %} — {{ tenant_branding.display_name }}{% endif %} +
+
+ +
+
+
+
+ {{ workspace_name }} +
+

+ 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. +

+

+ 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. +

+
+
+ +
+
+
+ Your data +
+

+ {{ 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. +

+
+
+ + {% if current_user.role == 'admin' %} + + + + {% endif %} + +
+
+
+ Contact & Support +
+

+ 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. +

+ + Go to Support + +
+
+
+{% endblock %} diff --git a/app/templates/ui/support_center.html b/app/templates/ui/support_center.html new file mode 100644 index 0000000..5a5b7d2 --- /dev/null +++ b/app/templates/ui/support_center.html @@ -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 %} +
+
Support
+
JQC Features — find answers and how-to guides
+
+ +{# ── Live support routes (role-aware) ──────────────────────────────────── #} + + +{# ── 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.'), +] %} + +
+ {% for icon, title, body in guides %} +
+ +
+ {% 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' %} + + {% elif current_user.role in ['admin', 'director'] %} + + {% endif %} +
+{% endblock %} diff --git a/app/templates/ui/theme_votes.html b/app/templates/ui/theme_votes.html new file mode 100644 index 0000000..2a9b9d8 --- /dev/null +++ b/app/templates/ui/theme_votes.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Design Vote Tally{% endblock %} + +{# Admin-only: which design are active users currently keeping? #} + +{% block content %} +
+
+

Design Vote Tally

+
Which web portal design each active user is currently using.
+
+
+ +
+
+
+
+
Classic design
+
{{ tally.classic }}
+
+ {{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users +
+
+
+
+
+
+
+
New design
+
{{ tally.modern }}
+
+ {{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users +
+
+
+
+
+
+
+
Total active users
+
{{ total }}
+
Every account defaults to classic
+
+
+
+
+ +
+
Breakdown by role
+
+
+ + + + + + {% for role, theme, count in by_role %} + + + + + + {% else %} + + {% endfor %} + +
RoleDesignUsers
{{ role_labels.get(role, role.replace('_',' ')|title) }}{{ 'New design' if theme == 'modern' else 'Classic design' }}{{ count }}
No active users.
+
+
+
+{% endblock %} diff --git a/config.py b/config.py index 447caa7..065a6ee 100644 --- a/config.py +++ b/config.py @@ -88,6 +88,19 @@ class Config: # 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' + # ── 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) ───────────────────────────────────────────── # 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. diff --git a/migrations/versions/phase52_user_ui_theme.py b/migrations/versions/phase52_user_ui_theme.py new file mode 100644 index 0000000..29c75f5 --- /dev/null +++ b/migrations/versions/phase52_user_ui_theme.py @@ -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")) diff --git a/tests/test_ui_theme.py b/tests/test_ui_theme.py new file mode 100644 index 0000000..3cbf382 --- /dev/null +++ b/tests/test_ui_theme.py @@ -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