Aug 5 - Add new design (switchable)

This commit is contained in:
2026-08-05 14:07:38 -04:00
parent 07379bd915
commit f92d8783bf
15 changed files with 2899 additions and 568 deletions
+170 -57
View File
@@ -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/<same path>.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 <target>` 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.
+82
View File
@@ -27,8 +27,40 @@ limiter = Limiter(
)
# ── Design A/B test: per-request template overrides (phase48) ────────────────
# A user on the 'modern' design gets templates/modern/<name>.html in place of
# templates/<name>.html whenever that override exists; otherwise the normal
# template is used and only the layout shell + CSS differ.
#
# The rewrite happens in get_template() (not in the loader) so Jinja's template
# cache is keyed on the REWRITTEN name — a cached modern template can never be
# served to a classic user, or vice versa.
from flask.templating import Environment as _FlaskJinjaEnvironment
class ThemedEnvironment(_FlaskJinjaEnvironment):
"""Jinja environment that redirects template names to modern/<name>."""
# Populated once in create_app() by scanning templates/modern/.
jqc_modern_templates: set = set()
def get_template(self, name, parent=None, globals=None):
if (isinstance(name, str)
and self.jqc_modern_templates
and not name.startswith('modern/')):
candidate = 'modern/' + name
if candidate in self.jqc_modern_templates:
from flask import g, has_request_context
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
name = candidate
return super().get_template(name, parent, globals)
def create_app(config_name='default'):
app = Flask(__name__)
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
# property), so the themed subclass is the one actually instantiated.
app.jinja_environment = ThemedEnvironment
app.config.from_object(config[config_name])
# ── Reverse-proxy awareness (Nginx) ──────────────────────────────────────
@@ -111,6 +143,54 @@ def create_app(config_name='default'):
from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url
# ── Design A/B test wiring (phase48) ──────────────────────────────────
# Index the modern/ override templates once at boot, so get_template()
# never has to touch the filesystem per request.
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
if not os.path.isabs(_modern_root):
_modern_root = os.path.join(app.root_path, _modern_root)
_modern_set = set()
if os.path.isdir(_modern_root):
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
for _fn in _filenames:
if _fn.endswith('.html'):
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
ThemedEnvironment.jqc_modern_templates = _modern_set
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
from flask import g
@app.before_request
def resolve_ui_theme():
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
# The mobile API renders no templates and authenticates by JWT — skip it
# so this never touches the Flask-Login session loader on API traffic.
if request.path.startswith('/api/'):
g.jqc_theme = 'classic'
return
from flask_login import current_user as _cu
theme = 'classic'
try:
if _cu.is_authenticated:
theme = _cu.ui_theme or 'classic'
except Exception: # DB column missing (migration not yet run)
theme = 'classic'
g.jqc_theme = theme if theme in ('classic', 'modern') else 'classic'
@app.context_processor
def inject_ui_theme():
"""Give base.html the shell to extend."""
from app.utils.time_utils import now_eastern
theme = getattr(g, 'jqc_theme', 'classic')
return {
'jqc_theme': theme,
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
else 'layouts/classic.html',
# Long-form date shown in the modern dashboard header.
'now_display': now_eastern().strftime('%A, %B %-d, %Y'),
}
# ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually.
@@ -183,6 +263,7 @@ def create_app(config_name='default'):
from app.routes import devices # Admin device registry
from app.routes import public # Public facility QR pages (no login)
from app.routes import scheduled_inspections # Planned/recurring inspections
from app.routes import ui # phase48 — design A/B test + new pages
app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp)
@@ -201,6 +282,7 @@ def create_app(config_name='default'):
app.register_blueprint(devices.bp)
app.register_blueprint(public.bp)
app.register_blueprint(scheduled_inspections.bp)
app.register_blueprint(ui.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
+9
View File
@@ -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.
+115
View File
@@ -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)
+472
View File
@@ -0,0 +1,472 @@
/* ════════════════════════════════════════════════════════════════════════
Janitorial QC — MODERN design skin (design A/B test — "modern")
────────────────────────────────────────────────────────────────────────
Loaded ONLY by templates/layouts/modern.html, and always AFTER
theme.css, so every token below overrides the classic one.
The classic design is completely untouched by this file.
Palette sampled from the JQC_design deck:
brand #155F82 deep teal-blue top bar / table headers
brand-700 #0F4A66 hover / pressed
brand-050 #DCEBF5 soft icon tiles, active rail rows
page #EAEEF1 page background
surface #FFFFFF cards
ink #1D2A32 headings
muted #6B7A85 secondary text
════════════════════════════════════════════════════════════════════════ */
/* ── 1. Tokens ───────────────────────────────────────────────────────────── */
body.jqc-modern {
--jqc-brand: #155F82;
--jqc-brand-700: #0F4A66;
--jqc-brand-600: #1B6E93;
--jqc-brand-050: #DCEBF5;
--jqc-brand-025: #E9F0F8;
--jqc-page: #EAEEF1;
--jqc-ink: #1D2A32;
--jqc-heading: #1D2A32;
--jqc-muted: #6B7A85;
--jqc-faint: #93A1AB;
--jqc-border: #E3E8EC;
--jqc-border-2: #CFD9E0;
--jqc-surface: #F5F8FA;
--jqc-surface-2: #EEF3F6;
--jqc-accent: #155F82;
--jqc-accent-700: #0F4A66;
--jqc-accent-50: #DCEBF5;
--jqc-shadow: 0 1px 2px rgba(21, 46, 62, .05), 0 6px 18px rgba(21, 46, 62, .06);
--jqc-shadow-md: 0 10px 30px rgba(21, 46, 62, .12);
--bs-primary: #155F82;
--bs-primary-rgb: 21, 95, 130;
--bs-link-color: #155F82;
--bs-link-color-rgb: 21, 95, 130;
--bs-link-hover-color: #0F4A66;
--bs-link-hover-color-rgb: 15, 74, 102;
--bs-body-bg: #EAEEF1;
--bs-body-color: #1D2A32;
--bs-border-color: #E3E8EC;
--bs-border-radius: .6rem;
--bs-border-radius-sm: .45rem;
--bs-border-radius-lg: 1rem;
--bs-border-radius-xl: 1.15rem;
--jqc-topbar-h: 72px;
--jqc-sidebar-w: 232px;
background-color: var(--jqc-page);
color: var(--jqc-ink);
font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* ── 2. Top bar ──────────────────────────────────────────────────────────── */
.jqc-modern .jqc-topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--jqc-topbar-h);
z-index: 1035;
background: var(--jqc-brand);
display: flex;
align-items: center;
gap: 14px;
padding: 0 20px;
padding-top: env(safe-area-inset-top);
box-shadow: 0 1px 0 rgba(0, 0, 0, .10);
}
.jqc-modern .jqc-brand {
text-decoration: none;
color: #fff;
line-height: 1;
flex: 0 0 auto;
}
.jqc-modern .jqc-brand-mark {
display: block;
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -.02em;
}
.jqc-modern .jqc-brand-sub {
display: block;
font-size: .68rem;
opacity: .82;
margin-top: 3px;
}
.jqc-modern .jqc-search {
position: relative;
margin-left: auto;
width: min(420px, 42vw);
}
.jqc-modern .jqc-search i {
position: absolute;
left: 16px; top: 50%;
transform: translateY(-50%);
color: var(--jqc-muted);
pointer-events: none;
}
.jqc-modern .jqc-search .form-control {
border: none;
border-radius: 999px;
height: 42px;
padding-left: 44px;
background: #fff;
font-size: .92rem;
}
.jqc-modern .jqc-search .form-control:focus {
box-shadow: 0 0 0 .2rem rgba(255, 255, 255, .35);
}
.jqc-modern .jqc-topbar-actions {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.jqc-modern .jqc-icon-btn {
color: #fff;
font-size: 1.2rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px; height: 40px;
border-radius: 50%;
transition: background-color .15s;
}
.jqc-modern .jqc-icon-btn:hover { background: rgba(255, 255, 255, .14); color: #fff; }
.jqc-modern .jqc-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--jqc-brand-700);
border: 2px solid rgba(255, 255, 255, .85);
color: #fff;
font-weight: 700;
font-size: .9rem;
letter-spacing: .02em;
text-decoration: none;
}
.jqc-modern .jqc-avatar:hover { background: #0b3b53; color: #fff; }
.jqc-modern .jqc-hamburger {
background: transparent;
border: none;
color: #fff;
font-size: 1.5rem;
line-height: 1;
padding: 4px 6px;
}
/* ── 3. Sidebar ──────────────────────────────────────────────────────────── */
.jqc-modern .jqc-sidebar {
position: fixed;
top: var(--jqc-topbar-h);
bottom: 0;
left: 0;
width: var(--jqc-sidebar-w);
background: #fff;
border-right: 1px solid var(--jqc-border);
overflow-y: auto;
z-index: 1030;
display: flex;
flex-direction: column;
padding-top: 10px;
}
.jqc-modern .jqc-nav { flex: 1 1 auto; }
.jqc-modern .jqc-nav-link {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 13px 18px 13px 22px;
color: #43525C;
text-decoration: none;
font-size: .95rem;
font-weight: 500;
transition: background-color .15s, color .15s;
}
.jqc-modern .jqc-nav-link i { font-size: 1.15rem; width: 22px; text-align: center; }
.jqc-modern .jqc-nav-link span { flex: 1 1 auto; }
.jqc-modern .jqc-nav-link:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
.jqc-modern .jqc-nav-link.active {
background: var(--jqc-brand-025);
color: var(--jqc-brand);
font-weight: 700;
}
.jqc-modern .jqc-nav-link.active::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 5px;
background: var(--jqc-brand);
}
.jqc-modern .jqc-nav-caret { font-size: .7rem !important; width: auto !important; opacity: .6; }
.jqc-modern .jqc-nav-badge {
background: #D9534F;
color: #fff;
border-radius: 999px;
font-size: .68rem;
font-weight: 700;
padding: 1px 7px;
line-height: 1.5;
}
.jqc-modern .jqc-nav-sublink {
display: block;
padding: 9px 18px 9px 58px;
font-size: .88rem;
color: #5A6A75;
text-decoration: none;
}
.jqc-modern .jqc-nav-sublink:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
.jqc-modern .jqc-nav-sublink.active { color: var(--jqc-brand); font-weight: 700; }
.jqc-modern .jqc-sidebar-foot {
flex: 0 0 auto;
padding: 14px 16px 20px;
border-top: 1px solid var(--jqc-border);
}
.jqc-modern .jqc-switch-btn {
width: 100%;
background: #fff;
border: 1.5px solid var(--jqc-brand);
color: var(--jqc-brand);
border-radius: 999px;
font-size: .82rem;
font-weight: 600;
padding: 8px 10px;
transition: background-color .15s, color .15s;
}
.jqc-modern .jqc-switch-btn:hover { background: var(--jqc-brand); color: #fff; }
.jqc-modern .jqc-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 34, 46, .45);
z-index: 1029;
display: none;
}
.jqc-modern .jqc-sidebar-backdrop.show { display: block; }
/* ── 4. Main region ──────────────────────────────────────────────────────── */
.jqc-modern .jqc-main {
margin-left: var(--jqc-sidebar-w);
padding: calc(var(--jqc-topbar-h) + 22px) 10px 40px;
min-height: 100vh;
}
.jqc-modern .jqc-main > .container-fluid { padding-inline: 14px; }
@media (max-width: 991.98px) {
.jqc-modern .jqc-sidebar {
transform: translateX(-100%);
transition: transform .2s ease;
box-shadow: 0 0 24px rgba(15, 34, 46, .18);
}
.jqc-modern .jqc-sidebar.open { transform: translateX(0); }
.jqc-modern .jqc-main { margin-left: 0; }
.jqc-modern .jqc-search { width: auto; flex: 1 1 auto; }
.jqc-modern .jqc-brand-sub { display: none; }
}
/* ── 5. Page heading block (used by the rebuilt modern pages) ────────────── */
.jqc-modern .jqc-page-head { margin-bottom: 20px; }
.jqc-modern .jqc-page-head h1,
.jqc-modern .jqc-page-title {
font-size: 2rem;
font-weight: 800;
letter-spacing: -.02em;
color: var(--jqc-ink);
margin: 0;
text-align: center;
}
.jqc-modern .jqc-page-sub {
color: var(--jqc-muted);
font-size: .95rem;
margin-top: 4px;
}
/* ── 6. Cards / surfaces (applies to every page, rebuilt or not) ─────────── */
.jqc-modern .card {
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
}
.jqc-modern .card-header {
background: #fff;
border-bottom: 1px solid var(--jqc-border);
color: var(--jqc-ink);
font-weight: 700;
padding: .9rem 1.15rem;
}
.jqc-modern .card-header.bg-light,
.jqc-modern .card-header.bg-white { background: #fff !important; }
.jqc-modern .card-body { padding: 1.15rem; }
.jqc-modern .jqc-card {
background: #fff;
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 20px 22px;
margin-bottom: 22px;
}
.jqc-modern .jqc-card-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.15rem;
font-weight: 800;
color: var(--jqc-ink);
margin-bottom: 16px;
}
/* Soft square icon tile — the deck's signature element */
.jqc-modern .jqc-tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px; height: 42px;
border-radius: 11px;
background: var(--jqc-brand-050);
color: var(--jqc-brand);
font-size: 1.15rem;
flex: 0 0 auto;
}
.jqc-modern .jqc-tile-icon.lg { width: 74px; height: 74px; border-radius: 18px; font-size: 2rem; }
/* KPI tiles */
.jqc-modern .jqc-kpi {
background: #fff;
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 18px 20px;
height: 100%;
display: block;
text-decoration: none;
color: inherit;
transition: box-shadow .15s, transform .15s;
}
a.jqc-kpi:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-1px); color: inherit; }
.jqc-modern .jqc-kpi-value {
font-size: 2.1rem;
font-weight: 800;
line-height: 1.05;
color: var(--jqc-ink);
margin-top: 10px;
}
.jqc-modern .jqc-kpi-label { font-size: .85rem; color: var(--jqc-muted); margin-top: 2px; }
/* Label / value rows inside summary cards */
.jqc-modern .jqc-stat-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 2px;
border-bottom: 1px solid var(--jqc-border);
text-decoration: none;
color: var(--jqc-ink);
}
.jqc-modern .jqc-stat-row:last-child { border-bottom: none; }
.jqc-modern .jqc-stat-row:hover { color: var(--jqc-brand); }
.jqc-modern .jqc-stat-label { font-size: .95rem; display: flex; align-items: center; gap: 9px; }
.jqc-modern .jqc-stat-value { font-size: 1.05rem; font-weight: 800; white-space: nowrap; }
.jqc-modern .jqc-dot {
width: 9px; height: 9px; border-radius: 50%;
display: inline-block; flex: 0 0 auto;
}
/* Hub cards (Facility / Support pages) */
.jqc-modern .jqc-hub-card {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 24px 26px;
text-decoration: none;
color: inherit;
transition: box-shadow .15s, transform .15s;
}
.jqc-modern .jqc-hub-card:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-2px); color: inherit; }
.jqc-modern .jqc-hub-title { font-size: 1.3rem; font-weight: 800; color: var(--jqc-ink); }
.jqc-modern .jqc-hub-text { color: var(--jqc-muted); font-size: .93rem; margin-top: 6px; }
.jqc-modern .jqc-hub-open { color: var(--jqc-brand); font-weight: 700; font-size: .9rem; margin-top: auto; padding-top: 18px; }
.jqc-modern .jqc-hub-card.dark { background: var(--jqc-brand); border-color: var(--jqc-brand); }
.jqc-modern .jqc-hub-card.dark .jqc-hub-title,
.jqc-modern .jqc-hub-card.dark .jqc-hub-text { color: #fff; }
.jqc-modern .jqc-hub-card.dark .jqc-tile-icon { background: #fff; }
/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */
.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; }
.jqc-modern .table > thead > tr > th,
.jqc-modern .table thead.table-light > tr > th,
.jqc-modern .table > thead th {
background: var(--jqc-brand) !important;
color: #fff !important;
border-color: var(--jqc-brand-700) !important;
font-weight: 600;
font-size: .88rem;
vertical-align: middle;
white-space: nowrap;
}
.jqc-modern .table > tbody > tr > td { vertical-align: middle; font-size: .92rem; }
.jqc-modern .table-hover > tbody > tr:hover > * { background-color: var(--jqc-brand-025); }
.jqc-modern .jqc-table-wrap {
border: 1px solid var(--jqc-border);
border-radius: 12px;
overflow: hidden;
}
/* ── 8. Buttons, badges, forms ───────────────────────────────────────────── */
.jqc-modern .btn { border-radius: .6rem; font-weight: 600; }
.jqc-modern .btn-primary {
--bs-btn-bg: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
--bs-btn-hover-bg: var(--jqc-brand-700); --bs-btn-hover-border-color: var(--jqc-brand-700);
--bs-btn-active-bg: var(--jqc-brand-700); --bs-btn-active-border-color: var(--jqc-brand-700);
--bs-btn-disabled-bg: var(--jqc-brand); --bs-btn-disabled-border-color: var(--jqc-brand);
}
.jqc-modern .btn-outline-primary {
--bs-btn-color: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
--bs-btn-hover-bg: var(--jqc-brand); --bs-btn-hover-border-color: var(--jqc-brand);
--bs-btn-active-bg: var(--jqc-brand); --bs-btn-active-border-color: var(--jqc-brand);
}
.jqc-modern .bg-primary { background-color: var(--jqc-brand) !important; }
.jqc-modern .text-primary { color: var(--jqc-brand) !important; }
.jqc-modern .badge { border-radius: 999px; font-weight: 700; padding: .35em .7em; }
.jqc-modern .form-control,
.jqc-modern .form-select {
border-radius: .6rem;
border-color: var(--jqc-border-2);
}
.jqc-modern .form-control:focus,
.jqc-modern .form-select:focus {
border-color: var(--jqc-brand);
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .18);
}
/* Filter bar — the rounded pill row from the deck */
.jqc-modern .jqc-filter-bar {
background: #fff;
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 14px 16px;
margin-bottom: 20px;
}
.jqc-modern .jqc-filter-bar .form-control,
.jqc-modern .jqc-filter-bar .form-select { border-radius: 999px; padding-inline: 16px; }
/* ── 9. Alerts / misc ────────────────────────────────────────────────────── */
.jqc-modern .alert { border-radius: 12px; border: 1px solid var(--jqc-border); }
.jqc-modern .dropdown-menu { border-radius: 12px; border-color: var(--jqc-border); box-shadow: var(--jqc-shadow-md); }
.jqc-modern .nav-tabs .nav-link.active { color: var(--jqc-brand); }
.jqc-modern .progress-bar.bg-success { background-color: #2E7D4F !important; }
/* Print: drop the chrome entirely */
@media print {
.jqc-modern .jqc-topbar,
.jqc-modern .jqc-sidebar,
.jqc-modern .jqc-sidebar-backdrop { display: none !important; }
.jqc-modern .jqc-main { margin-left: 0; padding-top: 0; }
}
+13 -511
View File
@@ -1,515 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
{# ══════════════════════════════════════════════════════════════════════════
base.html — layout dispatcher (design A/B test)
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
Every page template still does {% extends "base.html" %} exactly as before.
This file no longer holds any markup; it forwards to whichever shell the
current user has selected:
/* ── Shared list-page filter panel (matches dashboard blue theme) ── */
.filter-panel {
background: #eaf2fc; /* light blue tint */
border: 1px solid #bcd2ee;
border-left: 4px solid #4a90d9; /* dashboard blue accent */
border-radius: 12px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: #2f6db3;
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
/* Give every input/select a clear border so they stand out on the tint */
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #9db8db;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 .18rem rgba(74, 144, 217, .25);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
jqc_layout == 'layouts/classic.html' → the original top-navbar design
jqc_layout == 'layouts/modern.html' → the new sidebar design
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-lg-block">
<a class="nav-link position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
`jqc_layout` is injected by the inject_ui_theme() context processor in
app/__init__.py, driven by users.ui_theme (see app/routes/ui.py).
<!-- User menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
Jinja resolves {% block %} overrides through the whole inheritance chain,
so child templates need no change at all.
══════════════════════════════════════════════════════════════════════════ #}
{% extends jqc_layout %}
+527
View File
@@ -0,0 +1,527 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
/* ── Shared list-page filter panel (matches dashboard blue theme) ── */
.filter-panel {
background: #eaf2fc; /* light blue tint */
border: 1px solid #bcd2ee;
border-left: 4px solid #4a90d9; /* dashboard blue accent */
border-radius: 12px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: #2f6db3;
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
/* Give every input/select a clear border so they stand out on the tint */
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #9db8db;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 .18rem rgba(74, 144, 217, .25);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-lg-block">
<a class="nav-link position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
<!-- User menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
{# ── Design switcher (classic → modern) ── #}
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="modern">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-stars me-1"></i>Try the New Design
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+479
View File
@@ -0,0 +1,479 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{# theme_modern.css loads LAST so it wins over theme.css tokens #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme_modern.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles (shared with the classic layout) ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: var(--jqc-brand);
background-color: #f0f6fa;
}
.notif-item:hover { background-color: #e9f0f8; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Shared list-page filter panel (modern tint) ── */
.filter-panel {
background: #ffffff;
border: 1px solid var(--jqc-border);
border-left: 4px solid var(--jqc-brand);
border-radius: 14px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--jqc-brand);
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #cfd9e0;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: var(--jqc-brand);
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .20);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body class="jqc-modern">
{% if current_user.is_authenticated %}
<!-- ══════════════════════════ TOP BAR ══════════════════════════ -->
<header class="jqc-topbar">
<button class="jqc-hamburger d-lg-none" type="button" id="jqcSidebarToggle" aria-label="Menu">
<i class="bi bi-list"></i>
</button>
<a class="jqc-brand" href="{{ url_for('dashboard.index') }}">
<span class="jqc-brand-mark">JQC</span>
<span class="jqc-brand-sub">By L.T. Services, Inc</span>
</a>
<form class="jqc-search" method="GET" action="{{ url_for('inspections.index') }}" role="search">
<i class="bi bi-search"></i>
<input type="text" name="inspection_id" class="form-control"
placeholder="Search inspection number" aria-label="Search inspection number">
</form>
<div class="jqc-topbar-actions">
<!-- ── Notification Bell ── -->
<div class="dropdown">
<a class="jqc-icon-btn position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
<!-- ── User avatar menu ── -->
<div class="dropdown">
<a class="jqc-avatar" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
title="{{ current_user.display_name }}">
{{ (current_user.display_name.split() | map('first') | join)[:2] | upper }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li class="px-3 py-2 border-bottom">
<div class="fw-semibold" style="font-size:.9rem;">{{ current_user.display_name }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ current_user.role.replace('_',' ')|title }}</div>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
{# ── Design switcher (modern → classic) ── #}
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="classic">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-arrow-counterclockwise me-1"></i>Back to Classic Design
</button>
</form>
</li>
{% if current_user.role == 'admin' %}
<li>
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
</a>
</li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</div>
</div>
</header>
<!-- ══════════════════════════ SIDEBAR ══════════════════════════ -->
<aside class="jqc-sidebar" id="jqcSidebar">
<nav class="jqc-nav">
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}"
href="{{ url_for('dashboard.index') }}">
<i class="bi bi-grid"></i><span>Dashboard</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('inspections.') or request.endpoint.startswith('scheduled_inspections.')) }}"
href="{{ url_for('inspections.index') }}">
<i class="bi bi-clipboard-check"></i><span>Inspections</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}"
href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart-fill"></i><span>Reports &amp; Analytics</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}"
href="{{ url_for('issues.index') }}">
<i class="bi bi-exclamation-triangle"></i><span>Issues</span>
</a>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
<i class="bi bi-patch-check"></i><span>Verify</span>
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="jqc-nav-badge">{{ pending_verification_count }}</span>
{% endif %}
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}"
href="{{ url_for('projects.index') }}">
<i class="bi bi-file-earmark-text"></i><span>Contract</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}"
href="{{ url_for('facilities.list_facilities') }}">
<i class="bi bi-buildings"></i><span>Facility</span>
</a>
{% if current_user.role in ['admin', 'director'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}"
href="{{ url_for('templates.index') }}">
<i class="bi bi-list-check"></i><span>Templates</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}"
href="{{ url_for('customers.index') }}">
<i class="bi bi-people"></i><span>Customer</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('support.') or request.endpoint == 'ui.support_center') }}"
href="{{ url_for('ui.support_center') }}">
<i class="bi bi-life-preserver"></i><span>Supports</span>
{% if open_support_tickets_count > 0 %}
<span class="jqc-nav-badge">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
href="#jqcAdminMenu" role="button" aria-expanded="{{ 'true' if admin_active else 'false' }}">
<i class="bi bi-shield-lock"></i><span>Admin</span>
<i class="bi bi-chevron-down jqc-nav-caret"></i>
</a>
<div class="collapse {{ 'show' if admin_active }}" id="jqcAdminMenu">
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</div>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'ui.about' }}" href="{{ url_for('ui.about') }}">
<i class="bi bi-info-circle"></i><span>About Us</span>
</a>
</nav>
<div class="jqc-sidebar-foot">
<form method="POST" action="{{ url_for('ui.switch_theme') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="classic">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="jqc-switch-btn" title="Switch back to the classic design">
<i class="bi bi-arrow-left-right"></i> Classic Design
</button>
</form>
</div>
</aside>
<div class="jqc-sidebar-backdrop d-lg-none" id="jqcSidebarBackdrop"></div>
{% endif %}
<!-- ══════════════════════════ MAIN ══════════════════════════ -->
<main class="{{ 'jqc-main' if current_user.is_authenticated else '' }}">
<div class="container-fluid {{ '' if current_user.is_authenticated else 'mt-4' }}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
// ── Sidebar off-canvas toggle (mobile / tablet portrait) ──────────────
(function () {
var btn = document.getElementById('jqcSidebarToggle');
var sidebar = document.getElementById('jqcSidebar');
var backdrop = document.getElementById('jqcSidebarBackdrop');
if (!btn || !sidebar) return;
function close() {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('show');
}
btn.addEventListener('click', function () {
sidebar.classList.toggle('open');
if (backdrop) backdrop.classList.toggle('show');
});
if (backdrop) backdrop.addEventListener('click', close);
})();
</script>
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
const badgeDesktop = document.getElementById('notif-count-badge');
const listDesktop = document.getElementById('notif-list');
function updateBadge(count) {
[badgeDesktop].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
if (deskOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
['notifDropdown'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+408
View File
@@ -0,0 +1,408 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{#
MODERN dashboard (design A/B test — slide 1 of JQC_design).
Uses exactly the same context variables as templates/dashboard.html — the
dashboard.index route is untouched. Every tile links to the same filtered
list view the classic dashboard links to, so no navigation path is lost.
#}
{% block content %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title" style="text-align:left;">Welcome, {{ current_user.display_name }}</div>
<div class="jqc-page-sub">{{ current_user.role.replace('_',' ')|title }}</div>
</div>
<div class="text-muted">{{ now_display }}</div>
</div>
{# ── KPI row ──────────────────────────────────────────────────────────── #}
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
<div class="col">
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>
<div class="jqc-kpi-value">{{ completed_today }}</div>
<div class="jqc-kpi-label">Submitted Today</div>
</a>
</div>
<div class="col">
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
<span class="jqc-tile-icon"><i class="bi bi-calendar-week"></i></span>
<div class="jqc-kpi-value">{{ submitted_this_week }}</div>
<div class="jqc-kpi-label">Submitted This Week</div>
</a>
</div>
<div class="col">
<a class="jqc-kpi" href="{{ url_for('issues.index', status='open') }}">
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>
<div class="jqc-kpi-value">{{ open_issues }}</div>
<div class="jqc-kpi-label">Open Issues</div>
</a>
</div>
<div class="col">
{% if current_user.role != 'customer' %}
<a class="jqc-kpi" href="{{ url_for('scheduled_inspections.index') }}">
<span class="jqc-tile-icon"><i class="bi bi-calendar2-check"></i></span>
<div class="jqc-kpi-value">{{ sched_total }}</div>
<div class="jqc-kpi-label">On Schedules</div>
</a>
{% else %}
<a class="jqc-kpi" href="{{ url_for('facilities.list_facilities') }}">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>
<div class="jqc-kpi-value">{{ customer_facilities|length if customer_facilities else 0 }}</div>
<div class="jqc-kpi-label">Your Facilities</div>
</a>
{% endif %}
</div>
</div>
{# ── Three summary cards ──────────────────────────────────────────────── #}
<div class="row g-3 mb-2">
<!-- Inspection -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>Inspection
</div>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label">Submitted Today</span>
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ completed_today }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
<span class="jqc-stat-label">Submitted This Week</span>
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ submitted_this_week }}</span>
</a>
{% if current_user.role != 'customer' %}
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='in_progress') }}">
<span class="jqc-stat-label">
In Process
{% if stale_in_progress %}<span class="badge bg-warning text-dark">{{ stale_in_progress }} stale</span>{% endif %}
</span>
<span class="jqc-stat-value" style="color:#1B9AD1;">{{ in_progress_total }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='follow_up') }}">
<span class="jqc-stat-label">Pending to follow up</span>
<span class="jqc-stat-value" style="color:#E0A800;">{{ pending_followups }}</span>
</a>
{% endif %}
</div>
</div>
<!-- Open Issues -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>Open Issues
</div>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='internal') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E8722C;"></span>Janitorial</span>
<span class="jqc-stat-value">{{ handler_breakdown.internal }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='facility') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#155F82;"></span>Facility Staff</span>
<span class="jqc-stat-value">{{ handler_breakdown.facility }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='vendor') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Vendors</span>
<span class="jqc-stat-value">{{ handler_breakdown.vendor }}</span>
</a>
{% if current_user.role != 'customer' %}
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='pending_verification') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>Pending Verification</span>
<span class="jqc-stat-value">{{ pending_verification }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', unassigned='1') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>Unassigned Issue</span>
<span class="jqc-stat-value">{{ unassigned_open }}</span>
</a>
{% endif %}
</div>
</div>
<!-- SLA Issues -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>SLA Issues
</div>
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='breached') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>SLA Alert</span>
<span class="jqc-stat-value" style="color:#D9534F;">{{ sla_breached }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='at_risk') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>SLA At Risk</span>
<span class="jqc-stat-value" style="color:#E0A800;">{{ sla_at_risk }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Issues Opened Today</span>
<span class="jqc-stat-value">{{ issues_opened_today }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#2E7D4F;"></span>Resolved Today</span>
<span class="jqc-stat-value" style="color:#2E7D4F;">{{ resolved_today }}</span>
</a>
</div>
</div>
</div>
{# ── Scheduled inspections ────────────────────────────────────────────── #}
{% if current_user.role != 'customer' %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-calendar2-week"></i></span>Scheduled Inspection In Progress
</div>
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2">
<i class="bi bi-alarm-fill me-1"></i>
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
</div>
{% endif %}
{% if sched_upcoming %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Facility</th><th>Inspection Template</th><th>Inspector</th>
<th>How Often</th><th>Next Due Date</th><th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small text-muted">{{ s.recurrence_label }}</td>
<td class="small">{{ s.next_due_date.strftime('%b %d, %Y') }}</td>
<td class="text-end text-nowrap">
{% if s.inspector_id and s.inspector_id == current_user.id %}
{% if s.is_acknowledged %}
<span class="badge bg-success" title="You confirmed receipt"><i class="bi bi-check-circle"></i> Confirmed</span>
{% else %}
<form method="POST" class="d-inline" action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success py-0"
title="Confirm you received this request">
<i class="bi bi-check-lg"></i> Confirm</button>
</form>
{% endif %}
{% set open_id = sched_open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue</a>
{% else %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small"><i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.</div>
{% endif %}
</div>
{% endif %}
{# ── Recent activity ──────────────────────────────────────────────────── #}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>Recent Activities
</div>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if recent_inspections %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Facility Name</th>
<th>Area</th>
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
<tr style="cursor:pointer;" onclick="window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
<td><small>{{ insp.inspection_date.strftime('%b %d, %Y') }}</small></td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td>
{% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
{{ insp.overall_score }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
</div>
{% endif %}
</div>
{# ── Inspector activity today (admin / director / PM) ─────────────────── #}
{% if inspector_activity %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-people"></i></span>Inspector Activity Today
</div>
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}"
class="btn btn-sm btn-outline-primary">View all</a>
</div>
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Inspector</th>
<th class="text-center" style="width:150px;">Submitted Today</th>
<th style="width:220px;"></th>
</tr>
</thead>
<tbody>
{% for row in inspector_activity %}
<tr>
<td>{{ row.name }}</td>
<td class="text-center">
{% if row.count > 0 %}
<span class="badge bg-success">{{ row.count }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<div class="progress" style="height:6px;">
{% set max_count = inspector_activity | map(attribute='count') | max %}
{% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %}
<div class="progress-bar bg-success" style="width:{{ pct }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── My open issues (inspector widget) ────────────────────────────────── #}
{% if my_issues %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-person-check"></i></span>My Open Issues
</div>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:60px;">ID</th>
<th style="width:90px;">Severity</th>
<th>Facility / Description</th>
<th style="width:100px;">Status</th>
<th style="width:120px;">SLA</th>
</tr>
</thead>
<tbody>
{% for issue in my_issues %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
</td>
<td>
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
{% else %}
<span class="badge bg-secondary">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Customer portal: scoped facilities panel ─────────────────────────── #}
{% if current_user.role == 'customer' and customer_facilities %}
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-building"></i></span>Your Facilities
<span class="badge bg-secondary rounded-pill ms-2">{{ customer_facilities|length }}</span>
</div>
<div class="row g-3">
{% for f in customer_facilities %}
<div class="col-12 col-sm-6 col-lg-4">
<div class="border rounded-3 p-3 h-100 d-flex flex-column">
<div class="fw-semibold mb-1">{{ f.name }}</div>
<div class="text-muted" style="font-size:.82rem;">{{ f.address or '—' }}</div>
<div class="my-2">
<span class="badge bg-light text-dark border">{{ f.project.name if f.project else '—' }}</span>
</div>
<div class="mt-auto pt-1">
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
class="btn btn-sm btn-outline-secondary ms-1"><i class="bi bi-graph-up"></i> Report</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
+271
View File
@@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}Facilities{% endblock %}
{#
MODERN facilities page (design A/B test — slide 5 of JQC_design).
The four hub cards are new; everything below them is the original grouped
facility list, delete modal and JS, unchanged — no functionality removed.
#}
{% block content %}
<div class="jqc-page-head">
<div class="jqc-page-title">Facilities</div>
<div class="jqc-page-sub text-center">Manage facility records, statistics and QR access</div>
</div>
<div class="row g-4 mb-4">
{% if current_user.role != 'inspector' %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('facilities.facility_qr_print_all') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-qr-code"></i></span>
<div>
<div class="jqc-hub-title">Print QR Code</div>
<div class="jqc-hub-text">Generate and print scannable QR codes for every facility entrance and asset.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="#facility-list">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-buildings"></i></span>
<div>
<div class="jqc-hub-title">Facilities Information</div>
<div class="jqc-hub-text">View addresses, contacts, contracts and service details in one place.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('reports.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-pie-chart"></i></span>
<div>
<div class="jqc-hub-title">Facilities Statistics</div>
<div class="jqc-hub-text">Track inspection scores and issue trends by location over time.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('templates.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-gear"></i></span>
<div>
<div class="jqc-hub-title">Customize</div>
<div class="jqc-hub-text">Configure inspection templates, checklist items and scoring for your facilities.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{# ── Original facility list (unchanged) ───────────────────────────────── #}
<div id="facility-list" class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0" style="font-size:1.4rem;font-weight:800;">
<i class="bi bi-building"></i> All Facilities
</h2>
<div>
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.facility_qr_print_all') }}"
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
<i class="bi bi-qr-code"></i> Print All QR Codes
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility
</a>
{% endif %}
</div>
</div>
{% if grouped %}
{% for group_key, group in grouped.items() %}
{# ── Contract group header ────────────────────────────────────────────── #}
{% set collapse_id = 'contract-' ~ loop.index %}
<div class="mb-4">
<div class="d-flex align-items-center mb-2">
<button class="btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2 fw-semibold fs-5"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ collapse_id }}"
aria-expanded="false"
aria-controls="{{ collapse_id }}">
<i class="bi bi-chevron-down contract-chevron" style="transition: transform .2s; transform: rotate(-90deg);"></i>
{% if group.project %}
<i class="bi bi-briefcase text-primary"></i>
{{ group.project.name }}
{% else %}
<i class="bi bi-dash-circle text-secondary"></i>
<span class="text-secondary">No Contract Assigned</span>
{% endif %}
</button>
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
class="btn btn-sm btn-outline-secondary ms-2"
title="View Contract">
<i class="bi bi-arrow-right-circle"></i>
</a>
{% endif %}
</div>
{# ── Collapsible card grid ─────────────────────────────────────────── #}
<div class="collapse" id="{{ collapse_id }}">
<div class="row">
{% for facility in group.facilities %}
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card shadow-sm h-100">
<div class="card-body py-2 px-3">
<div class="mb-1" style="font-size:.875rem;font-weight:600;line-height:1.3;">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="text-decoration-none">
{{ facility.name }}
</a>
{% if not facility.active %}
<span class="badge bg-secondary" style="font-size:.7rem;">Inactive</span>
{% endif %}
</div>
{% if facility.address %}
<p class="card-text text-muted mb-1" style="font-size:.78rem;">
<i class="bi bi-geo-alt"></i> {{ facility.address }}
</p>
{% endif %}
<div class="mt-1">
<small class="text-muted" style="font-size:.78rem;">
<i class="bi bi-diagram-3"></i> {{ facility.areas.count() }} areas
</small>
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 py-2 px-3">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
{% if current_user.role == 'admin' %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-auto"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-facility-id="{{ facility.id }}"
data-facility-name="{{ facility.name }}"
data-inspection-count="{{ facility.inspections.count() }}">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No facilities configured yet.
</div>
{% endif %}
{% if current_user.role == 'admin' %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete:</p>
<p class="fw-bold fs-5" id="modalFacilityName"></p>
<div id="modalWarningBlock" class="alert alert-danger d-none">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this facility.</strong> It has existing inspection records.
Please remove all associated inspections first.
</div>
<div id="modalConfirmBlock">
<p class="text-muted mb-0">This action is <strong>irreversible</strong>. All areas associated with this facility will also be deleted.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteFacilityForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Rotate chevron on collapse toggle ────────────────────────────────
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (btn) {
const target = document.querySelector(btn.getAttribute('data-bs-target'));
if (!target) return;
const chevron = btn.querySelector('.contract-chevron');
target.addEventListener('hide.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(-90deg)';
});
target.addEventListener('show.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(0deg)';
});
});
{% if current_user.role == 'admin' %}
// ── Delete modal wiring ──────────────────────────────────────────────
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const facilityId = button.getAttribute('data-facility-id');
const facilityName = button.getAttribute('data-facility-name');
const inspectionCount = parseInt(button.getAttribute('data-inspection-count'));
document.getElementById('modalFacilityName').textContent = facilityName;
document.getElementById('deleteFacilityForm').action = '/facilities/' + facilityId + '/delete';
const warningBlock = document.getElementById('modalWarningBlock');
const confirmBlock = document.getElementById('modalConfirmBlock');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (inspectionCount > 0) {
warningBlock.classList.remove('d-none');
confirmBlock.classList.add('d-none');
confirmBtn.disabled = true;
} else {
warningBlock.classList.add('d-none');
confirmBlock.classList.remove('d-none');
confirmBtn.disabled = false;
}
});
{% endif %}
});
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}About Us{% endblock %}
{# About Us — new page (design A/B test). Linked from the modern sidebar. #}
{% block content %}
<div class="jqc-page-head">
<div class="jqc-page-title">About Us</div>
<div class="jqc-page-sub text-center">Janitorial Quality Control by L.T. Services, Inc.</div>
</div>
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>L.T. Services, Inc.
</div>
<p class="mb-3">
L.T. Services, Inc. provides commercial janitorial services to public and private
facilities. Quality is verified in the field, not assumed — every contract is backed
by scheduled inspections, documented findings and tracked resolution.
</p>
<p class="mb-0 text-muted">
JQC is our in-house quality control platform. Inspectors work from an offline-capable
iPad app; managers, contract staff and customers work from this web portal. Both share
one record of every inspection, issue and photo.
</p>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-check2-square"></i></span>What JQC Covers
</div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-clipboard-check"></i>Facility inspections &amp; scoring</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-calendar2-week"></i>Recurring inspection schedules</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-exclamation-triangle"></i>Issue tracking with SLA deadlines</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-qr-code"></i>QR reporting from any facility</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-bar-chart"></i>Reports, scorecards and exports</span></div>
</div>
</div>
<div class="col-12">
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact &amp; Support
</div>
<p class="mb-3">
Questions about an inspection, an issue on your site, or access to the portal —
start in the Support Center and we will route it to the right person.
</p>
<a href="{{ url_for('ui.support_center') }}" class="btn btn-primary">
<i class="bi bi-life-preserver me-1"></i>Go to Support
</a>
</div>
</div>
</div>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}Support{% endblock %}
{#
Support Center — new page (design A/B test, slide 6 of JQC_design).
Linked from the modern sidebar. Every card either opens an existing route or
expands an inline how-to, so nothing here dead-ends.
#}
{% block content %}
<div class="jqc-page-head">
<div class="jqc-page-title">Support</div>
<div class="jqc-page-sub text-center">JQC Features — find answers and how-to guides</div>
</div>
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
<div class="row g-3 mb-4">
{% if current_user.role == 'customer' %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-dots"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Ask a Question</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Requests</div>
</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Support Requests</div>
</div>
{% if open_support_tickets_count > 0 %}
<div class="jqc-hub-text">{{ open_support_tickets_count }} open</div>
{% endif %}
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-square-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Chat Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_knowledge') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-journal-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Knowledge Base</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── How-to guides ─────────────────────────────────────────────────────── #}
{% set guides = [
('bi-clipboard-check', 'How to create a new inspection',
'Inspections → New Inspection. Pick the contract, facility, area and template, then Start. The checklist opens straight away and saves as you go — you can leave and resume from Inspections → In Progress.'),
('bi-search', 'How to follow up on an inspection',
'Open the inspection and use Request Follow-up. It moves to the Follow-up list, notifies the inspector, and stays there until a re-inspection is submitted against it.'),
('bi-calendar2-week', 'How to schedule an inspection',
'Inspections → Schedule. Choose facility, template, inspector and how often it repeats. Recurring schedules roll their due date forward automatically once the inspection is submitted.'),
('bi-exclamation-triangle', 'How to Flag For Attention',
'While executing an inspection, use Flag Issue on any failing item. Set severity and who handles it (janitorial crew, facility staff or an outside vendor) — the SLA clock starts from that moment.'),
('bi-qr-code', 'How QR code works',
"Every facility and area has a QR code. Scanning it opens that location's public page — anyone on site can report a problem without an account, and the request lands in Issues."),
('bi-file-earmark-text','Send a request without the app',
'Point the on-site contact at the facility QR code, or forward them the public facility link. Their submission arrives as an unassigned issue for triage.'),
('bi-search', 'How to search',
'Use the search box in the top bar for an inspection number. For anything broader, each list page has filters for contract, facility, inspector, status, date range and score.'),
('bi-chat-dots', 'How to comment',
'Open any inspection or issue and use the comment box at the bottom. Comments are visible to staff; sharing one with the customer is an explicit choice on the comment itself.'),
('bi-bar-chart', 'Create & print inspection reports',
'Reports & Analytics → filter by date, contract, facility or inspector → Apply. Export to CSV, or use the PDF export on an individual inspection or facility scorecard.'),
('bi-clock', 'What does SLA At Risk mean?',
'The issue is approaching its resolution deadline for its severity but has not passed it yet. Treat it as the last window to close the issue on time.'),
('bi-alarm', 'What does an SLA Alert mean?',
'The issue has passed its resolution deadline for its severity. It stays flagged until resolved and shows on the dashboard SLA card.'),
] %}
<div class="row g-3">
{% for icon, title, body in guides %}
<div class="col-12 col-md-6 col-xl-4">
<div class="jqc-hub-card" role="button" data-bs-toggle="collapse"
data-bs-target="#guide{{ loop.index }}" aria-expanded="false">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi {{ icon }}"></i></span>
<div class="jqc-hub-title" style="font-size:1.02rem;">{{ title }}</div>
</div>
<div class="collapse" id="guide{{ loop.index }}">
<div class="jqc-hub-text mt-3">{{ body }}</div>
</div>
</div>
</div>
{% endfor %}
<div class="col-12 col-md-6 col-xl-4">
<div class="jqc-hub-card dark">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
<div>
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
<span class="badge bg-light text-dark mt-2">Under construction</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Design Vote Tally{% endblock %}
{# Admin-only: which design are active users currently keeping? #}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
<div class="text-muted">Which web portal design each active user is currently using.</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Classic design</div>
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
<div class="text-muted small">
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">New design</div>
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
<div class="text-muted small">
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total active users</div>
<div class="fs-2 fw-bold">{{ total }}</div>
<div class="text-muted small">Every account defaults to classic</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
</thead>
<tbody>
{% for role, theme, count in by_role %}
<tr>
<td>{{ role.replace('_',' ')|title }}</td>
<td>{{ 'New design' if theme == 'modern' else 'Classic design' }}</td>
<td class="text-end fw-semibold">{{ count }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center text-muted py-4">No active users.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -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 00010003 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 <target>` 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
@@ -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"))