Aug 7 - Update: UI change - MT16
This commit is contained in:
@@ -28,8 +28,41 @@ limiter = Limiter(
|
||||
)
|
||||
|
||||
|
||||
# ── Web portal design: per-request template overrides (MT-16) ────────────────
|
||||
# A user on the 'modern' design gets templates/modern/<name>.html in place of
|
||||
# templates/<name>.html whenever that override exists; otherwise the normal
|
||||
# template is used and only the layout shell + CSS differ.
|
||||
#
|
||||
# The rewrite happens in get_template() (not in the loader) so Jinja's template
|
||||
# cache is keyed on the REWRITTEN name — a cached modern template can never be
|
||||
# served to a classic user, or vice versa. A loader-level swap would have that
|
||||
# bug, and in MT it would leak across tenants sharing a worker process.
|
||||
from flask.templating import Environment as _FlaskJinjaEnvironment
|
||||
|
||||
|
||||
class ThemedEnvironment(_FlaskJinjaEnvironment):
|
||||
"""Jinja environment that redirects template names to modern/<name>."""
|
||||
|
||||
# Populated once in create_app() by scanning templates/modern/.
|
||||
jqc_modern_templates: set = set()
|
||||
|
||||
def get_template(self, name, parent=None, globals=None):
|
||||
if (isinstance(name, str)
|
||||
and self.jqc_modern_templates
|
||||
and not name.startswith('modern/')):
|
||||
candidate = 'modern/' + name
|
||||
if candidate in self.jqc_modern_templates:
|
||||
from flask import g, has_request_context
|
||||
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
|
||||
name = candidate
|
||||
return super().get_template(name, parent, globals)
|
||||
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
|
||||
# property), so the themed subclass is the one actually instantiated.
|
||||
app.jinja_environment = ThemedEnvironment
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
|
||||
@@ -127,6 +160,65 @@ def create_app(config_name='default'):
|
||||
from app.utils import storage as _storage
|
||||
app.jinja_env.globals['media_url'] = _storage.media_url
|
||||
|
||||
# ── Web portal design wiring (MT-16) ──────────────────────────────────
|
||||
# Index the modern/ override templates once at boot, so get_template()
|
||||
# never has to touch the filesystem per request.
|
||||
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
|
||||
if not os.path.isabs(_modern_root):
|
||||
_modern_root = os.path.join(app.root_path, _modern_root)
|
||||
_modern_set = set()
|
||||
if os.path.isdir(_modern_root):
|
||||
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
|
||||
for _fn in _filenames:
|
||||
if _fn.endswith('.html'):
|
||||
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
|
||||
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
|
||||
ThemedEnvironment.jqc_modern_templates = _modern_set
|
||||
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
|
||||
|
||||
from flask import g, request as _request
|
||||
|
||||
@app.before_request
|
||||
def resolve_ui_theme():
|
||||
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
|
||||
# The mobile API renders no templates and authenticates by JWT — skip it
|
||||
# so this never touches the Flask-Login session loader on API traffic.
|
||||
if _request.path.startswith('/api/'):
|
||||
# The API renders no templates; 'classic' here only means "never
|
||||
# rewrite a template name" (see ThemedEnvironment.get_template).
|
||||
g.jqc_theme = 'classic'
|
||||
return
|
||||
from flask_login import current_user as _cu
|
||||
# MT-16 — the fallback is configurable per deployment. It defaults to
|
||||
# 'classic' so an existing tenant's users see no change until they opt
|
||||
# in; a stored users.ui_theme always wins over the default.
|
||||
default = app.config.get('DEFAULT_UI_THEME', 'classic')
|
||||
theme = default
|
||||
try:
|
||||
if _cu.is_authenticated:
|
||||
theme = _cu.ui_theme or default
|
||||
except Exception: # DB column missing (migration not yet run)
|
||||
theme = default
|
||||
g.jqc_theme = theme if theme in ('classic', 'modern') else default
|
||||
|
||||
@app.context_processor
|
||||
def inject_ui_theme():
|
||||
"""Give base.html the shell to extend."""
|
||||
from app.utils.time_utils import now_eastern
|
||||
theme = getattr(g, 'jqc_theme',
|
||||
app.config.get('DEFAULT_UI_THEME', 'classic'))
|
||||
_now = now_eastern()
|
||||
return {
|
||||
'jqc_theme': theme,
|
||||
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
|
||||
else 'layouts/classic.html',
|
||||
# Long-form date shown in the modern dashboard header. The day is
|
||||
# interpolated rather than formatted with '%-d' — that flag is a
|
||||
# glibc extension and raises ValueError on Windows, which would
|
||||
# 500 every page (this context processor runs on both themes).
|
||||
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
|
||||
}
|
||||
|
||||
# ── Inject unread notification count into every template context ──────
|
||||
# This powers the red badge on the navbar bell icon without requiring
|
||||
# individual routes to pass the count manually.
|
||||
@@ -221,6 +313,7 @@ def create_app(config_name='default'):
|
||||
from app.routes import tenant_settings # MT-7 — tenant self-service
|
||||
from app.routes import signup # MT-8+ — public self-service signup
|
||||
from app.routes import landing # Public apex marketing/landing page
|
||||
from app.routes import ui # MT-16 — design switch + new pages
|
||||
from app.billing import bp as billing_bp # MT-8 — Stripe billing
|
||||
|
||||
app.register_blueprint(auth.bp)
|
||||
@@ -244,6 +337,7 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(tenant_settings.bp)
|
||||
app.register_blueprint(signup.bp)
|
||||
app.register_blueprint(landing.bp)
|
||||
app.register_blueprint(ui.bp)
|
||||
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
||||
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
|
||||
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
|
||||
|
||||
@@ -55,6 +55,17 @@ class User(UserMixin, db.Model):
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
# ── Web portal design preference (MT-16) ──────────────────────────────
|
||||
# 'classic' = the original top-navbar design. 'modern' = the sidebar design.
|
||||
# Drives base.html's layout dispatch via the inject_ui_theme() context
|
||||
# processor. Persisted per user so the choice survives logout.
|
||||
#
|
||||
# Defaults to 'classic' so existing tenants see no change on deploy; the
|
||||
# effective fallback for accounts that never choose is config
|
||||
# DEFAULT_UI_THEME, which a tenant can be provisioned with as 'modern'.
|
||||
ui_theme = db.Column(db.String(16), nullable=False,
|
||||
server_default='classic', default='classic')
|
||||
|
||||
# ── Customer password-setup workflow ──────────────────────────────────
|
||||
# password_set: False for newly created customer accounts until they
|
||||
# complete the set-password flow via emailed link.
|
||||
|
||||
@@ -24,6 +24,10 @@ def index():
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
# MT-16 — Monday 00:00 of the current week, for the modern dashboard's
|
||||
# "submitted this week" tile. Derived from today_start so it inherits
|
||||
# now_eastern() rather than mixing in a second clock.
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
|
||||
is_inspector = current_user.is_inspector
|
||||
is_privileged = current_user.role in ['admin', 'director']
|
||||
@@ -62,6 +66,14 @@ def index():
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
# MT-16 — fully completed & submitted so far this week (Monday → now).
|
||||
# Reuses base_q, so it inherits the same role scoping as every other tile.
|
||||
submitted_this_week = base_q.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= week_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
# ── Open issues (inspector: all issues in contracted facilities) ───────
|
||||
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
@@ -282,6 +294,26 @@ def index():
|
||||
stale_q = stale_q.filter(False)
|
||||
stale_in_progress = stale_q.count()
|
||||
|
||||
# ── In-progress inspections, all ages (MT-16) ─────────────────────────────
|
||||
# stale_in_progress above counts only those older than 24h. The modern
|
||||
# dashboard shows the full in-progress count as its own tile, so this is a
|
||||
# separate query with the SAME role scoping rather than a reuse of stale_q.
|
||||
inprog_q = Inspection.query.filter(Inspection.status == 'in_progress')
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
inprog_q = inprog_q.filter(False)
|
||||
else:
|
||||
inprog_q = inprog_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
inprog_q = inprog_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
inprog_q = inprog_q.filter(False)
|
||||
in_progress_total = inprog_q.count()
|
||||
|
||||
# ── Unassigned open issues ────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaU
|
||||
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
|
||||
@@ -349,6 +381,11 @@ def index():
|
||||
# inspections list, so surfacing them here would double-report the work.
|
||||
sched_upcoming = []
|
||||
sched_overdue_count = 0
|
||||
# MT-16 — the modern dashboard additionally shows the total number of active
|
||||
# plans ("On Schedules") and offers Continue instead of a duplicate Start
|
||||
# where an inspection is already underway for that plan.
|
||||
sched_total = 0
|
||||
sched_open_inspections = {}
|
||||
if not is_customer:
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
_today = now_eastern().date()
|
||||
@@ -365,11 +402,34 @@ def index():
|
||||
s for s in _all_sched
|
||||
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
sched_total = len(_all_sched) # active plan-mode schedules
|
||||
# {schedule_id: inspection_id} for plans with an inspection already in
|
||||
# progress. MT's FK is Inspection.inspection_schedule_id (ST calls it
|
||||
# scheduled_inspection_id). Ordered ascending so that when a plan somehow
|
||||
# has more than one open inspection, the dict keeps the LOWEST id — the
|
||||
# original, not a later duplicate.
|
||||
_sched_ids = [s.id for s in sched_upcoming if s.id]
|
||||
if _sched_ids:
|
||||
_open_rows = (
|
||||
Inspection.query
|
||||
.filter(Inspection.inspection_schedule_id.in_(_sched_ids),
|
||||
Inspection.status == 'in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.all()
|
||||
)
|
||||
sched_open_inspections = {
|
||||
r.inspection_schedule_id: r.id for r in _open_rows
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
sched_total = sched_total,
|
||||
sched_open_inspections = sched_open_inspections,
|
||||
in_progress_total = in_progress_total,
|
||||
submitted_this_week = submitted_this_week,
|
||||
week_start_str = week_start.strftime('%Y-%m-%d'),
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
app/routes/ui.py
|
||||
----------------
|
||||
Web portal design switch and the pages the modern sidebar links to (MT-16).
|
||||
|
||||
Routes
|
||||
POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern
|
||||
GET /ui/about about() — About Us page
|
||||
GET /ui/support-center support_center() — support hub of how-to cards
|
||||
GET /ui/theme-votes theme_votes() — admin tally of design choices
|
||||
|
||||
Nothing here changes existing behaviour: the theme flag only selects which
|
||||
layout shell base.html extends. Every page template is untouched.
|
||||
|
||||
Multi-tenant note
|
||||
-----------------
|
||||
`users` is a per-tenant table, so every query in this module is automatically
|
||||
scoped to the caller's tenant by the routing session — the vote tally shows one
|
||||
tenant's users, never the estate. There is deliberately no cross-tenant rollup
|
||||
here; that belongs in the control plane if it is ever wanted.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import (Blueprint, render_template, redirect, request,
|
||||
url_for, flash, current_app)
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.models.user import User, ROLE_LABELS
|
||||
from app.utils.audit import log_action, ACTION_UPDATE
|
||||
|
||||
bp = Blueprint('ui', __name__, url_prefix='/ui')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_THEMES = ('classic', 'modern')
|
||||
|
||||
|
||||
def _default_theme():
|
||||
"""Fallback design for accounts that have never chosen one."""
|
||||
default = (current_app.config.get('DEFAULT_UI_THEME') or 'classic').lower()
|
||||
return default if default in VALID_THEMES else 'classic'
|
||||
|
||||
|
||||
def _safe_next(target):
|
||||
"""Only allow same-site relative redirects (open-redirect guard).
|
||||
|
||||
A protocol-relative URL ('//evil.com') is a valid redirect target to the
|
||||
browser but points off-site, so the leading-slash test alone is not enough.
|
||||
"""
|
||||
if not target:
|
||||
return url_for('dashboard.index')
|
||||
if target.startswith('/') and not target.startswith('//'):
|
||||
return target
|
||||
return url_for('dashboard.index')
|
||||
|
||||
|
||||
# ── Design switch ────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/theme', methods=['POST'])
|
||||
@login_required
|
||||
def switch_theme():
|
||||
"""Persist the user's design choice, then return them to the same page."""
|
||||
theme = (request.form.get('theme') or '').strip().lower()
|
||||
if theme not in VALID_THEMES:
|
||||
flash('Unknown design option.', 'warning')
|
||||
return redirect(_safe_next(request.form.get('next')))
|
||||
|
||||
previous = current_user.ui_theme or _default_theme()
|
||||
if previous != theme:
|
||||
current_user.ui_theme = theme
|
||||
db.session.commit()
|
||||
|
||||
# log_action() commits internally — always AFTER the business commit.
|
||||
log_action(
|
||||
action = ACTION_UPDATE,
|
||||
entity_type = 'User',
|
||||
entity_id = current_user.id,
|
||||
entity_label = current_user.username,
|
||||
details = f'ui_theme={previous}→{theme}',
|
||||
)
|
||||
logger.info('UI | theme switch | user=%s | %s -> %s',
|
||||
current_user.username, previous, theme)
|
||||
flash('Now showing the {} design. You can switch back any time from '
|
||||
'the account menu.'.format('new' if theme == 'modern' else 'classic'),
|
||||
'info')
|
||||
|
||||
return redirect(_safe_next(request.form.get('next')))
|
||||
|
||||
|
||||
# ── New pages (linked from the modern sidebar) ───────────────────────────────
|
||||
|
||||
@bp.route('/about')
|
||||
@login_required
|
||||
def about():
|
||||
return render_template('ui/about.html')
|
||||
|
||||
|
||||
@bp.route('/support-center')
|
||||
@login_required
|
||||
def support_center():
|
||||
return render_template('ui/support_center.html')
|
||||
|
||||
|
||||
# ── Admin: which design are people actually keeping? ─────────────────────────
|
||||
|
||||
@bp.route('/theme-votes')
|
||||
@login_required
|
||||
def theme_votes():
|
||||
if current_user.role != 'admin':
|
||||
flash('You do not have permission to view the design vote tally.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
default = _default_theme()
|
||||
|
||||
rows = (db.session.query(User.ui_theme, func.count(User.id))
|
||||
.filter(User.active == True) # noqa: E712 — SQL boolean
|
||||
.group_by(User.ui_theme)
|
||||
.all())
|
||||
tally = {t: 0 for t in VALID_THEMES}
|
||||
for theme, count in rows:
|
||||
key = theme if theme in VALID_THEMES else default
|
||||
tally[key] = tally.get(key, 0) + count
|
||||
total = sum(tally.values())
|
||||
|
||||
by_role = (db.session.query(User.role, User.ui_theme, func.count(User.id))
|
||||
.filter(User.active == True) # noqa: E712
|
||||
.group_by(User.role, User.ui_theme)
|
||||
.order_by(User.role)
|
||||
.all())
|
||||
|
||||
return render_template('ui/theme_votes.html',
|
||||
tally=tally, total=total, by_role=by_role,
|
||||
default_theme=default,
|
||||
# by_role yields raw role strings from a group_by,
|
||||
# so hand the template the same label source the
|
||||
# User.role_label property uses (MT-15).
|
||||
role_labels=ROLE_LABELS)
|
||||
@@ -0,0 +1,482 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Janitorial QC — MODERN design skin (design A/B test — "modern")
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
Loaded ONLY by templates/layouts/modern.html, and always AFTER
|
||||
theme.css, so every token below overrides the classic one.
|
||||
|
||||
The classic design is completely untouched by this file.
|
||||
|
||||
Palette sampled from the JQC_design deck:
|
||||
brand #155F82 deep teal-blue top bar / table headers
|
||||
brand-700 #0F4A66 hover / pressed
|
||||
brand-050 #DCEBF5 soft icon tiles, active rail rows
|
||||
page #EAEEF1 page background
|
||||
surface #FFFFFF cards
|
||||
ink #1D2A32 headings
|
||||
muted #6B7A85 secondary text
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 1. Tokens ───────────────────────────────────────────────────────────── */
|
||||
body.jqc-modern {
|
||||
--jqc-brand: #155F82;
|
||||
--jqc-brand-700: #0F4A66;
|
||||
--jqc-brand-600: #1B6E93;
|
||||
--jqc-brand-050: #DCEBF5;
|
||||
--jqc-brand-025: #E9F0F8;
|
||||
--jqc-page: #EAEEF1;
|
||||
--jqc-ink: #1D2A32;
|
||||
--jqc-heading: #1D2A32;
|
||||
--jqc-muted: #6B7A85;
|
||||
--jqc-faint: #93A1AB;
|
||||
--jqc-border: #E3E8EC;
|
||||
--jqc-border-2: #CFD9E0;
|
||||
--jqc-surface: #F5F8FA; /* subtle fill — hovers, muted rows (theme.css) */
|
||||
--jqc-surface-2: #EEF3F6;
|
||||
--jqc-card-bg: #FFFFFF; /* raised surfaces — cards, KPI tiles, sidebar */
|
||||
--jqc-accent: #155F82;
|
||||
--jqc-accent-700: #0F4A66;
|
||||
--jqc-accent-50: #DCEBF5;
|
||||
--jqc-shadow: 0 1px 2px rgba(21, 46, 62, .05), 0 6px 18px rgba(21, 46, 62, .06);
|
||||
--jqc-shadow-md: 0 10px 30px rgba(21, 46, 62, .12);
|
||||
|
||||
--bs-primary: #155F82;
|
||||
--bs-primary-rgb: 21, 95, 130;
|
||||
--bs-link-color: #155F82;
|
||||
--bs-link-color-rgb: 21, 95, 130;
|
||||
--bs-link-hover-color: #0F4A66;
|
||||
--bs-link-hover-color-rgb: 15, 74, 102;
|
||||
--bs-body-bg: #EAEEF1;
|
||||
--bs-body-color: #1D2A32;
|
||||
--bs-border-color: #E3E8EC;
|
||||
--bs-border-radius: .6rem;
|
||||
--bs-border-radius-sm: .45rem;
|
||||
--bs-border-radius-lg: 1rem;
|
||||
--bs-border-radius-xl: 1.15rem;
|
||||
|
||||
--jqc-topbar-h: 72px;
|
||||
--jqc-sidebar-w: 232px;
|
||||
|
||||
background-color: var(--jqc-page);
|
||||
color: var(--jqc-ink);
|
||||
font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* ── 2. Top bar ──────────────────────────────────────────────────────────── */
|
||||
.jqc-modern .jqc-topbar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: var(--jqc-topbar-h);
|
||||
z-index: 1035;
|
||||
background: var(--jqc-brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 20px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .10);
|
||||
}
|
||||
.jqc-modern .jqc-brand {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.jqc-modern .jqc-brand-mark {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.jqc-modern .jqc-brand-sub {
|
||||
display: block;
|
||||
font-size: .68rem;
|
||||
opacity: .82;
|
||||
margin-top: 3px;
|
||||
}
|
||||
/* MT: a tenant with an uploaded logo renders it in place of the "JQC" wordmark.
|
||||
Capped in height so a tall logo cannot stretch the top bar. */
|
||||
.jqc-modern .jqc-brand-logo {
|
||||
display: block;
|
||||
max-height: 30px;
|
||||
max-width: 150px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.jqc-modern .jqc-search {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
width: min(420px, 42vw);
|
||||
}
|
||||
.jqc-modern .jqc-search i {
|
||||
position: absolute;
|
||||
left: 16px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--jqc-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.jqc-modern .jqc-search .form-control {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
height: 42px;
|
||||
padding-left: 44px;
|
||||
background: #fff;
|
||||
font-size: .92rem;
|
||||
}
|
||||
.jqc-modern .jqc-search .form-control:focus {
|
||||
box-shadow: 0 0 0 .2rem rgba(255, 255, 255, .35);
|
||||
}
|
||||
.jqc-modern .jqc-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.jqc-modern .jqc-icon-btn {
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
transition: background-color .15s;
|
||||
}
|
||||
.jqc-modern .jqc-icon-btn:hover { background: rgba(255, 255, 255, .14); color: #fff; }
|
||||
.jqc-modern .jqc-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--jqc-brand-700);
|
||||
border: 2px solid rgba(255, 255, 255, .85);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: .9rem;
|
||||
letter-spacing: .02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.jqc-modern .jqc-avatar:hover { background: #0b3b53; color: #fff; }
|
||||
.jqc-modern .jqc-hamburger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* ── 3. Sidebar ──────────────────────────────────────────────────────────── */
|
||||
.jqc-modern .jqc-sidebar {
|
||||
position: fixed;
|
||||
top: var(--jqc-topbar-h);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--jqc-sidebar-w);
|
||||
background: var(--jqc-card-bg);
|
||||
border-right: 1px solid var(--jqc-border);
|
||||
overflow-y: auto;
|
||||
z-index: 1030;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.jqc-modern .jqc-nav { flex: 1 1 auto; }
|
||||
.jqc-modern .jqc-nav-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 13px 18px 13px 22px;
|
||||
color: #43525C;
|
||||
text-decoration: none;
|
||||
font-size: .95rem;
|
||||
font-weight: 500;
|
||||
transition: background-color .15s, color .15s;
|
||||
}
|
||||
.jqc-modern .jqc-nav-link i { font-size: 1.15rem; width: 22px; text-align: center; }
|
||||
.jqc-modern .jqc-nav-link span { flex: 1 1 auto; }
|
||||
.jqc-modern .jqc-nav-link:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-nav-link.active {
|
||||
background: var(--jqc-brand-025);
|
||||
color: var(--jqc-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
.jqc-modern .jqc-nav-link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--jqc-brand);
|
||||
}
|
||||
.jqc-modern .jqc-nav-caret { font-size: .7rem !important; width: auto !important; opacity: .6; }
|
||||
.jqc-modern .jqc-nav-badge {
|
||||
background: #D9534F;
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: .68rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.jqc-modern .jqc-nav-sublink {
|
||||
display: block;
|
||||
padding: 9px 18px 9px 58px;
|
||||
font-size: .88rem;
|
||||
color: #5A6A75;
|
||||
text-decoration: none;
|
||||
}
|
||||
.jqc-modern .jqc-nav-sublink:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-nav-sublink.active { color: var(--jqc-brand); font-weight: 700; }
|
||||
/* .jqc-sidebar-foot / .jqc-switch-btn were dropped in phase50 along with the
|
||||
sidebar design switcher — no template references them any more. */
|
||||
.jqc-modern .jqc-sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 34, 46, .45);
|
||||
z-index: 1029;
|
||||
display: none;
|
||||
}
|
||||
.jqc-modern .jqc-sidebar-backdrop.show { display: block; }
|
||||
|
||||
/* ── 4. Main region ──────────────────────────────────────────────────────── */
|
||||
.jqc-modern .jqc-main {
|
||||
margin-left: var(--jqc-sidebar-w);
|
||||
padding: calc(var(--jqc-topbar-h) + 22px) 10px 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.jqc-modern .jqc-main > .container-fluid { padding-inline: 14px; }
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.jqc-modern .jqc-sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform .2s ease;
|
||||
box-shadow: 0 0 24px rgba(15, 34, 46, .18);
|
||||
}
|
||||
.jqc-modern .jqc-sidebar.open { transform: translateX(0); }
|
||||
.jqc-modern .jqc-main { margin-left: 0; }
|
||||
.jqc-modern .jqc-search { width: auto; flex: 1 1 auto; }
|
||||
.jqc-modern .jqc-brand-sub { display: none; }
|
||||
}
|
||||
|
||||
/* ── 5. Page heading block (used by the rebuilt modern pages) ────────────── */
|
||||
.jqc-modern .jqc-page-head { margin-bottom: 20px; }
|
||||
.jqc-modern .jqc-page-head h1,
|
||||
.jqc-modern .jqc-page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--jqc-ink);
|
||||
margin: 0;
|
||||
}
|
||||
/* Opt-in centring — the default is left-aligned. `.center` on the head block
|
||||
centres the title and its sub-line together. */
|
||||
.jqc-modern .jqc-page-head.center,
|
||||
.jqc-modern .jqc-page-title.center { text-align: center; }
|
||||
.jqc-modern .jqc-page-sub {
|
||||
color: var(--jqc-muted);
|
||||
font-size: .95rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 6. Cards / surfaces (applies to every page, rebuilt or not) ─────────── */
|
||||
.jqc-modern .card {
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
}
|
||||
.jqc-modern .card-header {
|
||||
background: var(--jqc-card-bg);
|
||||
border-bottom: 1px solid var(--jqc-border);
|
||||
color: var(--jqc-ink);
|
||||
font-weight: 700;
|
||||
padding: .9rem 1.15rem;
|
||||
}
|
||||
.jqc-modern .card-header.bg-light,
|
||||
.jqc-modern .card-header.bg-white { background: var(--jqc-card-bg) !important; }
|
||||
.jqc-modern .card-body { padding: 1.15rem; }
|
||||
|
||||
.jqc-modern .jqc-card {
|
||||
background: var(--jqc-card-bg);
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.jqc-modern .jqc-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--jqc-ink);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Soft square icon tile — the deck's signature element */
|
||||
.jqc-modern .jqc-tile-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 11px;
|
||||
background: var(--jqc-brand-050);
|
||||
color: var(--jqc-brand);
|
||||
font-size: 1.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.jqc-modern .jqc-tile-icon.lg { width: 74px; height: 74px; border-radius: 18px; font-size: 2rem; }
|
||||
|
||||
/* KPI tiles */
|
||||
.jqc-modern .jqc-kpi {
|
||||
background: var(--jqc-card-bg);
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 18px 20px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow .15s, transform .15s;
|
||||
}
|
||||
a.jqc-kpi:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-1px); color: inherit; }
|
||||
.jqc-modern .jqc-kpi-value {
|
||||
font-size: 2.1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: var(--jqc-ink);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.jqc-modern .jqc-kpi-label { font-size: .85rem; color: var(--jqc-muted); margin-top: 2px; }
|
||||
|
||||
/* Label / value rows inside summary cards */
|
||||
.jqc-modern .jqc-stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 2px;
|
||||
border-bottom: 1px solid var(--jqc-border);
|
||||
text-decoration: none;
|
||||
color: var(--jqc-ink);
|
||||
}
|
||||
.jqc-modern .jqc-stat-row:last-child { border-bottom: none; }
|
||||
.jqc-modern .jqc-stat-row:hover { color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-stat-label { font-size: .95rem; display: flex; align-items: center; gap: 9px; }
|
||||
.jqc-modern .jqc-stat-value { font-size: 1.05rem; font-weight: 800; white-space: nowrap; }
|
||||
.jqc-modern .jqc-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
display: inline-block; flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Hub cards (Facility / Support pages) */
|
||||
.jqc-modern .jqc-hub-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--jqc-card-bg);
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 24px 26px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow .15s, transform .15s;
|
||||
}
|
||||
.jqc-modern .jqc-hub-card:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-2px); color: inherit; }
|
||||
.jqc-modern .jqc-hub-title { font-size: 1.3rem; font-weight: 800; color: var(--jqc-ink); }
|
||||
.jqc-modern .jqc-hub-text { color: var(--jqc-muted); font-size: .93rem; margin-top: 6px; }
|
||||
.jqc-modern .jqc-hub-open { color: var(--jqc-brand); font-weight: 700; font-size: .9rem; margin-top: auto; padding-top: 18px; }
|
||||
.jqc-modern .jqc-hub-card.dark { background: var(--jqc-brand); border-color: var(--jqc-brand); }
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-hub-title,
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-hub-text { color: #fff; }
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-tile-icon { background: #fff; }
|
||||
/* The default .jqc-hub-open is brand-coloured, which is invisible on the dark
|
||||
(brand-filled) card — it needs its own colour. */
|
||||
.jqc-modern .jqc-hub-card.dark .jqc-hub-open { color: #fff; }
|
||||
|
||||
/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */
|
||||
.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; }
|
||||
|
||||
/* Recoloured via Bootstrap's own table CSS variables rather than !important, so
|
||||
a page that deliberately wants a different header (table-dark, a tinted
|
||||
report header) can still override it with a normal rule. */
|
||||
.jqc-modern .table > thead > tr > th,
|
||||
.jqc-modern .table thead.table-light > tr > th,
|
||||
.jqc-modern .table > thead th {
|
||||
--bs-table-bg: var(--jqc-brand);
|
||||
--bs-table-color: #fff;
|
||||
background-color: var(--jqc-brand);
|
||||
color: #fff;
|
||||
border-color: var(--jqc-brand-700);
|
||||
font-weight: 600;
|
||||
font-size: .88rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* nowrap only where the column set is known-narrow (dashboard panels); wide
|
||||
tables such as the issues list and audit trail must be free to wrap rather
|
||||
than force a horizontal scroll on iPad portrait. */
|
||||
.jqc-modern .jqc-card .table > thead th { white-space: nowrap; }
|
||||
.jqc-modern .table > tbody > tr > td { vertical-align: middle; font-size: .92rem; }
|
||||
.jqc-modern .table-hover > tbody > tr:hover > * { background-color: var(--jqc-brand-025); }
|
||||
.jqc-modern .jqc-table-wrap {
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 8. Buttons, badges, forms ───────────────────────────────────────────── */
|
||||
.jqc-modern .btn { border-radius: .6rem; font-weight: 600; }
|
||||
.jqc-modern .btn-primary {
|
||||
--bs-btn-bg: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
|
||||
--bs-btn-hover-bg: var(--jqc-brand-700); --bs-btn-hover-border-color: var(--jqc-brand-700);
|
||||
--bs-btn-active-bg: var(--jqc-brand-700); --bs-btn-active-border-color: var(--jqc-brand-700);
|
||||
--bs-btn-disabled-bg: var(--jqc-brand); --bs-btn-disabled-border-color: var(--jqc-brand);
|
||||
}
|
||||
.jqc-modern .btn-outline-primary {
|
||||
--bs-btn-color: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
|
||||
--bs-btn-hover-bg: var(--jqc-brand); --bs-btn-hover-border-color: var(--jqc-brand);
|
||||
--bs-btn-active-bg: var(--jqc-brand); --bs-btn-active-border-color: var(--jqc-brand);
|
||||
}
|
||||
.jqc-modern .bg-primary { background-color: var(--jqc-brand) !important; }
|
||||
.jqc-modern .text-primary { color: var(--jqc-brand) !important; }
|
||||
.jqc-modern .badge { border-radius: 999px; font-weight: 700; padding: .35em .7em; }
|
||||
.jqc-modern .form-control,
|
||||
.jqc-modern .form-select {
|
||||
border-radius: .6rem;
|
||||
border-color: var(--jqc-border-2);
|
||||
}
|
||||
.jqc-modern .form-control:focus,
|
||||
.jqc-modern .form-select:focus {
|
||||
border-color: var(--jqc-brand);
|
||||
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .18);
|
||||
}
|
||||
|
||||
/* Filter bar — the rounded pill row from the deck */
|
||||
.jqc-modern .jqc-filter-bar {
|
||||
background: var(--jqc-card-bg);
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--jqc-shadow);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.jqc-modern .jqc-filter-bar .form-control,
|
||||
.jqc-modern .jqc-filter-bar .form-select { border-radius: 999px; padding-inline: 16px; }
|
||||
|
||||
/* ── 9. Alerts / misc ────────────────────────────────────────────────────── */
|
||||
.jqc-modern .alert { border-radius: 12px; border: 1px solid var(--jqc-border); }
|
||||
.jqc-modern .dropdown-menu { border-radius: 12px; border-color: var(--jqc-border); box-shadow: var(--jqc-shadow-md); }
|
||||
.jqc-modern .nav-tabs .nav-link.active { color: var(--jqc-brand); }
|
||||
.jqc-modern .progress-bar.bg-success { background-color: #2E7D4F !important; }
|
||||
|
||||
/* Print: drop the chrome entirely */
|
||||
@media print {
|
||||
.jqc-modern .jqc-topbar,
|
||||
.jqc-modern .jqc-sidebar,
|
||||
.jqc-modern .jqc-sidebar-backdrop { display: none !important; }
|
||||
.jqc-modern .jqc-main { margin-left: 0; padding-top: 0; }
|
||||
}
|
||||
+12
-527
@@ -1,530 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<!-- iOS / iPadOS web app meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
|
||||
{% if tenant_branding %}
|
||||
<style>
|
||||
:root {
|
||||
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
||||
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
||||
}
|
||||
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
||||
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
</style>
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* ── Notification bell styles ── */
|
||||
.notif-bell-wrapper { position: relative; }
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 2px; right: 2px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 16px; height: 16px; line-height: 16px;
|
||||
padding: 0 4px; border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.notif-dropdown {
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.notif-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notif-item.unread {
|
||||
border-left-color: #0d6efd;
|
||||
background-color: #f0f6ff;
|
||||
}
|
||||
.notif-item:hover { background-color: #e8f0fe; }
|
||||
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||
.notif-time { font-size: 0.7rem; color: #999; }
|
||||
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||
{# ────────────────────────────────────────────────────────────────────────────
|
||||
base.html — layout dispatcher (MT-16)
|
||||
|
||||
/* ── Active nav tab ── */
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff !important;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||
{% if tenant_branding and tenant_branding.logo_url %}
|
||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
||||
alt="{{ tenant_branding.display_name }}"
|
||||
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
|
||||
{% else %}
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
{% endif %}
|
||||
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
||||
</a>
|
||||
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
||||
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
|
||||
<!-- Notification bell (always visible) -->
|
||||
<div class="dropdown">
|
||||
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
||||
href="#"
|
||||
id="notifDropdownMobile"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu-mobile">
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
||||
style="font-size:.75rem;">Mark all as read</button>
|
||||
</div>
|
||||
<div class="notif-list-mobile">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
Verify
|
||||
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||
<span class="badge bg-info text-dark"
|
||||
style="font-size:.65rem;line-height:1;">
|
||||
{{ pending_verification_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="{{ url_for('support.admin_tickets') }}">
|
||||
Support
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'customer' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-chat-dots me-1"></i>Support
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
|
||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
|
||||
<i class="bi bi-clock-history me-2"></i>My Conversations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||
<i class="bi bi-inbox me-2"></i>My Requests
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% set admin_active = request.endpoint and (
|
||||
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||
or request.endpoint.startswith('audit.')
|
||||
or request.endpoint == 'auth.notification_matrix'
|
||||
or request.endpoint.startswith('broadcast.')
|
||||
or request.endpoint.startswith('devices.')
|
||||
or request.endpoint.startswith('tenant_settings.')
|
||||
) %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
|
||||
href="#" id="adminMenu" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-sliders me-1"></i>Admin
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
||||
href="{{ url_for('auth.list_users') }}">
|
||||
<i class="bi bi-people me-2"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
||||
href="{{ url_for('audit.index') }}">
|
||||
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
||||
href="{{ url_for('auth.notification_matrix') }}">
|
||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||
href="{{ url_for('broadcast.index') }}">
|
||||
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||
href="{{ url_for('devices.index') }}">
|
||||
<i class="bi bi-tablet me-2"></i>Devices
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||
href="{{ url_for('tenant_settings.branding') }}">
|
||||
<i class="bi bi-gear me-2"></i>Workspace Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav align-items-center">
|
||||
This file used to hold the entire page chrome. That markup now lives in
|
||||
layouts/classic.html, unchanged.
|
||||
|
||||
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
||||
<li class="nav-item dropdown me-2 d-none d-xxl-block">
|
||||
<a class="nav-link position-relative notif-bell-wrapper"
|
||||
href="#"
|
||||
id="notifDropdown"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center
|
||||
px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
<!-- Items -->
|
||||
<div id="notif-list">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2"
|
||||
style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}"
|
||||
class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}"
|
||||
class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<!-- ── End Notification Bell ── -->
|
||||
Every page template still says {% extends "base.html" %} and needed ZERO
|
||||
edits: Jinja resolves {% block %} overrides through the whole inheritance
|
||||
chain, so one extra link in that chain is invisible to them.
|
||||
|
||||
<!-- 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 %}
|
||||
|
||||
{% include 'billing/_billing_banner.html' %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||
const MARK_READ_BASE = '/notifications/';
|
||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
const POLL_INTERVAL = 60000; // 60 seconds
|
||||
|
||||
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
||||
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
||||
const listDesktop = document.getElementById('notif-list');
|
||||
const listMobile = document.querySelector('.notif-list-mobile');
|
||||
|
||||
// ── Update both badge instances ────────────────────────────────────────
|
||||
function updateBadge(count) {
|
||||
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.classList.remove('d-none');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render notification items into a given container ───────────────────
|
||||
function renderInto(container, notifications) {
|
||||
if (!container) return;
|
||||
if (!notifications.length) {
|
||||
container.innerHTML = '<div class="notif-empty">'
|
||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = notifications.map(function(n) {
|
||||
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||
+ (n.is_read ? '' : 'unread') + '"'
|
||||
+ ' data-notif-id="' + n.id + '"'
|
||||
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
var id = this.dataset.notifId;
|
||||
var link = this.dataset.link;
|
||||
markRead(id, function() {
|
||||
el.classList.remove('unread');
|
||||
if (link) window.location.href = link;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(notifications) {
|
||||
renderInto(listDesktop, notifications);
|
||||
renderInto(listMobile, notifications);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escapeAttr(str) { return escapeHtml(str); }
|
||||
|
||||
// ── Fetch + update ─────────────────────────────────────────────────────
|
||||
window.fetchNotifications = function fetchNotifications() {
|
||||
fetch(FEED_URL, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
updateBadge(data.unread_count);
|
||||
window._jqcNotifications = data.notifications;
|
||||
var deskEl = document.getElementById('notifDropdown');
|
||||
var mobileEl = document.getElementById('notifDropdownMobile');
|
||||
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
|
||||
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
|
||||
if (deskOpen || mobileOpen) {
|
||||
renderNotifications(data.notifications);
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
function markRead(id, callback) {
|
||||
fetch(MARK_READ_BASE + id + '/mark-read', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() { if (callback) callback(); fetchNotifications(); })
|
||||
.catch(function() { if (callback) callback(); });
|
||||
}
|
||||
|
||||
// ── Show dropdown → render cached data immediately ─────────────────────
|
||||
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('show.bs.dropdown', function() {
|
||||
if (window._jqcNotifications) {
|
||||
renderNotifications(window._jqcNotifications);
|
||||
} else {
|
||||
fetchNotifications();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mark all read — works from either bell ─────────────────────────────
|
||||
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
fetch(MARK_ALL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': CSRF_TOKEN,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
updateBadge(0);
|
||||
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
|
||||
el.classList.remove('unread');
|
||||
});
|
||||
if (window._jqcNotifications) {
|
||||
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
});
|
||||
});
|
||||
|
||||
fetchNotifications();
|
||||
setInterval(fetchNotifications, POLL_INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
`jqc_layout` is supplied by the inject_ui_theme() context processor in
|
||||
app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with
|
||||
config DEFAULT_UI_THEME as the fallback for accounts that never chose.
|
||||
──────────────────────────────────────────────────────────────────────────── #}
|
||||
{% extends jqc_layout %}
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<!-- iOS / iPadOS web app meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
|
||||
{% if tenant_branding %}
|
||||
<style>
|
||||
:root {
|
||||
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
||||
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
||||
}
|
||||
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
||||
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
</style>
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* ── Notification bell styles ── */
|
||||
.notif-bell-wrapper { position: relative; }
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 2px; right: 2px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 16px; height: 16px; line-height: 16px;
|
||||
padding: 0 4px; border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.notif-dropdown {
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.notif-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notif-item.unread {
|
||||
border-left-color: #0d6efd;
|
||||
background-color: #f0f6ff;
|
||||
}
|
||||
.notif-item:hover { background-color: #e8f0fe; }
|
||||
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||
.notif-time { font-size: 0.7rem; color: #999; }
|
||||
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||
|
||||
/* ── Active nav tab ── */
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff !important;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||
{% if tenant_branding and tenant_branding.logo_url %}
|
||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
||||
alt="{{ tenant_branding.display_name }}"
|
||||
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
|
||||
{% else %}
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
{% endif %}
|
||||
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
||||
</a>
|
||||
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
||||
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
|
||||
<!-- Notification bell (always visible) -->
|
||||
<div class="dropdown">
|
||||
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
||||
href="#"
|
||||
id="notifDropdownMobile"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu-mobile">
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
||||
style="font-size:.75rem;">Mark all as read</button>
|
||||
</div>
|
||||
<div class="notif-list-mobile">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
Verify
|
||||
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||
<span class="badge bg-info text-dark"
|
||||
style="font-size:.65rem;line-height:1;">
|
||||
{{ pending_verification_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="{{ url_for('support.admin_tickets') }}">
|
||||
Support
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'customer' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-chat-dots me-1"></i>Support
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
|
||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
|
||||
<i class="bi bi-clock-history me-2"></i>My Conversations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||
<i class="bi bi-inbox me-2"></i>My Requests
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% set admin_active = request.endpoint and (
|
||||
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||
or request.endpoint.startswith('audit.')
|
||||
or request.endpoint == 'auth.notification_matrix'
|
||||
or request.endpoint.startswith('broadcast.')
|
||||
or request.endpoint.startswith('devices.')
|
||||
or request.endpoint.startswith('tenant_settings.')
|
||||
) %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
|
||||
href="#" id="adminMenu" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-sliders me-1"></i>Admin
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
||||
href="{{ url_for('auth.list_users') }}">
|
||||
<i class="bi bi-people me-2"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
||||
href="{{ url_for('audit.index') }}">
|
||||
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
||||
href="{{ url_for('auth.notification_matrix') }}">
|
||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||
href="{{ url_for('broadcast.index') }}">
|
||||
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||
href="{{ url_for('devices.index') }}">
|
||||
<i class="bi bi-tablet me-2"></i>Devices
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||
href="{{ url_for('tenant_settings.branding') }}">
|
||||
<i class="bi bi-gear me-2"></i>Workspace Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav align-items-center">
|
||||
|
||||
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
||||
<li class="nav-item dropdown me-2 d-none d-xxl-block">
|
||||
<a class="nav-link position-relative notif-bell-wrapper"
|
||||
href="#"
|
||||
id="notifDropdown"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center
|
||||
px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
<!-- Items -->
|
||||
<div id="notif-list">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2"
|
||||
style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}"
|
||||
class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}"
|
||||
class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<!-- ── End Notification Bell ── -->
|
||||
|
||||
<!-- User menu -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
|
||||
role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person-circle me-1"></i>My Profile
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('notifications.preferences') }}">
|
||||
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{# MT-16 — opt in to the sidebar design. POST so the
|
||||
switch is not a GET side effect; `next` returns the
|
||||
user to the page they were on. #}
|
||||
<li>
|
||||
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="theme" value="modern">
|
||||
<input type="hidden" name="next" value="{{ request.full_path }}">
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-stars me-1"></i>Try the New Design
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
|
||||
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% include 'billing/_billing_banner.html' %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||
const MARK_READ_BASE = '/notifications/';
|
||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
const POLL_INTERVAL = 60000; // 60 seconds
|
||||
|
||||
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
||||
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
||||
const listDesktop = document.getElementById('notif-list');
|
||||
const listMobile = document.querySelector('.notif-list-mobile');
|
||||
|
||||
// ── Update both badge instances ────────────────────────────────────────
|
||||
function updateBadge(count) {
|
||||
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.classList.remove('d-none');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render notification items into a given container ───────────────────
|
||||
function renderInto(container, notifications) {
|
||||
if (!container) return;
|
||||
if (!notifications.length) {
|
||||
container.innerHTML = '<div class="notif-empty">'
|
||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = notifications.map(function(n) {
|
||||
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||
+ (n.is_read ? '' : 'unread') + '"'
|
||||
+ ' data-notif-id="' + n.id + '"'
|
||||
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
var id = this.dataset.notifId;
|
||||
var link = this.dataset.link;
|
||||
markRead(id, function() {
|
||||
el.classList.remove('unread');
|
||||
if (link) window.location.href = link;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(notifications) {
|
||||
renderInto(listDesktop, notifications);
|
||||
renderInto(listMobile, notifications);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escapeAttr(str) { return escapeHtml(str); }
|
||||
|
||||
// ── Fetch + update ─────────────────────────────────────────────────────
|
||||
window.fetchNotifications = function fetchNotifications() {
|
||||
fetch(FEED_URL, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
updateBadge(data.unread_count);
|
||||
window._jqcNotifications = data.notifications;
|
||||
var deskEl = document.getElementById('notifDropdown');
|
||||
var mobileEl = document.getElementById('notifDropdownMobile');
|
||||
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
|
||||
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
|
||||
if (deskOpen || mobileOpen) {
|
||||
renderNotifications(data.notifications);
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
function markRead(id, callback) {
|
||||
fetch(MARK_READ_BASE + id + '/mark-read', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() { if (callback) callback(); fetchNotifications(); })
|
||||
.catch(function() { if (callback) callback(); });
|
||||
}
|
||||
|
||||
// ── Show dropdown → render cached data immediately ─────────────────────
|
||||
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('show.bs.dropdown', function() {
|
||||
if (window._jqcNotifications) {
|
||||
renderNotifications(window._jqcNotifications);
|
||||
} else {
|
||||
fetchNotifications();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mark all read — works from either bell ─────────────────────────────
|
||||
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
fetch(MARK_ALL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': CSRF_TOKEN,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
updateBadge(0);
|
||||
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
|
||||
el.classList.remove('unread');
|
||||
});
|
||||
if (window._jqcNotifications) {
|
||||
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
});
|
||||
});
|
||||
|
||||
fetchNotifications();
|
||||
setInterval(fetchNotifications, POLL_INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,514 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<!-- iOS / iPadOS web app meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||
{# theme_modern.css loads LAST so it wins over theme.css tokens #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme_modern.css') }}">
|
||||
{# MT: per-tenant branding overrides, mirrored from layouts/classic.html.
|
||||
Loaded AFTER theme_modern.css so a tenant's colours win over the modern
|
||||
palette; the modern layout/structure is unaffected. #}
|
||||
{% if tenant_branding %}
|
||||
<style>
|
||||
:root {
|
||||
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
||||
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
||||
--jqc-brand: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||
}
|
||||
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
||||
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
</style>
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* ── Notification bell styles (shared with the classic layout) ── */
|
||||
.notif-bell-wrapper { position: relative; }
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 2px; right: 2px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 16px; height: 16px; line-height: 16px;
|
||||
padding: 0 4px; border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.notif-dropdown {
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.notif-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notif-item.unread {
|
||||
border-left-color: var(--jqc-brand);
|
||||
background-color: #f0f6fa;
|
||||
}
|
||||
.notif-item:hover { background-color: #e9f0f8; }
|
||||
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||
.notif-time { font-size: 0.7rem; color: #999; }
|
||||
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||
|
||||
/* ── Shared list-page filter panel (modern tint) ── */
|
||||
.filter-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--jqc-border);
|
||||
border-left: 4px solid var(--jqc-brand);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.filter-panel .filter-title {
|
||||
font-weight: 700;
|
||||
font-size: .82rem;
|
||||
letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--jqc-brand);
|
||||
}
|
||||
.filter-panel .form-label {
|
||||
font-weight: 600;
|
||||
color: #3f4652;
|
||||
}
|
||||
.filter-panel .form-control,
|
||||
.filter-panel .form-select {
|
||||
border: 1.5px solid #cfd9e0;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.filter-panel .form-control:focus,
|
||||
.filter-panel .form-select:focus {
|
||||
border-color: var(--jqc-brand);
|
||||
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .20);
|
||||
}
|
||||
.filter-panel .form-control::placeholder { color: #9aa4b2; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="jqc-modern">
|
||||
{% if current_user.is_authenticated %}
|
||||
|
||||
<!-- ══════════════════════════ TOP BAR ══════════════════════════ -->
|
||||
<header class="jqc-topbar">
|
||||
<button class="jqc-hamburger d-lg-none" type="button" id="jqcSidebarToggle" aria-label="Menu">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
<a class="jqc-brand" href="{{ url_for('dashboard.index') }}">
|
||||
{% if tenant_branding and tenant_branding.logo_url %}
|
||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
||||
alt="{{ tenant_branding.display_name }}" class="jqc-brand-logo">
|
||||
{% else %}
|
||||
<span class="jqc-brand-mark">JQC</span>
|
||||
{% endif %}
|
||||
<span class="jqc-brand-sub">
|
||||
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{# Scoped to inspection ID only — the placeholder says so explicitly so
|
||||
nobody types a facility name and assumes the search is broken. #}
|
||||
<form class="jqc-search" method="GET" action="{{ url_for('inspections.index') }}" role="search">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="search" name="inspection_id" class="form-control" inputmode="numeric"
|
||||
placeholder="Inspection # (e.g. 1423)" aria-label="Search by inspection number"
|
||||
title="Search by inspection number">
|
||||
</form>
|
||||
|
||||
<div class="jqc-topbar-actions">
|
||||
<!-- ── Notification Bell ── -->
|
||||
<div class="dropdown">
|
||||
<a class="jqc-icon-btn position-relative notif-bell-wrapper"
|
||||
href="#"
|
||||
id="notifDropdown"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
<div id="notif-list">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── User avatar menu ── -->
|
||||
<div class="dropdown">
|
||||
<a class="jqc-avatar" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
|
||||
title="{{ current_user.display_name }}">
|
||||
{{ (current_user.display_name.split() | map('first') | join)[:2] | upper }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li class="px-3 py-2 border-bottom">
|
||||
<div class="fw-semibold" style="font-size:.9rem;">{{ current_user.display_name }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ current_user.role_label }}</div>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person-circle me-1"></i>My Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('notifications.preferences') }}">
|
||||
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
{# ── Design switcher (modern → classic) ── #}
|
||||
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="theme" value="classic">
|
||||
<input type="hidden" name="next" value="{{ request.full_path }}">
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Back to Classic Design
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
|
||||
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ══════════════════════════ SIDEBAR ══════════════════════════ -->
|
||||
<aside class="jqc-sidebar" id="jqcSidebar">
|
||||
<nav class="jqc-nav">
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}"
|
||||
href="{{ url_for('dashboard.index') }}">
|
||||
<i class="bi bi-grid"></i><span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('inspections.') or request.endpoint.startswith('inspection_schedules.')) }}"
|
||||
href="{{ url_for('inspections.index') }}">
|
||||
<i class="bi bi-clipboard-check"></i><span>Inspections</span>
|
||||
</a>
|
||||
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}"
|
||||
href="{{ url_for('reports.index') }}">
|
||||
<i class="bi bi-bar-chart-fill"></i><span>Reports & Analytics</span>
|
||||
</a>
|
||||
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.index') }}">
|
||||
<i class="bi bi-exclamation-triangle"></i><span>Issues</span>
|
||||
</a>
|
||||
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
<i class="bi bi-patch-check"></i><span>Verify</span>
|
||||
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||
<span class="jqc-nav-badge">{{ pending_verification_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}"
|
||||
href="{{ url_for('projects.index') }}">
|
||||
<i class="bi bi-file-earmark-text"></i><span>Contract</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}"
|
||||
href="{{ url_for('facilities.list_facilities') }}">
|
||||
<i class="bi bi-buildings"></i><span>Facility</span>
|
||||
</a>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}"
|
||||
href="{{ url_for('templates.index') }}">
|
||||
<i class="bi bi-list-check"></i><span>Templates</span>
|
||||
</a>
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}"
|
||||
href="{{ url_for('customers.index') }}">
|
||||
<i class="bi bi-people"></i><span>Customer</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('support.') or request.endpoint == 'ui.support_center') }}"
|
||||
href="{{ url_for('ui.support_center') }}">
|
||||
<i class="bi bi-life-preserver"></i><span>Supports</span>
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<span class="jqc-nav-badge">{{ open_support_tickets_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% set admin_active = request.endpoint and (
|
||||
request.endpoint.startswith('audit.')
|
||||
or request.endpoint == 'auth.notification_matrix'
|
||||
or request.endpoint.startswith('broadcast.')
|
||||
or request.endpoint.startswith('devices.')
|
||||
or request.endpoint.startswith('tenant_settings.')
|
||||
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||
) %}
|
||||
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
|
||||
href="#jqcAdminMenu" role="button" aria-expanded="{{ 'true' if admin_active else 'false' }}">
|
||||
<i class="bi bi-shield-lock"></i><span>Admin</span>
|
||||
<i class="bi bi-chevron-down jqc-nav-caret"></i>
|
||||
</a>
|
||||
<div class="collapse {{ 'show' if admin_active }}" id="jqcAdminMenu">
|
||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
||||
href="{{ url_for('auth.list_users') }}">Users</a>
|
||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
||||
href="{{ url_for('audit.index') }}">Audit Trail</a>
|
||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
||||
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
|
||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||
href="{{ url_for('broadcast.index') }}">Broadcast</a>
|
||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||
href="{{ url_for('devices.index') }}">Devices</a>
|
||||
{# MT: tenant self-service settings replace ST's enrollment entry —
|
||||
the enrollment blueprint does not exist in this codebase. #}
|
||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||
href="{{ url_for('tenant_settings.branding') }}">Workspace Settings</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'ui.about' }}" href="{{ url_for('ui.about') }}">
|
||||
<i class="bi bi-info-circle"></i><span>About Us</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{# The design switcher was removed from the sidebar in phase50, when
|
||||
modern became the default — it no longer belongs in the primary nav.
|
||||
The same action still exists in the account menu (top right), so
|
||||
anyone who needs the classic design can still get to it. #}
|
||||
</aside>
|
||||
<div class="jqc-sidebar-backdrop d-lg-none" id="jqcSidebarBackdrop"></div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ══════════════════════════ MAIN ══════════════════════════ -->
|
||||
<main class="{{ 'jqc-main' if current_user.is_authenticated else '' }}">
|
||||
<div class="container-fluid {{ '' if current_user.is_authenticated else 'mt-4' }}">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{# MT: the billing banner is layout chrome in layouts/classic.html and
|
||||
must render here too — a user on the modern design must not miss a
|
||||
suspension or dunning notice.
|
||||
|
||||
_quota_warning.html is deliberately NOT included here: it is a
|
||||
per-form include (user_form, issue form, facility form,
|
||||
inspection start), not layout chrome. Including it globally would
|
||||
render it twice on exactly those pages. #}
|
||||
{% include 'billing/_billing_banner.html' %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<script>
|
||||
// ── Sidebar off-canvas toggle (mobile / tablet portrait) ──────────────
|
||||
(function () {
|
||||
var btn = document.getElementById('jqcSidebarToggle');
|
||||
var sidebar = document.getElementById('jqcSidebar');
|
||||
var backdrop = document.getElementById('jqcSidebarBackdrop');
|
||||
if (!btn || !sidebar) return;
|
||||
function close() {
|
||||
sidebar.classList.remove('open');
|
||||
if (backdrop) backdrop.classList.remove('show');
|
||||
}
|
||||
btn.addEventListener('click', function () {
|
||||
sidebar.classList.toggle('open');
|
||||
if (backdrop) backdrop.classList.toggle('show');
|
||||
});
|
||||
if (backdrop) backdrop.addEventListener('click', close);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||
const MARK_READ_BASE = '/notifications/';
|
||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
const POLL_INTERVAL = 60000; // 60 seconds
|
||||
|
||||
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||
const listDesktop = document.getElementById('notif-list');
|
||||
|
||||
function updateBadge(count) {
|
||||
[badgeDesktop].forEach(function(badge) {
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.classList.remove('d-none');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderInto(container, notifications) {
|
||||
if (!container) return;
|
||||
if (!notifications.length) {
|
||||
container.innerHTML = '<div class="notif-empty">'
|
||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = notifications.map(function(n) {
|
||||
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||
+ (n.is_read ? '' : 'unread') + '"'
|
||||
+ ' data-notif-id="' + n.id + '"'
|
||||
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
var id = this.dataset.notifId;
|
||||
var link = this.dataset.link;
|
||||
markRead(id, function() {
|
||||
el.classList.remove('unread');
|
||||
if (link) window.location.href = link;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(notifications) {
|
||||
renderInto(listDesktop, notifications);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escapeAttr(str) { return escapeHtml(str); }
|
||||
|
||||
window.fetchNotifications = function fetchNotifications() {
|
||||
fetch(FEED_URL, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
updateBadge(data.unread_count);
|
||||
window._jqcNotifications = data.notifications;
|
||||
var deskEl = document.getElementById('notifDropdown');
|
||||
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
|
||||
if (deskOpen) {
|
||||
renderNotifications(data.notifications);
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
function markRead(id, callback) {
|
||||
fetch(MARK_READ_BASE + id + '/mark-read', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() { if (callback) callback(); fetchNotifications(); })
|
||||
.catch(function() { if (callback) callback(); });
|
||||
}
|
||||
|
||||
['notifDropdown'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('show.bs.dropdown', function() {
|
||||
if (window._jqcNotifications) {
|
||||
renderNotifications(window._jqcNotifications);
|
||||
} else {
|
||||
fetchNotifications();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
fetch(MARK_ALL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': CSRF_TOKEN,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
updateBadge(0);
|
||||
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
|
||||
el.classList.remove('unread');
|
||||
});
|
||||
if (window._jqcNotifications) {
|
||||
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
});
|
||||
});
|
||||
|
||||
fetchNotifications();
|
||||
setInterval(fetchNotifications, POLL_INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,418 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{#
|
||||
MODERN dashboard (design A/B test — slide 1 of JQC_design).
|
||||
|
||||
Uses exactly the same context variables as templates/dashboard.html — the
|
||||
dashboard.index route is untouched. Every tile links to the same filtered
|
||||
list view the classic dashboard links to, so no navigation path is lost.
|
||||
#}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ── Header ───────────────────────────────────────────────────────────── #}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
||||
<div>
|
||||
<div class="jqc-page-title">Welcome, {{ current_user.display_name }}</div>
|
||||
<div class="jqc-page-sub">{{ current_user.role.replace('_',' ')|title }}</div>
|
||||
</div>
|
||||
<div class="text-muted">{{ now_display }}</div>
|
||||
</div>
|
||||
|
||||
{# ── Scheduled inspections ────────────────────────────────────────────── #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="jqc-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="jqc-card-title mb-0">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-calendar2-week"></i></span>Scheduled Inspection In Progress
|
||||
</div>
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||
</div>
|
||||
|
||||
{% if sched_overdue_count %}
|
||||
<div class="alert alert-danger py-2">
|
||||
<i class="bi bi-alarm-fill me-1"></i>
|
||||
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
||||
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sched_upcoming %}
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Facility</th><th>Inspection Template</th><th>Inspector</th>
|
||||
<th>How Often</th><th>Next Due Date</th><th class="text-end"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sched_upcoming %}
|
||||
<tr>
|
||||
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
||||
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
||||
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||
<td class="small text-muted">{{ s.recurrence_label }}</td>
|
||||
<td class="small">{{ s.next_due_date.strftime('%b %d, %Y') }}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if s.inspector_id and s.inspector_id == current_user.id %}
|
||||
{% if s.is_acknowledged %}
|
||||
<span class="badge bg-success" title="You confirmed receipt"><i class="bi bi-check-circle"></i> Confirmed</span>
|
||||
{% else %}
|
||||
<form method="POST" class="d-inline" action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success py-0"
|
||||
title="Confirm you received this request">
|
||||
<i class="bi bi-check-lg"></i> Confirm</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% set open_id = sched_open_inspections.get(s.id) %}
|
||||
{% if open_id %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
||||
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
|
||||
<i class="bi bi-pencil-square"></i> Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small"><i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── KPI row ──────────────────────────────────────────────────────────── #}
|
||||
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
|
||||
<div class="col">
|
||||
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>
|
||||
<div class="jqc-kpi-value">{{ completed_today }}</div>
|
||||
<div class="jqc-kpi-label">Submitted Today</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-calendar-week"></i></span>
|
||||
<div class="jqc-kpi-value">{{ submitted_this_week }}</div>
|
||||
<div class="jqc-kpi-label">Submitted This Week</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
<a class="jqc-kpi" href="{{ url_for('issues.index', status='open') }}">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>
|
||||
<div class="jqc-kpi-value">{{ open_issues }}</div>
|
||||
<div class="jqc-kpi-label">Open Issues</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a class="jqc-kpi" href="{{ url_for('inspection_schedules.index') }}">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-calendar2-check"></i></span>
|
||||
<div class="jqc-kpi-value">{{ sched_total }}</div>
|
||||
<div class="jqc-kpi-label">On Schedules</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="jqc-kpi" href="{{ url_for('facilities.list_facilities') }}">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>
|
||||
<div class="jqc-kpi-value">{{ customer_facilities|length if customer_facilities else 0 }}</div>
|
||||
<div class="jqc-kpi-label">Your Facilities</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Three summary cards ──────────────────────────────────────────────── #}
|
||||
<div class="row g-3 mb-2">
|
||||
|
||||
<!-- Inspection -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>Inspection
|
||||
</div>
|
||||
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
|
||||
<span class="jqc-stat-label">Submitted Today</span>
|
||||
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ completed_today }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
|
||||
<span class="jqc-stat-label">Submitted This Week</span>
|
||||
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ submitted_this_week }}</span>
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='in_progress') }}">
|
||||
<span class="jqc-stat-label">
|
||||
In Process
|
||||
{% if stale_in_progress %}<span class="badge bg-warning text-dark">{{ stale_in_progress }} stale</span>{% endif %}
|
||||
</span>
|
||||
<span class="jqc-stat-value" style="color:#1B9AD1;">{{ in_progress_total }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='follow_up') }}">
|
||||
<span class="jqc-stat-label">Pending to follow up</span>
|
||||
<span class="jqc-stat-value" style="color:#E0A800;">{{ pending_followups }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Issues -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>Open Issues
|
||||
</div>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='internal') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E8722C;"></span>Janitorial</span>
|
||||
<span class="jqc-stat-value">{{ handler_breakdown.internal }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='facility') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#155F82;"></span>Facility Staff</span>
|
||||
<span class="jqc-stat-value">{{ handler_breakdown.facility }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='vendor') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Vendors</span>
|
||||
<span class="jqc-stat-value">{{ handler_breakdown.vendor }}</span>
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='pending_verification') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>Pending Verification</span>
|
||||
<span class="jqc-stat-value">{{ pending_verification }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', unassigned='1') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>Unassigned Issue</span>
|
||||
<span class="jqc-stat-value">{{ unassigned_open }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA Issues -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>SLA Issues
|
||||
</div>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='breached') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>SLA Alert</span>
|
||||
<span class="jqc-stat-value" style="color:#D9534F;">{{ sla_breached }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='at_risk') }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>SLA At Risk</span>
|
||||
<span class="jqc-stat-value" style="color:#E0A800;">{{ sla_at_risk }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Issues Opened Today</span>
|
||||
<span class="jqc-stat-value">{{ issues_opened_today }}</span>
|
||||
</a>
|
||||
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}">
|
||||
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#2E7D4F;"></span>Resolved Today</span>
|
||||
<span class="jqc-stat-value" style="color:#2E7D4F;">{{ resolved_today }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Recent activity ──────────────────────────────────────────────────── #}
|
||||
<div class="jqc-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="jqc-card-title mb-0">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>Recent Activities
|
||||
</div>
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||
</div>
|
||||
{% if recent_inspections %}
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Facility Name</th>
|
||||
<th>Area</th>
|
||||
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for insp in recent_inspections %}
|
||||
{# The row stays click-anywhere for the mouse, but the date is a real
|
||||
link so the row is keyboard-reachable and openable in a new tab.
|
||||
The guard stops the row handler from double-firing on that link. #}
|
||||
<tr style="cursor:pointer;"
|
||||
onclick="if(!event.target.closest('a')) window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
|
||||
<td>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
|
||||
class="text-decoration-none"><small>{{ insp.inspection_date.strftime('%b %d, %Y') }}</small></a>
|
||||
</td>
|
||||
<td>{{ insp.facility.name }}</td>
|
||||
<td>{{ insp.area.name if insp.area else '—' }}</td>
|
||||
{% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
|
||||
<td>
|
||||
{% if insp.overall_score %}
|
||||
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
|
||||
{{ insp.overall_score }}%
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
|
||||
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── My open issues (inspector widget) ────────────────────────────────── #}
|
||||
{% if my_issues %}
|
||||
<div class="jqc-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="jqc-card-title mb-0">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-person-check"></i></span>My Open Issues
|
||||
</div>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||
</div>
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">ID</th>
|
||||
<th style="width:90px;">Severity</th>
|
||||
<th>Facility / Description</th>
|
||||
<th style="width:100px;">Status</th>
|
||||
<th style="width:120px;">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in my_issues %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td>
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
|
||||
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if sla == 'breached' %}
|
||||
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||
{% elif sla == 'at_risk' %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">OK</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Customer portal: scoped facilities panel ─────────────────────────── #}
|
||||
{% if current_user.role == 'customer' and customer_facilities %}
|
||||
<div class="jqc-card">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-building"></i></span>Your Facilities
|
||||
<span class="badge bg-secondary rounded-pill ms-2">{{ customer_facilities|length }}</span>
|
||||
</div>
|
||||
{% if customer_facilities|length > 6 %}
|
||||
<div class="mb-3">
|
||||
<input type="text" id="facilitySearch" class="form-control form-control-sm"
|
||||
placeholder="Search facilities…" aria-label="Search facilities">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row g-3" id="facilityGrid">
|
||||
{% for f in customer_facilities %}
|
||||
<div class="col-12 col-sm-6 col-lg-4 facility-col">
|
||||
<div class="border rounded-3 p-3 h-100 d-flex flex-column facility-card">
|
||||
<div class="fw-semibold mb-1">{{ f.name }}</div>
|
||||
<div class="text-muted" style="font-size:.82rem;">{{ f.address or '—' }}</div>
|
||||
<div class="my-2">
|
||||
<span class="badge bg-light text-dark border">{{ f.project.name if f.project else '—' }}</span>
|
||||
</div>
|
||||
<div class="mt-auto pt-1">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary ms-1"><i class="bi bi-graph-up"></i> Report</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if customer_facilities|length > 9 %}
|
||||
<div id="facilityShowMore" class="text-center mt-3">
|
||||
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
|
||||
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Same VISIBLE = 9 / search > 6 thresholds as the classic dashboard (rule 65). #}
|
||||
<script>
|
||||
(function () {
|
||||
{% if customer_facilities|length > 9 %}
|
||||
var VISIBLE = 9;
|
||||
var cols = document.querySelectorAll('#facilityGrid .facility-col');
|
||||
var btn = document.getElementById('toggleFacilities');
|
||||
var expanded = false;
|
||||
|
||||
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
expanded = !expanded;
|
||||
cols.forEach(function (c, i) {
|
||||
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
|
||||
});
|
||||
btn.innerHTML = expanded
|
||||
? 'Show fewer <i class="bi bi-chevron-up"></i>'
|
||||
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
|
||||
});
|
||||
{% endif %}
|
||||
{% if customer_facilities|length > 6 %}
|
||||
document.getElementById('facilitySearch').addEventListener('input', function () {
|
||||
var q = this.value.toLowerCase();
|
||||
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
|
||||
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
|
||||
col.style.display = match ? '' : 'none';
|
||||
});
|
||||
var more = document.getElementById('facilityShowMore');
|
||||
if (more) more.style.display = this.value ? 'none' : '';
|
||||
});
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,271 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Facilities{% endblock %}
|
||||
|
||||
{#
|
||||
MODERN facilities page (design A/B test — slide 5 of JQC_design).
|
||||
|
||||
The four hub cards are new; everything below them is the original grouped
|
||||
facility list, delete modal and JS, unchanged — no functionality removed.
|
||||
#}
|
||||
|
||||
{% block content %}
|
||||
<div class="jqc-page-head center">
|
||||
<div class="jqc-page-title">Facilities</div>
|
||||
<div class="jqc-page-sub text-center">Manage facility records, statistics and QR access</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
{% if not current_user.is_inspector %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('facilities.qr_print_all') }}">
|
||||
<div class="d-flex gap-4 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-qr-code"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Print QR Code</div>
|
||||
<div class="jqc-hub-text">Generate and print scannable QR codes for every facility entrance and asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="#facility-list">
|
||||
<div class="d-flex gap-4 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-buildings"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Facilities Information</div>
|
||||
<div class="jqc-hub-text">View addresses, contacts, contracts and service details in one place.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('reports.index') }}">
|
||||
<div class="d-flex gap-4 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-pie-chart"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Facilities Statistics</div>
|
||||
<div class="jqc-hub-text">Track inspection scores and issue trends by location over time.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('templates.index') }}">
|
||||
<div class="d-flex gap-4 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-gear"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Customize</div>
|
||||
<div class="jqc-hub-text">Configure inspection templates, checklist items and scoring for your facilities.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Original facility list (unchanged) ───────────────────────────────── #}
|
||||
<div id="facility-list" class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||
<h2 class="mb-0" style="font-size:1.4rem;font-weight:800;">
|
||||
<i class="bi bi-building"></i> All Facilities
|
||||
</h2>
|
||||
<div>
|
||||
{% if not current_user.is_inspector %}
|
||||
<a href="{{ url_for('facilities.qr_print_all') }}"
|
||||
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
|
||||
<i class="bi bi-qr-code"></i> Print All QR Codes
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Facility
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if grouped %}
|
||||
{% for group_key, group in grouped.items() %}
|
||||
{# ── Contract group header ────────────────────────────────────────────── #}
|
||||
{% set collapse_id = 'contract-' ~ loop.index %}
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<button class="btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2 fw-semibold fs-5"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ collapse_id }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ collapse_id }}">
|
||||
<i class="bi bi-chevron-down contract-chevron" style="transition: transform .2s; transform: rotate(-90deg);"></i>
|
||||
{% if group.project %}
|
||||
<i class="bi bi-briefcase text-primary"></i>
|
||||
{{ group.project.name }}
|
||||
{% else %}
|
||||
<i class="bi bi-dash-circle text-secondary"></i>
|
||||
<span class="text-secondary">No Contract Assigned</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary ms-2"
|
||||
title="View Contract">
|
||||
<i class="bi bi-arrow-right-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Collapsible card grid ─────────────────────────────────────────── #}
|
||||
<div class="collapse" id="{{ collapse_id }}">
|
||||
<div class="row">
|
||||
{% for facility in group.facilities %}
|
||||
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="mb-1" style="font-size:.875rem;font-weight:600;line-height:1.3;">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="text-decoration-none">
|
||||
{{ facility.name }}
|
||||
</a>
|
||||
{% if not facility.active %}
|
||||
<span class="badge bg-secondary" style="font-size:.7rem;">Inactive</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if facility.address %}
|
||||
<p class="card-text text-muted mb-1" style="font-size:.78rem;">
|
||||
<i class="bi bi-geo-alt"></i> {{ facility.address }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-1">
|
||||
<small class="text-muted" style="font-size:.78rem;">
|
||||
<i class="bi bi-diagram-3"></i> {{ facility.areas.count() }} areas
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent d-flex gap-2 py-2 px-3">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View Details
|
||||
</a>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger ms-auto"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal"
|
||||
data-facility-id="{{ facility.id }}"
|
||||
data-facility-name="{{ facility.name }}"
|
||||
data-inspection-count="{{ facility.inspections.count() }}">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> No facilities configured yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to permanently delete:</p>
|
||||
<p class="fw-bold fs-5" id="modalFacilityName"></p>
|
||||
<div id="modalWarningBlock" class="alert alert-danger d-none">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
<strong>Cannot delete this facility.</strong> It has existing inspection records.
|
||||
Please remove all associated inspections first.
|
||||
</div>
|
||||
<div id="modalConfirmBlock">
|
||||
<p class="text-muted mb-0">This action is <strong>irreversible</strong>. All areas associated with this facility will also be deleted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteFacilityForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
|
||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ── Rotate chevron on collapse toggle ────────────────────────────────
|
||||
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (btn) {
|
||||
const target = document.querySelector(btn.getAttribute('data-bs-target'));
|
||||
if (!target) return;
|
||||
const chevron = btn.querySelector('.contract-chevron');
|
||||
|
||||
target.addEventListener('hide.bs.collapse', function () {
|
||||
if (chevron) chevron.style.transform = 'rotate(-90deg)';
|
||||
});
|
||||
target.addEventListener('show.bs.collapse', function () {
|
||||
if (chevron) chevron.style.transform = 'rotate(0deg)';
|
||||
});
|
||||
});
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
// ── Delete modal wiring ──────────────────────────────────────────────
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const facilityId = button.getAttribute('data-facility-id');
|
||||
const facilityName = button.getAttribute('data-facility-name');
|
||||
const inspectionCount = parseInt(button.getAttribute('data-inspection-count'));
|
||||
|
||||
document.getElementById('modalFacilityName').textContent = facilityName;
|
||||
document.getElementById('deleteFacilityForm').action = '/facilities/' + facilityId + '/delete';
|
||||
|
||||
const warningBlock = document.getElementById('modalWarningBlock');
|
||||
const confirmBlock = document.getElementById('modalConfirmBlock');
|
||||
const confirmBtn = document.getElementById('confirmDeleteBtn');
|
||||
|
||||
if (inspectionCount > 0) {
|
||||
warningBlock.classList.remove('d-none');
|
||||
confirmBlock.classList.add('d-none');
|
||||
confirmBtn.disabled = true;
|
||||
} else {
|
||||
warningBlock.classList.add('d-none');
|
||||
confirmBlock.classList.remove('d-none');
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,362 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspections{% endblock %}
|
||||
|
||||
{#
|
||||
MODERN inspections list (design A/B test).
|
||||
|
||||
Same context variables, same query params, same form field names and the same
|
||||
three JS blocks as templates/inspections/list.html — only the chrome differs.
|
||||
Nothing was dropped: every filter, column, badge, the pagination links and the
|
||||
delete modal are carried over verbatim. `insp-list-link` is preserved on the
|
||||
View/Continue buttons so filter-state restore on Back still works.
|
||||
#}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Any non-empty query param other than the page number means the user has
|
||||
actually filtered — used to show the match count only when it is meaningful. #}
|
||||
{% set active_filters = [] %}
|
||||
{% for _k, _v in request.args.items() %}
|
||||
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# ── Header ───────────────────────────────────────────────────────────── #}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
||||
<div>
|
||||
<div class="jqc-page-title">Inspections</div>
|
||||
</div>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
||||
{# Layout: fields fill two rows on the left; the actions sit in a block on the
|
||||
right that spans both rows — Filter full-height, Clear above Export PDF.
|
||||
Below the md breakpoint the action block wraps underneath, full width. #}
|
||||
<div class="jqc-filter-bar">
|
||||
<form method="get">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-stretch">
|
||||
|
||||
{# ── Fields ──────────────────────────────────────────────────────── #}
|
||||
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Inspection #</label>
|
||||
<input type="number" name="inspection_id" class="form-control form-control-sm"
|
||||
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
{% for s in ['in_progress','completed','flagged'] %}
|
||||
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
|
||||
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small mb-1">Contract</label>
|
||||
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
|
||||
<option value="">All Contracts</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Second row. The score fields absorb the Inspector column's width when
|
||||
the viewer is an inspector (they only ever see their own work, so the
|
||||
dropdown is not rendered for them) — the row always totals 12. #}
|
||||
<div class="row g-2 align-items-end mt-2">
|
||||
{% if inspectors %}
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small mb-1">Inspector</label>
|
||||
<select name="inspector_id" class="form-select form-select-sm">
|
||||
<option value="">All Inspectors</option>
|
||||
{% for u in inspectors %}
|
||||
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
|
||||
<label class="form-label small mb-1">Date From</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ date_from_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
|
||||
<label class="form-label small mb-1">Date To</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ date_to_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
|
||||
<label class="form-label small mb-1">Min Score</label>
|
||||
<input type="number" name="score_min" class="form-control form-control-sm"
|
||||
min="0" max="100" placeholder="0" value="{{ score_min_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
|
||||
<label class="form-label small mb-1">Max Score</label>
|
||||
<input type="number" name="score_max" class="form-control form-control-sm"
|
||||
min="0" max="100" placeholder="100" value="{{ score_max_filter }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Actions — spans both field rows ─────────────────────────────── #}
|
||||
{# mt-md-4 drops the block by roughly one label's height, so the buttons
|
||||
line up with the first row's INPUTS rather than its labels — that is
|
||||
what makes them shorter, since they still stretch to the bottom of the
|
||||
second row. The margin is md-only; below that the block wraps
|
||||
underneath the fields and needs its full width and natural height. #}
|
||||
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
|
||||
style="min-width:88px;">
|
||||
<span><i class="bi bi-funnel"></i> Filter</span>
|
||||
</button>
|
||||
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
|
||||
<a href="{{ url_for('inspections.index') }}"
|
||||
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
|
||||
style="min-width:104px;">Clear</a>
|
||||
<a id="exportPdfBtn"
|
||||
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
|
||||
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
|
||||
style="min-width:104px;">
|
||||
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Results ──────────────────────────────────────────────────────────── #}
|
||||
<div class="jqc-card">
|
||||
{% if active_filters %}
|
||||
<div class="text-muted small mb-2">
|
||||
<i class="bi bi-funnel me-1"></i>
|
||||
{{ inspections.total }} inspection{{ 's' if inspections.total != 1 }} match
|
||||
{{ 'es' if inspections.total == 1 }} your filters
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if inspections.items %}
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||
<th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||
<td>{{ ins.facility.name }}</td>
|
||||
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td>
|
||||
{{ ins.template.name }}
|
||||
{% if ins.scheduled_inspection_id %}
|
||||
<span class="badge bg-info text-dark ms-1" title="From a scheduled inspection">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ ins.inspector.display_name }}</td>
|
||||
<td>
|
||||
{% if ins.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
|
||||
{{ ins.overall_score }}%
|
||||
</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
||||
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
{% if ins.status == 'in_progress' %}
|
||||
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
|
||||
{% if hours_open > 24 %}
|
||||
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
|
||||
<i class="bi bi-clock-history"></i> Stale
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if ins.follow_up_required and not ins.follow_ups.count() %}
|
||||
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
|
||||
<i class="bi bi-arrow-repeat"></i> Follow-up
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger ms-1"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteInspectionModal"
|
||||
data-inspection-id="{{ ins.id }}"
|
||||
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
|
||||
title="Delete inspection">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{% if inspections.pages > 1 %}
|
||||
<div class="d-flex justify-content-center pt-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == inspections.page }}">
|
||||
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-clipboard-x fs-2 d-block mb-2"></i>
|
||||
No inspections found.
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<!-- Delete Inspection Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteInspectionModalLabel">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to permanently delete the following inspection:</p>
|
||||
<p class="fw-bold" id="deleteInspectionLabel"></p>
|
||||
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
// Save current filtered URL so view/execute pages can restore it on Back
|
||||
var links = document.querySelectorAll('.insp-list-link');
|
||||
links.forEach(function (a) {
|
||||
a.addEventListener('click', function () {
|
||||
sessionStorage.setItem('insp_list_back_url', window.location.href);
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var contractSel = document.getElementById('insp_filter_contract');
|
||||
var facilitySel = document.getElementById('insp_filter_facility');
|
||||
if (!contractSel || !facilitySel) return;
|
||||
|
||||
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||
|
||||
contractSel.addEventListener('change', function () {
|
||||
var projectId = this.value;
|
||||
facilitySel.value = '';
|
||||
if (!projectId) {
|
||||
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
||||
return;
|
||||
}
|
||||
facilitySel.disabled = true;
|
||||
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var html = '<option value="">All Facilities</option>';
|
||||
data.forEach(function (f) {
|
||||
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
||||
});
|
||||
facilitySel.innerHTML = html;
|
||||
facilitySel.disabled = false;
|
||||
})
|
||||
.catch(function () { facilitySel.disabled = false; });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modal = document.getElementById('deleteInspectionModal');
|
||||
modal.addEventListener('show.bs.modal', function (event) {
|
||||
const btn = event.relatedTarget;
|
||||
const id = btn.getAttribute('data-inspection-id');
|
||||
const label = btn.getAttribute('data-inspection-label');
|
||||
document.getElementById('deleteInspectionLabel').textContent = label;
|
||||
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,408 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issues{% endblock %}
|
||||
|
||||
{#
|
||||
MODERN issues list (design A/B test).
|
||||
|
||||
Same context variables, query params, form field names and JS as
|
||||
templates/issues/list.html — only the chrome differs. Every filter, column,
|
||||
badge, the quick-assign control, follow/unfollow, delete and the pagination
|
||||
links are carried over verbatim.
|
||||
#}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Any non-empty query param other than the page number means the user has
|
||||
actually filtered — used to show the match count only when it is meaningful. #}
|
||||
{% set active_filters = [] %}
|
||||
{% for _k, _v in request.args.items() %}
|
||||
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# ── Header ───────────────────────────────────────────────────────────── #}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
||||
<div>
|
||||
<div class="jqc-page-title">Issues</div>
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Log Issue
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
||||
{# Same layout as the modern inspections list: fields fill two rows on the left,
|
||||
actions in a block on the right spanning both — Filter full-height, Clear
|
||||
above Export PDF. Contract and Facility stay adjacent because they cascade
|
||||
(rule 61). Below md the action block wraps underneath, full width. #}
|
||||
<div class="jqc-filter-bar">
|
||||
<form method="get">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-stretch">
|
||||
|
||||
{# ── Fields ──────────────────────────────────────────────────────── #}
|
||||
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Issue #</label>
|
||||
<input type="number" name="issue_id" class="form-control form-control-sm"
|
||||
min="1" placeholder="ID" value="{{ issue_id_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Severity</label>
|
||||
<select name="severity" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['critical','high','medium','low'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['open','in_progress','pending_verification','resolved'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small mb-1">Contract</label>
|
||||
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
|
||||
<option value="">All Contracts</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end mt-2">
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">SLA</label>
|
||||
<select name="sla" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
|
||||
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
|
||||
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small mb-1">Reported From</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ date_from_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small mb-1">Reported To</label>
|
||||
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ date_to_filter }}">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Reporter</label>
|
||||
<select name="reporter_id" class="form-select form-select-sm">
|
||||
<option value="">All Reporters</option>
|
||||
{% for u in reporters %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small mb-1">Handled By</label>
|
||||
<select name="handler_type" class="form-select form-select-sm">
|
||||
<option value="">All Handlers</option>
|
||||
<option value="internal" {{ 'selected' if handler_filter == 'internal' }}>Janitorial Staff</option>
|
||||
<option value="facility" {{ 'selected' if handler_filter == 'facility' }}>Facility Staff</option>
|
||||
<option value="vendor" {{ 'selected' if handler_filter == 'vendor' }}>External Vendor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Actions — spans both field rows ─────────────────────────────── #}
|
||||
{# mt-md-4 drops the block by roughly one label's height, so the buttons
|
||||
line up with the first row's INPUTS rather than its labels — that is
|
||||
what makes them shorter, since they still stretch to the bottom of the
|
||||
second row. The margin is md-only; below that the block wraps
|
||||
underneath the fields and needs its full width and natural height. #}
|
||||
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
|
||||
style="min-width:88px;">
|
||||
<span><i class="bi bi-funnel"></i> Filter</span>
|
||||
</button>
|
||||
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
|
||||
<a href="{{ url_for('issues.index') }}"
|
||||
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
|
||||
style="min-width:104px;">Clear</a>
|
||||
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
|
||||
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
|
||||
style="min-width:104px;">
|
||||
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Results ──────────────────────────────────────────────────────────── #}
|
||||
<div class="jqc-card">
|
||||
{% if active_filters %}
|
||||
<div class="text-muted small mb-2">
|
||||
<i class="bi bi-funnel me-1"></i>
|
||||
{{ issues.total }} issue{{ 's' if issues.total != 1 }} match
|
||||
{{ 'es' if issues.total == 1 }} your filters
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if issues.items %}
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Reported</th>
|
||||
<th>Severity</th>
|
||||
<th>Contract</th>
|
||||
<th>Facility / Area</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>SLA</th>
|
||||
<th>Reporter</th>
|
||||
<th>Assigned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues.items %}
|
||||
{% set is_following = issue.id in followed_ids %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
|
||||
<small>{{ _c.name if _c else '—' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
|
||||
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span{% if issue.description|length > 60 %} title="{{ issue.description }}" style="cursor:help;"{% endif %}>
|
||||
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if sla == 'breached' %}
|
||||
<span class="badge bg-danger" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||
{% elif sla == 'at_risk' %}
|
||||
{% set hrs = sla_hours_remaining(issue) %}
|
||||
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
|
||||
{% elif sla == 'ok' %}
|
||||
<span class="badge bg-secondary">OK</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if issue.reporter %}
|
||||
<small>{{ issue.reporter.display_name }}</small>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
||||
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
{% for u in staff %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
{% endif %}
|
||||
{% if issue.handler_type == 'facility' %}
|
||||
<div><span class="badge bg-info text-dark mt-1" title="Handled by facility staff"><i class="bi bi-building"></i> Facility</span></div>
|
||||
{% elif issue.handler_type == 'vendor' %}
|
||||
<div><span class="badge bg-warning text-dark mt-1" title="Handled by external vendor"><i class="bi bi-person-gear"></i> Vendor</span></div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{# Following badge + inline unfollow #}
|
||||
{% if is_following %}
|
||||
<span class="badge bg-primary me-1" title="You are following this issue">
|
||||
<i class="bi bi-bell-fill"></i> Following
|
||||
</span>
|
||||
<form method="post"
|
||||
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
|
||||
class="d-inline"
|
||||
title="Unfollow this issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||
title="Unfollow">
|
||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
{% else %}
|
||||
<i class="bi bi-eye"></i> View
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Delete Issue #{{ issue.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if issues.pages > 1 %}
|
||||
<div class="d-flex justify-content-center pt-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == issues.page }}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-check2-circle fs-2 d-block mb-2"></i>
|
||||
No issues found.
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var contractSel = document.getElementById('filter_contract_id');
|
||||
var facilitySel = document.getElementById('filter_facility_id');
|
||||
if (!contractSel || !facilitySel) return;
|
||||
|
||||
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||
|
||||
contractSel.addEventListener('change', function () {
|
||||
var projectId = this.value;
|
||||
facilitySel.value = ''; // reset facility selection
|
||||
if (!projectId) {
|
||||
// No contract selected — restore all-facilities placeholder and submit
|
||||
// (server will return unfiltered facility list)
|
||||
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
||||
return;
|
||||
}
|
||||
facilitySel.disabled = true;
|
||||
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var html = '<option value="">All Facilities</option>';
|
||||
data.forEach(function (f) {
|
||||
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
||||
});
|
||||
facilitySel.innerHTML = html;
|
||||
facilitySel.disabled = false;
|
||||
})
|
||||
.catch(function () { facilitySel.disabled = false; });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
|
||||
sel.dataset.previous = sel.value;
|
||||
|
||||
sel.addEventListener('change', function () {
|
||||
const wrap = sel.closest('.quick-assign-wrap');
|
||||
const issueId = wrap.dataset.issueId;
|
||||
const spinner = wrap.querySelector('.quick-assign-spinner');
|
||||
const userId = sel.value || null;
|
||||
|
||||
sel.disabled = true;
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
fetch('/issues/' + issueId + '/quick-assign', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) {
|
||||
alert('Assignment failed: ' + (data.error || 'Unknown error'));
|
||||
sel.value = sel.dataset.previous;
|
||||
} else {
|
||||
sel.dataset.previous = sel.value;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
alert('Network error — assignment not saved.');
|
||||
sel.value = sel.dataset.previous;
|
||||
})
|
||||
.finally(function () {
|
||||
sel.disabled = false;
|
||||
spinner.classList.add('d-none');
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}About{% endblock %}
|
||||
|
||||
{# About — linked from the modern sidebar.
|
||||
|
||||
MT note: ST's version of this page hardcodes its own company name and links
|
||||
the `enrollment` blueprint, neither of which is portable here. This version
|
||||
is tenant-neutral: the workspace name comes from tenant_branding (the same
|
||||
source the layouts use), and "add people" points at the Users admin page,
|
||||
which is how accounts are actually created in MT. #}
|
||||
|
||||
{% set workspace_name = tenant_branding.display_name if tenant_branding else 'this workspace' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="jqc-page-head center">
|
||||
<div class="jqc-page-title">About</div>
|
||||
<div class="jqc-page-sub text-center">
|
||||
Janitorial Quality Control{% if tenant_branding %} — {{ tenant_branding.display_name }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>{{ workspace_name }}
|
||||
</div>
|
||||
<p class="mb-3">
|
||||
Quality is verified in the field, not assumed. Every contract is backed by
|
||||
scheduled inspections, documented findings and tracked resolution, so what
|
||||
was checked, when, and by whom is always on the record.
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
JQC is the quality control platform behind that work. Inspectors work from an
|
||||
offline-capable iPad app; managers, contract staff and customers work from
|
||||
this web portal. Both share one record of every inspection, issue and photo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-shield-check"></i></span>Your data
|
||||
</div>
|
||||
<p class="mb-0 text-muted">
|
||||
{{ workspace_name }} has its own isolated database. Inspections, issues,
|
||||
photos and accounts belong to this workspace alone and are never shared
|
||||
with, or visible to, any other organisation using JQC.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('auth.list_users') }}">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-person-plus"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Add More People</div>
|
||||
<div class="jqc-hub-text">
|
||||
Create accounts for colleagues and set what each person can do.
|
||||
External inspectors are invited by email and choose their own password.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('tenant_settings.branding') }}">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-palette"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Workspace Settings</div>
|
||||
<div class="jqc-hub-text">
|
||||
Set the workspace name, logo and colours used across the portal and
|
||||
in outbound email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12">
|
||||
<div class="jqc-card">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact & Support
|
||||
</div>
|
||||
<p class="mb-3">
|
||||
Questions about an inspection, an issue on a site, or access to the portal —
|
||||
start in the Support Center and it will be routed to the right person.
|
||||
</p>
|
||||
<a href="{{ url_for('ui.support_center') }}" class="btn btn-primary">
|
||||
<i class="bi bi-life-preserver me-1"></i>Go to Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,150 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Support{% endblock %}
|
||||
|
||||
{#
|
||||
Support Center — new page (design A/B test, slide 6 of JQC_design).
|
||||
Linked from the modern sidebar. Every card either opens an existing route or
|
||||
expands an inline how-to, so nothing here dead-ends.
|
||||
#}
|
||||
|
||||
{% block content %}
|
||||
<div class="jqc-page-head center">
|
||||
<div class="jqc-page-title">Support</div>
|
||||
<div class="jqc-page-sub text-center">JQC Features — find answers and how-to guides</div>
|
||||
</div>
|
||||
|
||||
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
|
||||
<div class="row g-3 mb-4">
|
||||
{% if current_user.role == 'customer' %}
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-chat-dots"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Ask a Question</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.my_conversations') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">My Conversations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.my_tickets') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">My Requests</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% elif current_user.role in ['admin', 'director'] %}
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.admin_tickets') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Support Requests</div>
|
||||
</div>
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<div class="jqc-hub-text">{{ open_support_tickets_count }} open</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.admin_conversations') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-chat-square-text"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Chat Conversations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.admin_knowledge') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-journal-text"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Knowledge Base</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── How-to guides ─────────────────────────────────────────────────────── #}
|
||||
{% set guides = [
|
||||
('bi-clipboard-check', 'How to create a new inspection',
|
||||
'Inspections → New Inspection. Pick the contract, facility, area and template, then Start. The checklist opens straight away and saves as you go — you can leave and resume from Inspections → In Progress.'),
|
||||
('bi-search', 'How to follow up on an inspection',
|
||||
'Open the inspection and use Request Follow-up. It moves to the Follow-up list, notifies the inspector, and stays there until a re-inspection is submitted against it.'),
|
||||
('bi-calendar2-week', 'How to schedule an inspection',
|
||||
'Inspections → Schedule. Choose facility, template, inspector and how often it repeats. Recurring schedules roll their due date forward automatically once the inspection is submitted.'),
|
||||
('bi-exclamation-triangle', 'How to Flag For Attention',
|
||||
'While executing an inspection, use Flag Issue on any failing item. Set severity and who handles it (janitorial crew, facility staff or an outside vendor) — the SLA clock starts from that moment.'),
|
||||
('bi-qr-code', 'How QR code works',
|
||||
"Every facility and area has a QR code. Scanning it opens that location's public page — anyone on site can report a problem without an account, and the request lands in Issues."),
|
||||
('bi-file-earmark-text','Send a request without the app',
|
||||
'Point the on-site contact at the facility QR code, or forward them the public facility link. Their submission arrives as an unassigned issue for triage.'),
|
||||
('bi-search', 'How to search',
|
||||
'Use the search box in the top bar for an inspection number. For anything broader, each list page has filters for contract, facility, inspector, status, date range and score.'),
|
||||
('bi-chat-dots', 'How to comment',
|
||||
'Open any inspection or issue and use the comment box at the bottom. Comments are visible to staff; sharing one with the customer is an explicit choice on the comment itself.'),
|
||||
('bi-bar-chart', 'Create & print inspection reports',
|
||||
'Reports & Analytics → filter by date, contract, facility or inspector → Apply. Export to CSV, or use the PDF export on an individual inspection or facility scorecard.'),
|
||||
('bi-clock', 'What does SLA At Risk mean?',
|
||||
'The issue is approaching its resolution deadline for its severity but has not passed it yet. Treat it as the last window to close the issue on time.'),
|
||||
('bi-alarm', 'What does an SLA Alert mean?',
|
||||
'The issue has passed its resolution deadline for its severity. It stays flagged until resolved and shows on the dashboard SLA card.'),
|
||||
] %}
|
||||
|
||||
<div class="row g-3">
|
||||
{% for icon, title, body in guides %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="jqc-hub-card" role="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#guide{{ loop.index }}" aria-expanded="false">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi {{ icon }}"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.02rem;">{{ title }}</div>
|
||||
</div>
|
||||
<div class="collapse" id="guide{{ loop.index }}">
|
||||
<div class="jqc-hub-text mt-3">{{ body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# The AI chat is customer-only (support.chat redirects staff to the ticket
|
||||
queue, which is itself @supervisor_required). So the destination is chosen
|
||||
per role rather than pointed at support.chat for everyone — an inspector
|
||||
following that chain would land on the dashboard with an access-denied
|
||||
flash, and this page is meant never to dead-end. Roles with no support
|
||||
destination get no card; the how-to guides below are their support. #}
|
||||
{% if current_user.role == 'customer' %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
|
||||
<div class="jqc-hub-text">Ask a question and get an answer straight away.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% elif current_user.role in ['admin', 'director'] %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a class="jqc-hub-card dark" href="{{ url_for('support.admin_conversations') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
|
||||
<div class="jqc-hub-text">Review the AI chat conversations customers have had.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Design Vote Tally{% endblock %}
|
||||
|
||||
{# Admin-only: which design are active users currently keeping? #}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
|
||||
<div class="text-muted">Which web portal design each active user is currently using.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Classic design</div>
|
||||
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">New design</div>
|
||||
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Total active users</div>
|
||||
<div class="fs-2 fw-bold">{{ total }}</div>
|
||||
<div class="text-muted small">Every account defaults to classic</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role, theme, count in by_role %}
|
||||
<tr>
|
||||
<td>{{ role_labels.get(role, role.replace('_',' ')|title) }}</td>
|
||||
<td>{{ 'New design' if theme == 'modern' else 'Classic design' }}</td>
|
||||
<td class="text-end fw-semibold">{{ count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">No active users.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user