diff --git a/DEPLOYMENT_MAP.txt b/DEPLOYMENT_MAP.txt index 9bf65f3..48fedf5 100644 --- a/DEPLOYMENT_MAP.txt +++ b/DEPLOYMENT_MAP.txt @@ -1,68 +1,181 @@ -JQC WEB — phase44: scheduled inspection End Date -================================================ -Repo: lt_janitorial_quality_control -Deploy root: /home/jqc/janitorial_qc/ -WEB ONLY. No iOS changes, no iPad rebuild. +════════════════════════════════════════════════════════════════════════════ + JQC — phase48: Modern web portal design (A/B test with user switch) +════════════════════════════════════════════════════════════════════════════ -NEW FILE - migrations/versions/phase44_scheduled_end_date.py - revision = 'phase44_sched_end_date' - down_revision = 'phase43_sched_recurrence' <- verified current HEAD +WHAT THIS DOES +────────────── +Adds a second, complete web portal design (sidebar shell, teal-blue palette +from JQC_design.pptx) alongside the existing one. Each user picks which design +they see; the choice is stored on their account and doubles as their vote. -OVERWRITE - app/models/scheduled_inspection.py end_date column; is_within_end_date(); - is_expired; expire_if_past_end_date(); - fulfill() deactivates past the boundary - app/utils/forms.py end_date DateField + validate() rules - app/routes/scheduled_inspections.py _apply_recurrence() sets/clears end_date; - _reject_if_past_end_date() guard; - per-context next_due_date label; - run_reminders() expiry sweep; audit detail - app/api/scheduled.py 'end_date' in _scheduled_payload - app/templates/scheduled_inspections/form.html End Date field + JS toggle - app/templates/scheduled_inspections/list.html "Ends" column + "Ended" badge - CLAUDE.md +The classic design is byte-for-byte unchanged apart from one added menu item +("Try the New Design"). No route, endpoint, function, model or column was +renamed, and no functionality was removed. -DEPLOY (migration step first, then code) + +HOW IT WORKS (root of the design, not a workaround) +─────────────────────────────────────────────────── +1. `app/templates/base.html` used to hold the entire page chrome. That markup + moved verbatim to `app/templates/layouts/classic.html`. + +2. `base.html` is now a single line — `{% extends jqc_layout %}`. Jinja resolves + `{% block %}` overrides through the whole inheritance chain, so all 69 page + templates keep `{% extends "base.html" %}` and needed ZERO edits. + +3. `jqc_layout` is supplied by the new `inject_ui_theme()` context processor in + `app/__init__.py`, driven by the new `users.ui_theme` column + ('classic' | 'modern'). + +4. Pages whose layout genuinely differs in the deck get an override file under + `app/templates/modern/.html`. `ThemedEnvironment.get_template()` + (app/__init__.py) swaps `dashboard.html` → `modern/dashboard.html` only when + `g.jqc_theme == 'modern'`. The swap happens in `get_template()` rather than + in the loader **on purpose**: Jinja's template cache is keyed on the name + that `get_template()` receives, so a cached modern template can never be + served to a classic user or vice versa. A loader-level swap would have that + bug. + +5. Every other page renders its existing markup inside the modern shell and is + restyled by `static/css/theme_modern.css`, which loads after `theme.css` and + is scoped to `body.jqc-modern`. Classic pages never load that file. + + +FILES — PLACEMENT MAP +───────────────────── +NEW + app/templates/layouts/classic.html ← old base.html verbatim + one + "Try the New Design" menu item in + the user dropdown + app/templates/layouts/modern.html ← new sidebar shell (top bar, search, + bell, avatar, sidebar nav, switch) + app/static/css/theme_modern.css ← modern skin, scoped to .jqc-modern + app/routes/ui.py ← blueprint `ui` + POST /ui/theme switch_theme() + GET /ui/about about() + GET /ui/support-center support_center() + GET /ui/theme-votes theme_votes() (admin) + app/templates/modern/dashboard.html ← deck slide 1 + app/templates/modern/facilities/list.html ← deck slide 5 (hub cards + the + original list, unchanged, below) + app/templates/ui/about.html ← new About Us page + app/templates/ui/support_center.html ← deck slide 6 support hub + app/templates/ui/theme_votes.html ← admin vote tally + migrations/versions/phase48_user_ui_theme.py + migrations/versions/0003_add_user_active.py + NO-OP stub. Repairs a PRE-EXISTING break in the Alembic revision graph: + phase1_projects_roles.py declares down_revision = '0003_add_user_active' + but that script is not in the repo (the early 0001-0003 files were lost). + Alembic warns while walking the graph but raises KeyError as soon as it + builds the full revision map, which any `flask db upgrade ` does. + The stub restores the node with down_revision = None and empty + upgrade()/downgrade(). No schema effect. Do not delete it. + +MODIFIED + app/templates/base.html + Entire file replaced by the one-line dispatcher (old content now lives in + layouts/classic.html). + app/models/user.py + class User — added `ui_theme` column after `active`. + VARCHAR(16) NOT NULL DEFAULT 'classic'. + app/__init__.py + + `ThemedEnvironment` class above create_app() + + `app.jinja_environment = ThemedEnvironment` as the FIRST statement in + create_app() (must precede any touch of app.jinja_env — it is a cached + property) + + modern-template index built at boot, before_request `resolve_ui_theme()`, + context processor `inject_ui_theme()` (also exposes `now_display`) + + `from app.routes import ui` and `app.register_blueprint(ui.bp)` + + +DEPLOY — STEP 1: CODE +───────────────────── cd /home/jqc/janitorial_qc - git pull + # back up the two files being replaced wholesale + cp app/templates/base.html /tmp/base.html.bak + cp app/__init__.py /tmp/__init__.py.bak - # 1. MIGRATION - source venv/bin/activate - flask db current # expect phase43_sched_recurrence + # unzip the package over the repo root (paths already match) + unzip -o jqc_phase48_modern_design.zip -d /home/jqc/janitorial_qc + + chown -R jqc:jqc /home/jqc/janitorial_qc/app + +DEPLOY — STEP 2: MIGRATION (run separately, after the code is in place) +─────────────────────────────────────────────────────────────────────── + cd /home/jqc/janitorial_qc + source venv/bin/activate # adjust if your venv path differs flask db upgrade - flask db current # expect phase44_sched_end_date - # 2. CODE - sudo systemctl restart janitorial_qc - sudo systemctl status janitorial_qc --no-pager + Expect: phase47_sched_acknowledged → phase48_user_ui_theme + With the 0003 stub in place, `flask db heads` reports exactly one head + (phase48_user_ui_theme) and no "Revision ... is not present" warning. + The migration uses an INFORMATION_SCHEMA existence check and an idempotent + backfill — safe to re-run. + + Verify: + mysql -e "SHOW COLUMNS FROM users LIKE 'ui_theme';" janitorial_qc + +DEPLOY — STEP 3: RESTART +──────────────────────── + sudo systemctl restart jqc # or your unit name + journalctl -u jqc -n 40 --no-pager + + Look for: "UI themes | modern overrides indexed: 2" + + No Nginx change is required — no new external host, no CSP change. + + +VERIFICATION +──────────── + 1. Log in. Portal looks exactly as before (everyone starts on classic). + 2. Account menu (top right) → "Try the New Design" → same page reloads in the + sidebar design, flash message confirms. + 3. Dashboard: 4 KPI tiles + Inspection / Open Issues / SLA Issues cards + + Scheduled + Recent Activities. Click each number — it lands on the same + filtered list the classic dashboard links to. + 4. Facility: 4 hub cards, then the full grouped facility list underneath. + Add Facility / Print All QR / Delete modal all still work. + 5. Sidebar → Supports and About Us render. + 6. Bell icon: badge count and dropdown behave as on classic. + 7. Sidebar → "Classic Design" button (or account menu) → returns to classic. + 8. Log out and back in — the design choice persists. + 9. Admin account menu → "Design Vote Tally" shows the split. +10. Audit Trail shows UPDATE / User / "ui_theme=classic→modern" for each switch. +11. Narrow the browser below 992px — the sidebar becomes an off-canvas drawer + behind the hamburger. + ROLLBACK - flask db downgrade phase43_sched_recurrence # drops end_date, nothing else +──────── +Fastest (no deploy): reset everyone to classic — + mysql -e "UPDATE users SET ui_theme='classic';" janitorial_qc +The modern design becomes unreachable; nothing else changes. -VERIFY - 1. New Schedule -> frequency "One-time": End Date row is HIDDEN, - date field reads "Start Date" - 2. Switch frequency to Weekly: End Date row appears - 3. Edit an existing schedule: date field reads "Next Due Date" - 4. Validation: - - end date before the due date -> rejected - - end date on a one-time schedule (via curl/devtools) -> rejected - - Mon/Wed/Fri, pick a Tuesday, end date that same Tuesday - -> rejected, message names the Wednesday - 5. List: "Ends" column shows the date, "No end" when blank, "—" for one-time - 6. Existing schedules: unchanged, "No end", still Active, cadence identical - 7. Boundary: set end date = next due date, complete the inspection - -> schedule goes Inactive, badge reads "Ended" - 8. Cron sweep: - curl -X POST "https://jqc.ltservicesinc.com/scheduled-inspections/run?token=$DIGEST_SECRET" - -> JSON now includes "expired": N - -> a schedule past its end date that was never completed goes Inactive - and stops generating overdue alerts +Full rollback: + cp /tmp/base.html.bak app/templates/base.html + cp /tmp/__init__.py.bak app/__init__.py + rm -rf app/templates/layouts app/templates/modern app/templates/ui \ + app/static/css/theme_modern.css app/routes/ui.py + flask db downgrade phase47_sched_acknowledged + sudo systemctl restart jqc -NOTES - - Migration follows the phase42/phase43 idiom in this repo - (op.get_bind() + sa.text() + INFORMATION_SCHEMA). Flag if you want the - stricter plain-string-only form instead; 62 existing migrations use this one. - - api/scheduled.py now returns "end_date". Additive and safe: the iPad - decodes explicit CodingKeys, so current builds ignore the new key. + +KNOWN SCOPE LIMITS (deliberate) +─────────────────────────────── +• Deck slides 2 (Reports), 3 (Inspections) and 4 (Issues) are NOT rebuilt as + separate templates. Their existing structure already matches the deck + (title + subtitle, filter row, KPI row, cards, table) and theme_modern.css + restyles them — dark-teal table headers, pill filters, rounded cards. Building + parallel copies of those three templates would duplicate several hundred lines + of filter/permission/export logic and double the maintenance surface during a + vote. Say the word after the vote and I will rebuild whichever ones you keep. +• Slide 3's "Scheduled Inspection In Progress" panel appears on the modern + DASHBOARD (where the route already supplies that data). Putting it on the + Inspections page as well needs an additive query in `inspections.index` — + small, but it is a route change, so it is not in this package. +• The deck's "Overall Score" and "Avg. Score" KPI tiles are not on the modern + dashboard: `dashboard.index` does not compute either value today. Adding them + means new aggregate queries in the route — flag it and I will send that + separately. +• "Customize" on the Facility hub points at Templates (inspection templates), + the closest existing feature. There is no facility field/tag configuration + screen in the app yet. diff --git a/app/__init__.py b/app/__init__.py index 92343ab..6154a26 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -27,8 +27,40 @@ limiter = Limiter( ) +# ── Design A/B test: per-request template overrides (phase48) ──────────────── +# 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. +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]) # ── Reverse-proxy awareness (Nginx) ────────────────────────────────────── @@ -111,6 +143,54 @@ def create_app(config_name='default'): from app.utils import storage as _storage app.jinja_env.globals['media_url'] = _storage.media_url + # ── Design A/B test wiring (phase48) ────────────────────────────────── + # Index the modern/ override templates once at boot, so get_template() + # never has to touch the filesystem per request. + _modern_root = os.path.join(app.template_folder or 'templates', 'modern') + if not os.path.isabs(_modern_root): + _modern_root = os.path.join(app.root_path, _modern_root) + _modern_set = set() + if os.path.isdir(_modern_root): + for _dirpath, _dirnames, _filenames in os.walk(_modern_root): + for _fn in _filenames: + if _fn.endswith('.html'): + _rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root) + _modern_set.add('modern/' + _rel.replace(os.sep, '/')) + ThemedEnvironment.jqc_modern_templates = _modern_set + app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set)) + + from flask import g + + @app.before_request + def resolve_ui_theme(): + """Stash the active design on `g` for ThemedEnvironment.get_template().""" + # The mobile API renders no templates and authenticates by JWT — skip it + # so this never touches the Flask-Login session loader on API traffic. + if request.path.startswith('/api/'): + g.jqc_theme = 'classic' + return + from flask_login import current_user as _cu + theme = 'classic' + try: + if _cu.is_authenticated: + theme = _cu.ui_theme or 'classic' + except Exception: # DB column missing (migration not yet run) + theme = 'classic' + g.jqc_theme = theme if theme in ('classic', 'modern') else 'classic' + + @app.context_processor + def inject_ui_theme(): + """Give base.html the shell to extend.""" + from app.utils.time_utils import now_eastern + theme = getattr(g, 'jqc_theme', 'classic') + return { + 'jqc_theme': theme, + 'jqc_layout': 'layouts/modern.html' if theme == 'modern' + else 'layouts/classic.html', + # Long-form date shown in the modern dashboard header. + 'now_display': now_eastern().strftime('%A, %B %-d, %Y'), + } + # ── Inject unread notification count into every template context ────── # This powers the red badge on the navbar bell icon without requiring # individual routes to pass the count manually. @@ -183,6 +263,7 @@ def create_app(config_name='default'): from app.routes import devices # Admin device registry from app.routes import public # Public facility QR pages (no login) from app.routes import scheduled_inspections # Planned/recurring inspections + from app.routes import ui # phase48 — design A/B test + new pages app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -201,6 +282,7 @@ def create_app(config_name='default'): app.register_blueprint(devices.bp) app.register_blueprint(public.bp) app.register_blueprint(scheduled_inspections.bp) + app.register_blueprint(ui.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. diff --git a/app/models/user.py b/app/models/user.py index df17f82..107e0aa 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,6 +25,15 @@ class User(UserMixin, db.Model): created_at = db.Column(db.DateTime, default=now_eastern) active = db.Column(db.Boolean, default=True, nullable=False) + # ── Web portal design preference (phase48 — design A/B test) ────────── + # 'classic' = the original top-navbar design (default for every account). + # 'modern' = the sidebar design from the JQC_design deck. + # Drives base.html's layout dispatch via the inject_ui_theme() context + # processor. Persisted per user so the choice survives logout and can be + # tallied as a vote (see /ui/theme-votes). + ui_theme = db.Column(db.String(16), nullable=False, + server_default='classic', default='classic') + # ── Customer password-setup workflow ────────────────────────────────── # password_set: False for newly created customer accounts until they # complete the set-password flow via emailed link. diff --git a/app/routes/ui.py b/app/routes/ui.py new file mode 100644 index 0000000..5e356b8 --- /dev/null +++ b/app/routes/ui.py @@ -0,0 +1,115 @@ +""" +ui.py +----- +Web-portal design A/B test (phase48). + +Routes + POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern + GET /ui/about about() — About Us page (new, modern deck) + GET /ui/support-center support_center() — Support hub of how-to cards (new) + GET /ui/theme-votes theme_votes() — admin tally of which design users kept + +Nothing here changes existing behaviour: the theme flag only selects which +layout shell base.html extends. +""" + +import logging + +from flask import (Blueprint, render_template, redirect, request, + url_for, flash) +from flask_login import login_required, current_user +from sqlalchemy import func + +from app import db +from app.models.user import User +from app.utils.audit import log_action, ACTION_UPDATE + +bp = Blueprint('ui', __name__, url_prefix='/ui') + +logger = logging.getLogger(__name__) + +VALID_THEMES = ('classic', 'modern') + + +def _safe_next(target: str | None) -> str: + """Only allow same-site relative redirects (open-redirect guard).""" + if not target: + return url_for('dashboard.index') + if target.startswith('/') and not target.startswith('//'): + return target + return url_for('dashboard.index') + + +# ── Design switch ───────────────────────────────────────────────────────────── + +@bp.route('/theme', methods=['POST']) +@login_required +def switch_theme(): + """Persist the user's design choice, then return them to the same page.""" + theme = (request.form.get('theme') or '').strip().lower() + if theme not in VALID_THEMES: + flash('Unknown design option.', 'warning') + return redirect(_safe_next(request.form.get('next'))) + + previous = current_user.ui_theme or 'classic' + if previous != theme: + current_user.ui_theme = theme + db.session.commit() + + # log_action() commits internally — always AFTER the business commit. + log_action( + action = ACTION_UPDATE, + entity_type = 'User', + entity_id = current_user.id, + entity_label = current_user.username, + details = f'ui_theme={previous}→{theme}', + ) + logger.info('UI | theme switch | user=%s | %s -> %s', + current_user.username, previous, theme) + flash('Now showing the {} design. You can switch back any time from ' + 'the account menu.'.format('new' if theme == 'modern' else 'classic'), + 'info') + + return redirect(_safe_next(request.form.get('next'))) + + +# ── New pages (linked from the modern sidebar) ─────────────────────────────── + +@bp.route('/about') +@login_required +def about(): + return render_template('ui/about.html') + + +@bp.route('/support-center') +@login_required +def support_center(): + return render_template('ui/support_center.html') + + +# ── Admin: which design are people actually keeping? ───────────────────────── + +@bp.route('/theme-votes') +@login_required +def theme_votes(): + if current_user.role != 'admin': + flash('You do not have permission to view the design vote tally.', 'danger') + return redirect(url_for('dashboard.index')) + + rows = (db.session.query(User.ui_theme, func.count(User.id)) + .filter(User.active == True) # noqa: E712 — SQL boolean + .group_by(User.ui_theme) + .all()) + tally = {t: 0 for t in VALID_THEMES} + for theme, count in rows: + tally[theme or 'classic'] = tally.get(theme or 'classic', 0) + count + total = sum(tally.values()) + + by_role = (db.session.query(User.role, User.ui_theme, func.count(User.id)) + .filter(User.active == True) # noqa: E712 + .group_by(User.role, User.ui_theme) + .order_by(User.role) + .all()) + + return render_template('ui/theme_votes.html', + tally=tally, total=total, by_role=by_role) diff --git a/app/static/css/theme_modern.css b/app/static/css/theme_modern.css new file mode 100644 index 0000000..051334b --- /dev/null +++ b/app/static/css/theme_modern.css @@ -0,0 +1,472 @@ +/* ════════════════════════════════════════════════════════════════════════ + Janitorial QC — MODERN design skin (design A/B test — "modern") + ──────────────────────────────────────────────────────────────────────── + Loaded ONLY by templates/layouts/modern.html, and always AFTER + theme.css, so every token below overrides the classic one. + + The classic design is completely untouched by this file. + + Palette sampled from the JQC_design deck: + brand #155F82 deep teal-blue top bar / table headers + brand-700 #0F4A66 hover / pressed + brand-050 #DCEBF5 soft icon tiles, active rail rows + page #EAEEF1 page background + surface #FFFFFF cards + ink #1D2A32 headings + muted #6B7A85 secondary text + ════════════════════════════════════════════════════════════════════════ */ + +/* ── 1. Tokens ───────────────────────────────────────────────────────────── */ +body.jqc-modern { + --jqc-brand: #155F82; + --jqc-brand-700: #0F4A66; + --jqc-brand-600: #1B6E93; + --jqc-brand-050: #DCEBF5; + --jqc-brand-025: #E9F0F8; + --jqc-page: #EAEEF1; + --jqc-ink: #1D2A32; + --jqc-heading: #1D2A32; + --jqc-muted: #6B7A85; + --jqc-faint: #93A1AB; + --jqc-border: #E3E8EC; + --jqc-border-2: #CFD9E0; + --jqc-surface: #F5F8FA; + --jqc-surface-2: #EEF3F6; + --jqc-accent: #155F82; + --jqc-accent-700: #0F4A66; + --jqc-accent-50: #DCEBF5; + --jqc-shadow: 0 1px 2px rgba(21, 46, 62, .05), 0 6px 18px rgba(21, 46, 62, .06); + --jqc-shadow-md: 0 10px 30px rgba(21, 46, 62, .12); + + --bs-primary: #155F82; + --bs-primary-rgb: 21, 95, 130; + --bs-link-color: #155F82; + --bs-link-color-rgb: 21, 95, 130; + --bs-link-hover-color: #0F4A66; + --bs-link-hover-color-rgb: 15, 74, 102; + --bs-body-bg: #EAEEF1; + --bs-body-color: #1D2A32; + --bs-border-color: #E3E8EC; + --bs-border-radius: .6rem; + --bs-border-radius-sm: .45rem; + --bs-border-radius-lg: 1rem; + --bs-border-radius-xl: 1.15rem; + + --jqc-topbar-h: 72px; + --jqc-sidebar-w: 232px; + + background-color: var(--jqc-page); + color: var(--jqc-ink); + font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +/* ── 2. Top bar ──────────────────────────────────────────────────────────── */ +.jqc-modern .jqc-topbar { + position: fixed; + top: 0; left: 0; right: 0; + height: var(--jqc-topbar-h); + z-index: 1035; + background: var(--jqc-brand); + display: flex; + align-items: center; + gap: 14px; + padding: 0 20px; + padding-top: env(safe-area-inset-top); + box-shadow: 0 1px 0 rgba(0, 0, 0, .10); +} +.jqc-modern .jqc-brand { + text-decoration: none; + color: #fff; + line-height: 1; + flex: 0 0 auto; +} +.jqc-modern .jqc-brand-mark { + display: block; + font-size: 1.75rem; + font-weight: 800; + letter-spacing: -.02em; +} +.jqc-modern .jqc-brand-sub { + display: block; + font-size: .68rem; + opacity: .82; + margin-top: 3px; +} +.jqc-modern .jqc-search { + position: relative; + margin-left: auto; + width: min(420px, 42vw); +} +.jqc-modern .jqc-search i { + position: absolute; + left: 16px; top: 50%; + transform: translateY(-50%); + color: var(--jqc-muted); + pointer-events: none; +} +.jqc-modern .jqc-search .form-control { + border: none; + border-radius: 999px; + height: 42px; + padding-left: 44px; + background: #fff; + font-size: .92rem; +} +.jqc-modern .jqc-search .form-control:focus { + box-shadow: 0 0 0 .2rem rgba(255, 255, 255, .35); +} +.jqc-modern .jqc-topbar-actions { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; +} +.jqc-modern .jqc-icon-btn { + color: #fff; + font-size: 1.2rem; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; height: 40px; + border-radius: 50%; + transition: background-color .15s; +} +.jqc-modern .jqc-icon-btn:hover { background: rgba(255, 255, 255, .14); color: #fff; } +.jqc-modern .jqc-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; height: 44px; + border-radius: 50%; + background: var(--jqc-brand-700); + border: 2px solid rgba(255, 255, 255, .85); + color: #fff; + font-weight: 700; + font-size: .9rem; + letter-spacing: .02em; + text-decoration: none; +} +.jqc-modern .jqc-avatar:hover { background: #0b3b53; color: #fff; } +.jqc-modern .jqc-hamburger { + background: transparent; + border: none; + color: #fff; + font-size: 1.5rem; + line-height: 1; + padding: 4px 6px; +} + +/* ── 3. Sidebar ──────────────────────────────────────────────────────────── */ +.jqc-modern .jqc-sidebar { + position: fixed; + top: var(--jqc-topbar-h); + bottom: 0; + left: 0; + width: var(--jqc-sidebar-w); + background: #fff; + border-right: 1px solid var(--jqc-border); + overflow-y: auto; + z-index: 1030; + display: flex; + flex-direction: column; + padding-top: 10px; +} +.jqc-modern .jqc-nav { flex: 1 1 auto; } +.jqc-modern .jqc-nav-link { + position: relative; + display: flex; + align-items: center; + gap: 14px; + padding: 13px 18px 13px 22px; + color: #43525C; + text-decoration: none; + font-size: .95rem; + font-weight: 500; + transition: background-color .15s, color .15s; +} +.jqc-modern .jqc-nav-link i { font-size: 1.15rem; width: 22px; text-align: center; } +.jqc-modern .jqc-nav-link span { flex: 1 1 auto; } +.jqc-modern .jqc-nav-link:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); } +.jqc-modern .jqc-nav-link.active { + background: var(--jqc-brand-025); + color: var(--jqc-brand); + font-weight: 700; +} +.jqc-modern .jqc-nav-link.active::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 5px; + background: var(--jqc-brand); +} +.jqc-modern .jqc-nav-caret { font-size: .7rem !important; width: auto !important; opacity: .6; } +.jqc-modern .jqc-nav-badge { + background: #D9534F; + color: #fff; + border-radius: 999px; + font-size: .68rem; + font-weight: 700; + padding: 1px 7px; + line-height: 1.5; +} +.jqc-modern .jqc-nav-sublink { + display: block; + padding: 9px 18px 9px 58px; + font-size: .88rem; + color: #5A6A75; + text-decoration: none; +} +.jqc-modern .jqc-nav-sublink:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); } +.jqc-modern .jqc-nav-sublink.active { color: var(--jqc-brand); font-weight: 700; } +.jqc-modern .jqc-sidebar-foot { + flex: 0 0 auto; + padding: 14px 16px 20px; + border-top: 1px solid var(--jqc-border); +} +.jqc-modern .jqc-switch-btn { + width: 100%; + background: #fff; + border: 1.5px solid var(--jqc-brand); + color: var(--jqc-brand); + border-radius: 999px; + font-size: .82rem; + font-weight: 600; + padding: 8px 10px; + transition: background-color .15s, color .15s; +} +.jqc-modern .jqc-switch-btn:hover { background: var(--jqc-brand); color: #fff; } +.jqc-modern .jqc-sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 34, 46, .45); + z-index: 1029; + display: none; +} +.jqc-modern .jqc-sidebar-backdrop.show { display: block; } + +/* ── 4. Main region ──────────────────────────────────────────────────────── */ +.jqc-modern .jqc-main { + margin-left: var(--jqc-sidebar-w); + padding: calc(var(--jqc-topbar-h) + 22px) 10px 40px; + min-height: 100vh; +} +.jqc-modern .jqc-main > .container-fluid { padding-inline: 14px; } + +@media (max-width: 991.98px) { + .jqc-modern .jqc-sidebar { + transform: translateX(-100%); + transition: transform .2s ease; + box-shadow: 0 0 24px rgba(15, 34, 46, .18); + } + .jqc-modern .jqc-sidebar.open { transform: translateX(0); } + .jqc-modern .jqc-main { margin-left: 0; } + .jqc-modern .jqc-search { width: auto; flex: 1 1 auto; } + .jqc-modern .jqc-brand-sub { display: none; } +} + +/* ── 5. Page heading block (used by the rebuilt modern pages) ────────────── */ +.jqc-modern .jqc-page-head { margin-bottom: 20px; } +.jqc-modern .jqc-page-head h1, +.jqc-modern .jqc-page-title { + font-size: 2rem; + font-weight: 800; + letter-spacing: -.02em; + color: var(--jqc-ink); + margin: 0; + text-align: center; +} +.jqc-modern .jqc-page-sub { + color: var(--jqc-muted); + font-size: .95rem; + margin-top: 4px; +} + +/* ── 6. Cards / surfaces (applies to every page, rebuilt or not) ─────────── */ +.jqc-modern .card { + border: 1px solid var(--jqc-border); + border-radius: 16px; + box-shadow: var(--jqc-shadow); +} +.jqc-modern .card-header { + background: #fff; + border-bottom: 1px solid var(--jqc-border); + color: var(--jqc-ink); + font-weight: 700; + padding: .9rem 1.15rem; +} +.jqc-modern .card-header.bg-light, +.jqc-modern .card-header.bg-white { background: #fff !important; } +.jqc-modern .card-body { padding: 1.15rem; } + +.jqc-modern .jqc-card { + background: #fff; + border: 1px solid var(--jqc-border); + border-radius: 16px; + box-shadow: var(--jqc-shadow); + padding: 20px 22px; + margin-bottom: 22px; +} +.jqc-modern .jqc-card-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 1.15rem; + font-weight: 800; + color: var(--jqc-ink); + margin-bottom: 16px; +} + +/* Soft square icon tile — the deck's signature element */ +.jqc-modern .jqc-tile-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; height: 42px; + border-radius: 11px; + background: var(--jqc-brand-050); + color: var(--jqc-brand); + font-size: 1.15rem; + flex: 0 0 auto; +} +.jqc-modern .jqc-tile-icon.lg { width: 74px; height: 74px; border-radius: 18px; font-size: 2rem; } + +/* KPI tiles */ +.jqc-modern .jqc-kpi { + background: #fff; + border: 1px solid var(--jqc-border); + border-radius: 16px; + box-shadow: var(--jqc-shadow); + padding: 18px 20px; + height: 100%; + display: block; + text-decoration: none; + color: inherit; + transition: box-shadow .15s, transform .15s; +} +a.jqc-kpi:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-1px); color: inherit; } +.jqc-modern .jqc-kpi-value { + font-size: 2.1rem; + font-weight: 800; + line-height: 1.05; + color: var(--jqc-ink); + margin-top: 10px; +} +.jqc-modern .jqc-kpi-label { font-size: .85rem; color: var(--jqc-muted); margin-top: 2px; } + +/* Label / value rows inside summary cards */ +.jqc-modern .jqc-stat-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 2px; + border-bottom: 1px solid var(--jqc-border); + text-decoration: none; + color: var(--jqc-ink); +} +.jqc-modern .jqc-stat-row:last-child { border-bottom: none; } +.jqc-modern .jqc-stat-row:hover { color: var(--jqc-brand); } +.jqc-modern .jqc-stat-label { font-size: .95rem; display: flex; align-items: center; gap: 9px; } +.jqc-modern .jqc-stat-value { font-size: 1.05rem; font-weight: 800; white-space: nowrap; } +.jqc-modern .jqc-dot { + width: 9px; height: 9px; border-radius: 50%; + display: inline-block; flex: 0 0 auto; +} + +/* Hub cards (Facility / Support pages) */ +.jqc-modern .jqc-hub-card { + display: flex; + flex-direction: column; + height: 100%; + background: #fff; + border: 1px solid var(--jqc-border); + border-radius: 16px; + box-shadow: var(--jqc-shadow); + padding: 24px 26px; + text-decoration: none; + color: inherit; + transition: box-shadow .15s, transform .15s; +} +.jqc-modern .jqc-hub-card:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-2px); color: inherit; } +.jqc-modern .jqc-hub-title { font-size: 1.3rem; font-weight: 800; color: var(--jqc-ink); } +.jqc-modern .jqc-hub-text { color: var(--jqc-muted); font-size: .93rem; margin-top: 6px; } +.jqc-modern .jqc-hub-open { color: var(--jqc-brand); font-weight: 700; font-size: .9rem; margin-top: auto; padding-top: 18px; } +.jqc-modern .jqc-hub-card.dark { background: var(--jqc-brand); border-color: var(--jqc-brand); } +.jqc-modern .jqc-hub-card.dark .jqc-hub-title, +.jqc-modern .jqc-hub-card.dark .jqc-hub-text { color: #fff; } +.jqc-modern .jqc-hub-card.dark .jqc-tile-icon { background: #fff; } + +/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */ +.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; } +.jqc-modern .table > thead > tr > th, +.jqc-modern .table thead.table-light > tr > th, +.jqc-modern .table > thead th { + background: var(--jqc-brand) !important; + color: #fff !important; + border-color: var(--jqc-brand-700) !important; + font-weight: 600; + font-size: .88rem; + vertical-align: middle; + white-space: nowrap; +} +.jqc-modern .table > tbody > tr > td { vertical-align: middle; font-size: .92rem; } +.jqc-modern .table-hover > tbody > tr:hover > * { background-color: var(--jqc-brand-025); } +.jqc-modern .jqc-table-wrap { + border: 1px solid var(--jqc-border); + border-radius: 12px; + overflow: hidden; +} + +/* ── 8. Buttons, badges, forms ───────────────────────────────────────────── */ +.jqc-modern .btn { border-radius: .6rem; font-weight: 600; } +.jqc-modern .btn-primary { + --bs-btn-bg: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand); + --bs-btn-hover-bg: var(--jqc-brand-700); --bs-btn-hover-border-color: var(--jqc-brand-700); + --bs-btn-active-bg: var(--jqc-brand-700); --bs-btn-active-border-color: var(--jqc-brand-700); + --bs-btn-disabled-bg: var(--jqc-brand); --bs-btn-disabled-border-color: var(--jqc-brand); +} +.jqc-modern .btn-outline-primary { + --bs-btn-color: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand); + --bs-btn-hover-bg: var(--jqc-brand); --bs-btn-hover-border-color: var(--jqc-brand); + --bs-btn-active-bg: var(--jqc-brand); --bs-btn-active-border-color: var(--jqc-brand); +} +.jqc-modern .bg-primary { background-color: var(--jqc-brand) !important; } +.jqc-modern .text-primary { color: var(--jqc-brand) !important; } +.jqc-modern .badge { border-radius: 999px; font-weight: 700; padding: .35em .7em; } +.jqc-modern .form-control, +.jqc-modern .form-select { + border-radius: .6rem; + border-color: var(--jqc-border-2); +} +.jqc-modern .form-control:focus, +.jqc-modern .form-select:focus { + border-color: var(--jqc-brand); + box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .18); +} + +/* Filter bar — the rounded pill row from the deck */ +.jqc-modern .jqc-filter-bar { + background: #fff; + border: 1px solid var(--jqc-border); + border-radius: 16px; + box-shadow: var(--jqc-shadow); + padding: 14px 16px; + margin-bottom: 20px; +} +.jqc-modern .jqc-filter-bar .form-control, +.jqc-modern .jqc-filter-bar .form-select { border-radius: 999px; padding-inline: 16px; } + +/* ── 9. Alerts / misc ────────────────────────────────────────────────────── */ +.jqc-modern .alert { border-radius: 12px; border: 1px solid var(--jqc-border); } +.jqc-modern .dropdown-menu { border-radius: 12px; border-color: var(--jqc-border); box-shadow: var(--jqc-shadow-md); } +.jqc-modern .nav-tabs .nav-link.active { color: var(--jqc-brand); } +.jqc-modern .progress-bar.bg-success { background-color: #2E7D4F !important; } + +/* Print: drop the chrome entirely */ +@media print { + .jqc-modern .jqc-topbar, + .jqc-modern .jqc-sidebar, + .jqc-modern .jqc-sidebar-backdrop { display: none !important; } + .jqc-modern .jqc-main { margin-left: 0; padding-top: 0; } +} diff --git a/app/templates/base.html b/app/templates/base.html index 4c60b36..ea95858 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,515 +1,17 @@ - - - - - - - - - - {% block title %}Janitorial QC System{% endblock %} - - - - - - {% 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 %} - - {% block content %}{% endblock %} -
- - - {% block extra_js %}{% endblock %} - - {% if current_user.is_authenticated %} - - {% endif %} - - \ No newline at end of file + Jinja resolves {% block %} overrides through the whole inheritance chain, + so child templates need no change at all. + ══════════════════════════════════════════════════════════════════════════ #} +{% extends jqc_layout %} diff --git a/app/templates/layouts/classic.html b/app/templates/layouts/classic.html new file mode 100644 index 0000000..17cafec --- /dev/null +++ b/app/templates/layouts/classic.html @@ -0,0 +1,527 @@ + + + + + + + + + + {% block title %}Janitorial QC System{% endblock %} + + + + + + {% 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 %} + + {% 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..6f26c98 --- /dev/null +++ b/app/templates/layouts/modern.html @@ -0,0 +1,479 @@ + + + + + + + + + + {% block title %}Janitorial QC System{% endblock %} + + + + + + {# theme_modern.css loads LAST so it wins over theme.css tokens #} + + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + + +
+ + + + JQC + By L.T. Services, Inc + + + + +
+ + + + + +
+
+ + + +
+ {% endif %} + + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% 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..6b61e96 --- /dev/null +++ b/app/templates/modern/dashboard.html @@ -0,0 +1,408 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} + +{# + MODERN dashboard (design A/B test — slide 1 of JQC_design). + + Uses exactly the same context variables as templates/dashboard.html — the + dashboard.index route is untouched. Every tile links to the same filtered + list view the classic dashboard links to, so no navigation path is lost. +#} + +{% block content %} + +{# ── Header ───────────────────────────────────────────────────────────── #} +
+
+
Welcome, {{ current_user.display_name }}
+
{{ current_user.role.replace('_',' ')|title }}
+
+
{{ now_display }}
+
+ +{# ── KPI row ──────────────────────────────────────────────────────────── #} + + +{# ── Three summary cards ──────────────────────────────────────────────── #} + + +{# ── 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 %} + +{# ── Recent activity ──────────────────────────────────────────────────── #} +
+
+
+ Recent Activities +
+ View all +
+ {% if recent_inspections %} +
+ + + + + + + {% if current_user.role != 'inspector' %}{% endif %} + + + + + + {% for insp in recent_inspections %} + + + + + {% if current_user.role != '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 %} +
+ +{# ── Inspector activity today (admin / director / PM) ─────────────────── #} +{% if inspector_activity %} +
+
+
+ Inspector Activity Today +
+ View all +
+
+ + + + + + + + + + {% for row in inspector_activity %} + + + + + + {% endfor %} + +
InspectorSubmitted Today
{{ row.name }} + {% if row.count > 0 %} + {{ row.count }} + {% else %} + + {% endif %} + +
+ {% set max_count = inspector_activity | map(attribute='count') | max %} + {% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %} +
+
+
+
+
+{% 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 }} +
+
+ {% for f in customer_facilities %} +
+
+
{{ f.name }}
+
{{ f.address or '—' }}
+
+ {{ f.project.name if f.project else '—' }} +
+ +
+
+ {% endfor %} +
+
+{% endif %} + +{% endblock %} diff --git a/app/templates/modern/facilities/list.html b/app/templates/modern/facilities/list.html new file mode 100644 index 0000000..3aeab54 --- /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 current_user.role != '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/ui/about.html b/app/templates/ui/about.html new file mode 100644 index 0000000..6597618 --- /dev/null +++ b/app/templates/ui/about.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}About Us{% endblock %} + +{# About Us — new page (design A/B test). Linked from the modern sidebar. #} + +{% block content %} +
+
About Us
+
Janitorial Quality Control by L.T. Services, Inc.
+
+ +
+
+
+
+ L.T. Services, Inc. +
+

+ L.T. Services, Inc. provides commercial janitorial services to public and private + facilities. Quality is verified in the field, not assumed — every contract is backed + by scheduled inspections, documented findings and tracked resolution. +

+

+ JQC is our in-house quality control platform. Inspectors work from an offline-capable + iPad app; managers, contract staff and customers work from this web portal. Both share + one record of every inspection, issue and photo. +

+
+
+ +
+
+
+ What JQC Covers +
+
Facility inspections & scoring
+
Recurring inspection schedules
+
Issue tracking with SLA deadlines
+
QR reporting from any facility
+
Reports, scorecards and exports
+
+
+ +
+
+
+ Contact & Support +
+

+ Questions about an inspection, an issue on your site, or access to the portal — + start in the Support Center and we will route it to the right person. +

+ + 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..a95d1e0 --- /dev/null +++ b/app/templates/ui/support_center.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}Support{% endblock %} + +{# + Support Center — new page (design A/B test, slide 6 of JQC_design). + Linked from the modern sidebar. Every card either opens an existing route or + expands an inline how-to, so nothing here dead-ends. +#} + +{% block content %} +
+
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 %} + +
+
+
+ +
+
AI Support
+ Under construction +
+
+
+
+
+{% endblock %} diff --git a/app/templates/ui/theme_votes.html b/app/templates/ui/theme_votes.html new file mode 100644 index 0000000..baaf27d --- /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.replace('_',' ')|title }}{{ 'New design' if theme == 'modern' else 'Classic design' }}{{ count }}
No active users.
+
+
+
+{% endblock %} diff --git a/migrations/versions/0003_add_user_active.py b/migrations/versions/0003_add_user_active.py new file mode 100644 index 0000000..ea256f8 --- /dev/null +++ b/migrations/versions/0003_add_user_active.py @@ -0,0 +1,44 @@ +"""0003 — placeholder for the missing early revision `0003_add_user_active` + +WHY THIS FILE EXISTS +──────────────────── +`phase1_projects_roles.py` declares `down_revision = '0003_add_user_active'`, +but no migration script with that revision id exists in migrations/versions/. +The early 0001–0003 scripts were lost from the repository at some point; the +schema changes they made are long since applied in every environment (the +`users.active` column exists), so nothing is missing from the database — only +the node in Alembic's revision graph. + +Alembic tolerates that as a warning while it walks the graph, but as soon as it +has to build the full revision map (which any `flask db upgrade ` does), +it fails hard: + + KeyError: '0003_add_user_active' + +This file restores the node so the chain resolves. It is intentionally a NO-OP: +it makes no schema change in either direction, and it declares itself the base +of the chain (`down_revision = None`) because the revisions below it no longer +exist as files. + +It never runs in practice — every environment's alembic_version is already far +past it — but if it ever did, upgrade() and downgrade() do nothing, so it is +safe either way. + +DO NOT DELETE. Removing it re-breaks `flask db upgrade`. +""" + +revision = '0003_add_user_active' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # No-op: the schema change this revision originally made (users.active) + # is already present in every environment. + pass + + +def downgrade(): + # No-op: nothing to reverse. + pass diff --git a/migrations/versions/phase48_user_ui_theme.py b/migrations/versions/phase48_user_ui_theme.py new file mode 100644 index 0000000..7600ea5 --- /dev/null +++ b/migrations/versions/phase48_user_ui_theme.py @@ -0,0 +1,51 @@ +"""phase48 — per-user web portal design preference + +Adds to `users`: + + ui_theme VARCHAR(16) NOT NULL DEFAULT 'classic' + +Drives the design A/B test: 'classic' renders the original top-navbar shell +(layouts/classic.html), 'modern' renders the new 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. + +The value doubles as the user's vote — /ui/theme-votes tallies it. + +Uses an INFORMATION_SCHEMA existence check — safe to re-run. +""" + +revision = 'phase48_user_ui_theme' +down_revision = 'phase47_sched_acknowledged' +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 backfill for any row that predates the DEFAULT. + 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"))