Aug 6 - Update enrollment page

This commit is contained in:
2026-08-06 11:38:22 -04:00
parent 633a5b865f
commit 5bc7592c1a
6 changed files with 597 additions and 279 deletions
+132 -66
View File
@@ -26,6 +26,7 @@ its own standalone template with no authenticated nav.
import csv
import io
import logging
import re
from datetime import datetime
from flask import (Blueprint, render_template, request, redirect, url_for,
@@ -59,13 +60,78 @@ def _clean(value, limit=_MAX_TEXT):
return (value or '').strip()[:limit]
#: Person rows are named person_<n>_<field>. The client controls <n> (rows can
#: be added and removed in any order), so the server discovers the indexes that
#: were actually posted rather than trusting a count field.
_PERSON_FIELD_RE = re.compile(r'^person_(\d+)_role$')
def _parse_people(form):
"""Return the submitted people as an ordered list of dicts.
Each entry gets a stable `key` (p1, p2, …) assigned by POSITION, not by the
client's index — so the matrix keys in a stored submission are always dense
and predictable no matter which rows the customer deleted before sending.
"""
indexes = sorted(
int(m.group(1))
for m in (_PERSON_FIELD_RE.match(k) for k in form.keys()) if m
)
people = []
for idx in indexes:
role = form.get(f'person_{idx}_role', '')
if role not in schema.ROLE_KEYS:
role = schema.DEFAULT_FIRST_ROLE
name = _clean(form.get(f'person_{idx}_name'))
job_title = _clean(form.get(f'person_{idx}_job_title'))
email = _clean(form.get(f'person_{idx}_email'))
# Drop rows the customer added but left completely blank.
if not (name or job_title or email):
continue
people.append({
'key': f'p{len(people) + 1}',
'form_index': idx, # so the matrix cells can be read back
'role': role,
'name': name,
'job_title': job_title,
'email': email,
})
if len(people) >= schema.MAX_PEOPLE:
logger.warning('ENROLLMENT | people capped at %d', schema.MAX_PEOPLE)
break
return people
def _seed_people(people, matrix, mobile_app):
"""Shape the submitted people for the page to re-render after an error.
Folds each person's ticked tasks into their own row, so the browser can
rebuild the table from scratch with fresh row indexes and still restore
every answer.
"""
seed = []
for person in people:
seed.append({
'role': person['role'],
'name': person['name'],
'job_title': person['job_title'],
'email': person['email'],
'tasks': [ref for ref, _l, _s in schema.TASKS
if matrix.get(str(ref), {}).get(person['key'])],
'mobile': bool(mobile_app.get(person['key'])),
})
return seed
# ── Public form ──────────────────────────────────────────────────────────────
@bp.route('', methods=['GET'])
@bp.route('/', methods=['GET'])
def form():
"""Render the blank enrollment form. No login — the link is emailed out."""
return render_template('enrollment/form.html', schema=schema)
return render_template('enrollment/form.html', schema=schema,
seed_people=[])
@bp.route('', methods=['POST'])
@@ -82,82 +148,80 @@ def submit():
project_name = _clean(request.form.get('project_name'))
request_by = _clean(request.form.get('request_by'))
# ── Step 1 matrix ────────────────────────────────────────────────────
people = _parse_people(request.form)
# ── Step 2 matrix + Step 3 mobile, keyed by person ───────────────────
# Only cells the person's role actually offers are read, so a crafted POST
# cannot record an admin-only task against an inspector.
matrix = {}
for ref, _label, scope in schema.TASKS:
row = {}
for col_key, _col_label in schema.columns_for(scope):
row[col_key] = bool(request.form.get(f'task_{ref}_{col_key}'))
for person in people:
if schema.task_applies(scope, person['role']):
row[person['key']] = bool(
request.form.get(f'task_{ref}_person_{person["form_index"]}'))
matrix[str(ref)] = row
# ── Step 2 registrants ───────────────────────────────────────────────
registrants = []
for key, label in schema.REGISTRANTS:
entry = {
'key': key,
'label': label,
'include': bool(request.form.get(f'reg_{key}_include')),
'name': _clean(request.form.get(f'reg_{key}_name')),
'job_title': _clean(request.form.get(f'reg_{key}_job_title')),
'email': _clean(request.form.get(f'reg_{key}_email')),
}
registrants.append(entry)
# ── Step 3 mobile app ────────────────────────────────────────────────
mobile_app = {
col_key: bool(request.form.get(f'mobile_{col_key}'))
for col_key, _ in schema.COLUMNS
p['key']: bool(request.form.get(f'mobile_person_{p["form_index"]}'))
for p in people
}
# ── Validation ───────────────────────────────────────────────────────
# A row counts as a real person only when it has BOTH a name and an email —
# a half-filled row cannot be set up, so it must not pass as one.
named = [r for r in registrants if r['name'] and r['email']]
# A person counts only with BOTH a name and an email — a half-filled row
# cannot be set up, so it must not pass as one.
named = [p for p in people if p['name'] and p['email']]
errors = []
if not project_name:
errors.append('Project Name is required.')
if not request_by:
errors.append('Request by is required.')
if not named:
errors.append('Please provide at least one user with both a name and '
'an email address in Step 2.')
for r in registrants:
if r['email'] and '@' not in r['email']:
errors.append(f'"{r["label"]}" has an email address that does not '
f'look valid.')
errors.append('Please add at least one person with both a name and an '
'email address.')
for p in people:
if p['email'] and '@' not in p['email']:
errors.append(f'"{p["name"] or p["key"]}" has an email address that '
f'does not look valid.')
seen = set()
for p in named:
low = p['email'].lower()
if low in seen:
errors.append(f'{p["email"]} is listed more than once — each person '
f'needs their own email address.')
seen.add(low)
prior = {
'project_name': project_name,
'request_by': request_by,
'date_requested': _clean(request.form.get('date_requested')),
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
'people': people,
'matrix': matrix,
'mobile_app': mobile_app,
}
if errors:
for e in errors:
flash(e, 'danger')
# Re-render with what they typed so nothing is retyped.
return render_template(
'enrollment/form.html', schema=schema,
submitted={'project_name': project_name, 'request_by': request_by,
'date_requested': _clean(request.form.get('date_requested')),
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
'matrix': matrix, 'registrants': registrants,
'mobile_app': mobile_app},
), 400
'enrollment/form.html', schema=schema, submitted=prior,
seed_people=_seed_people(people, matrix, mobile_app)), 400
now = datetime.now()
record = {
'id': storage.new_id(now),
'submitted_at': now.isoformat(timespec='seconds'),
'project_name': project_name,
'request_by': request_by,
'date_requested': _clean(request.form.get('date_requested')),
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
'matrix': matrix,
'registrants': registrants,
'mobile_app': mobile_app,
record = dict(prior)
record.update({
'id': storage.new_id(now),
'submitted_at': now.isoformat(timespec='seconds'),
# Filled in later by staff on the admin page.
'office': {k: '' for k, _ in schema.OFFICE_FIELDS},
'status': 'new',
'office': {k: '' for k, _ in schema.OFFICE_FIELDS},
'status': 'new',
'meta': {
'ip': request.remote_addr,
'user_agent': (request.headers.get('User-Agent') or '')[:300],
},
}
})
try:
storage.save(record)
@@ -165,9 +229,11 @@ def submit():
logger.exception('ENROLLMENT | save failed | project=%r', project_name)
flash('Sorry — we could not save your form. Please try again, or '
'email us directly.', 'danger')
return render_template('enrollment/form.html', schema=schema), 500
return render_template(
'enrollment/form.html', schema=schema, submitted=prior,
seed_people=_seed_people(people, matrix, mobile_app)), 500
logger.info('ENROLLMENT | submitted | id=%s project=%r users=%d ip=%s',
logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s',
record['id'], project_name, len(named), request.remote_addr)
return render_template('enrollment/submitted.html', reference=record['id'])
@@ -229,22 +295,20 @@ def admin_download(submission_id):
@login_required
@admin_required
def admin_export_csv():
"""One row per REGISTRANT (not per submission) — that is the unit of work
when actually setting the accounts up."""
"""One row per PERSON (not per submission) — that is the unit of work when
actually setting the accounts up. Reads through schema.people_of(), so
submissions stored in the older fixed-seat format export identically."""
records = storage.load_all()
buf = io.StringIO()
w = csv.writer(buf)
task_headers = [f'{ref}. {label}' for ref, label, _ in schema.TASKS]
w.writerow(['Submission ID', 'Submitted At', 'Status', 'Project Name',
'Requested By', 'Date Requested', 'Seat', 'Name', 'Job Title',
'Requested By', 'Date Requested', 'Role', 'Name', 'Job Title',
'Email', 'Mobile App'] + task_headers)
for rec in records:
for reg in rec.get('registrants', []):
if not (reg.get('name') or reg.get('email')):
continue
key = reg.get('key')
for person in schema.people_of(rec):
row = [
rec.get('id', ''),
rec.get('submitted_at', ''),
@@ -252,15 +316,17 @@ def admin_export_csv():
rec.get('project_name', ''),
rec.get('request_by', ''),
rec.get('date_requested', ''),
reg.get('label', ''),
reg.get('name', ''),
reg.get('job_title', ''),
reg.get('email', ''),
'Yes' if rec.get('mobile_app', {}).get(key) else '',
person['role_label'],
person['name'],
person['job_title'],
person['email'],
'Yes' if schema.wants_mobile(rec, person['key']) else '',
]
for ref, _label, _scope in schema.TASKS:
cell = rec.get('matrix', {}).get(str(ref), {}).get(key)
row.append('Yes' if cell else '')
for ref, _label, scope in schema.TASKS:
if not schema.task_applies(scope, person['role']):
row.append('n/a')
else:
row.append('Yes' if schema.cell(rec, ref, person['key']) else '')
w.writerow(row)
logger.info('ENROLLMENT | csv export | submissions=%d', len(records))
+130 -37
View File
@@ -5,32 +5,50 @@ The JQC Enrollment Form, expressed as data.
This is the SINGLE source of truth for the form's shape. The public template
renders from it, the POST handler parses against it, and the admin detail view
re-renders a stored submission through it. Changing a task label or adding an
inspector column is a one-line edit here — no template or parser change.
re-renders a stored submission through it. Changing a task label or adding a
role is a one-line edit here — no template or parser change.
Deliberately free of any app model / DB import: the enrollment form describes
what a prospective customer *wants set up*, not anything that exists in the
system yet. Keep it that way (see app/enrollment/__init__.py).
system yet. Keep it that way (see app/enrollment/__init__.py). The ROLES below
happen to mirror the app's user roles, but they are a COPY on purpose — the
public form must not import the User model.
"""
# ── Step 1 columns ───────────────────────────────────────────────────────────
# key -> display label. 'admin' is the Admin/Director column; the rest are the
# five inspector seats on the printed form.
COLUMNS = [
('admin', 'Admin /\nDirector'),
('inspector_1', 'User /\nInspector 1'),
('inspector_2', 'User /\nInspector 2'),
('inspector_3', 'User /\nInspector 3'),
('inspector_4', 'User /\nInspector 4'),
('inspector_5', 'User /\nInspector 5'),
# ── Roles a person can be enrolled as ────────────────────────────────────────
# key -> label, shown in the Step 1 role dropdown.
ROLES = [
('admin', 'Admin'),
('director', 'Director'),
('auditor', 'Auditor'),
('inspector', 'Inspector'),
('external_inspector', 'External Inspector'),
]
INSPECTOR_COLUMNS = [c for c in COLUMNS if c[0] != 'admin']
ROLE_LABELS = dict(ROLES)
ROLE_KEYS = [k for k, _ in ROLES]
#: Roles that act on the administrative side of the printed form (the
#: "Admin / Director" column). Everything else is an inspector seat. Drives
#: both the recommendation preset and eligibility for admin-only tasks.
ADMIN_ROLES = {'admin', 'director', 'auditor'}
#: Role pre-selected for the first row — the form starts with one
#: administrative contact, as on the printed sheet.
DEFAULT_FIRST_ROLE = 'admin'
#: Upper bound on people per submission. Generous for a real enrollment, but
#: bounded so a scripted POST cannot make us build an unbounded matrix.
MAX_PEOPLE = 25
# ── Step 1 rows ──────────────────────────────────────────────────────────────
# ref, label, columns_offered. Ref 10 (Search/Export Reports) is an
# Admin/Director-only capability on the printed form, so it offers one cell.
def is_admin_role(role):
return role in ADMIN_ROLES
# ── Task rows ────────────────────────────────────────────────────────────────
# ref, label, scope. scope 'admin_only' means the cell is offered only to
# people in an ADMIN_ROLES role (ref 10 on the printed form).
TASKS = [
(1, 'Receive new inspection submitted notification', 'all'),
(2, 'Receive issue-related notification', 'all'),
@@ -44,30 +62,26 @@ TASKS = [
(10, 'Search/Export Reports (inspection/issue)', 'admin_only'),
]
def columns_for(scope):
"""Return the column list a task row offers."""
return COLUMNS if scope == 'all' else [('admin', 'Admin /\nDirector')]
TASK_LABELS = {ref: label for ref, label, _ in TASKS}
# ── Step 2 registrants ───────────────────────────────────────────────────────
# key -> row label on the printed form.
REGISTRANTS = [
('admin', 'Administrative Roles (Admin/Director/Auditor)'),
('inspector_1', 'User / Inspector 1'),
('inspector_2', 'User / Inspector 2'),
('inspector_3', 'User / Inspector 3'),
('inspector_4', 'User / Inspector 4'),
('inspector_5', 'User / Inspector 5'),
]
def task_applies(scope, role):
"""True when a task row offers a checkbox to someone in `role`."""
return scope == 'all' or is_admin_role(role)
# ── Step 3 ───────────────────────────────────────────────────────────────────
MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device'
# ── Recommendation block (static reference, not an input) ────────────────────
# ref -> (admin_recommended, inspector_recommended). None = no cell on the form.
# ── Recommended defaults ─────────────────────────────────────────────────────
# ref -> (recommended for admin-side roles, recommended for inspector roles).
# None = the row offers that side no cell.
#
# The printed form showed this as a separate RECOMMENDATION table for the
# customer to copy by hand. It is now applied by the "Recommendation selection"
# button instead, so the table is no longer rendered — but this mapping is
# still the authority, and is handed to the page as JSON.
RECOMMENDATION = {
1: (False, True),
2: (False, True),
@@ -81,10 +95,30 @@ RECOMMENDATION = {
10: (True, None),
}
RECOMMENDATION_INTRO = (
'To prevent the administrator or director from receiving an overwhelming '
'number of email notifications, we recommend the following:'
)
def recommendation_for(role):
"""Return {task_ref: bool} — the recommended preset for one role.
Rows that offer this role no cell are omitted rather than set False, so
the caller never ticks a checkbox that does not exist.
"""
admin_side = is_admin_role(role)
preset = {}
for ref, _label, scope in TASKS:
if not task_applies(scope, role):
continue
rec = RECOMMENDATION.get(ref, (False, False))
value = rec[0] if admin_side else rec[1]
if value is None:
continue
preset[ref] = bool(value)
return preset
def recommendation_map():
"""{role_key: {task_ref: bool}} for every role — serialised to the page."""
return {role: recommendation_for(role) for role in ROLE_KEYS}
NOTES = [
'Each user will receive instructions on how to sign up and install the app '
@@ -108,3 +142,62 @@ STATUS_LABELS = {
'in_progress': 'In Progress',
'completed': 'Completed',
}
# ── Legacy record support ────────────────────────────────────────────────────
# Submissions taken before the form moved to free-form people used six fixed
# seats. Stored files are never rewritten, so the admin views normalise on
# read instead — one shape to render, whichever format is on disk.
_LEGACY_SEAT_ROLES = {
'admin': 'admin',
'inspector_1': 'inspector',
'inspector_2': 'inspector',
'inspector_3': 'inspector',
'inspector_4': 'inspector',
'inspector_5': 'inspector',
}
def people_of(record):
"""Return a submission's people as a uniform list, old format or new.
Each entry: {key, role, role_label, name, job_title, email}.
"""
if record.get('people'):
out = []
for p in record['people']:
role = p.get('role', 'inspector')
out.append({
'key': p.get('key', ''),
'role': role,
'role_label': ROLE_LABELS.get(role, role.replace('_', ' ').title()),
'name': p.get('name', ''),
'job_title': p.get('job_title', ''),
'email': p.get('email', ''),
})
return out
# Legacy: fixed seats under 'registrants'.
out = []
for reg in record.get('registrants', []):
if not (reg.get('name') or reg.get('email')):
continue
role = _LEGACY_SEAT_ROLES.get(reg.get('key'), 'inspector')
out.append({
'key': reg.get('key', ''),
'role': role,
'role_label': ROLE_LABELS.get(role, role.title()),
'name': reg.get('name', ''),
'job_title': reg.get('job_title', ''),
'email': reg.get('email', ''),
})
return out
def cell(record, ref, person_key):
"""True when `person_key` was ticked for task `ref` in this submission."""
return bool(record.get('matrix', {}).get(str(ref), {}).get(person_key))
def wants_mobile(record, person_key):
return bool(record.get('mobile_app', {}).get(person_key))
@@ -81,43 +81,41 @@
</div>
</div>
{# ── Step 2: who to set up ──────────────────────────────────────────── #}
{# ── The people to set up ──────────────────────────────────────────── #}
{% set people = schema.people_of(record) %}
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Users to Register</div>
<div class="card-header fw-semibold">
Users to Register
<span class="badge bg-secondary rounded-pill ms-1">{{ people | length }}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Seat</th><th>Name</th><th>Job Title</th><th>Email</th>
<th style="width:50px;">No.</th>
<th>Role</th><th>Name</th><th>Job Title</th><th>Email</th>
<th class="text-center">Mobile App</th>
</tr>
</thead>
<tbody>
{% for reg in record.registrants %}
{% if reg.name or reg.email %}
{% for person in people %}
<tr>
<td>{{ loop.index }}</td>
<td><span class="badge bg-light text-dark border">{{ person.role_label }}</span></td>
<td class="fw-semibold">{{ person.name or '—' }}</td>
<td>{{ person.job_title or '—' }}</td>
<td>
{{ reg.label }}
{% if reg.include %}
<i class="bi bi-check-circle-fill text-success ms-1"
title="Ticked on the form"></i>
{% endif %}
</td>
<td class="fw-semibold">{{ reg.name or '—' }}</td>
<td>{{ reg.job_title or '—' }}</td>
<td>
{% if reg.email %}
<a href="mailto:{{ reg.email }}">{{ reg.email }}</a>
{% if person.email %}
<a href="mailto:{{ person.email }}">{{ person.email }}</a>
{% else %}—{% endif %}
</td>
<td class="text-center">
{% if record.mobile_app.get(reg.key) %}
{% if schema.wants_mobile(record, person.key) %}
<i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
@@ -125,7 +123,7 @@
</div>
</div>
{# ── Step 1: the requested matrix ───────────────────────────────────── #}
{# ── The requested task matrix — one column per person ──────────────── #}
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Requested Tasks &amp; Functions</div>
<div class="card-body p-0">
@@ -134,25 +132,27 @@
<thead class="table-light">
<tr>
<th style="width:50px;">Ref</th>
<th>Task / Function</th>
{% for col_key, col_label in schema.COLUMNS %}
<th class="text-center" style="width:110px;">
{{ col_label.replace('\n', ' ') }}
<th style="min-width:280px;">Task / Function</th>
{% for person in people %}
<th class="text-center" style="min-width:120px;">
{{ person.name or 'Person ' ~ loop.index }}
<div class="fw-normal text-muted" style="font-size:.75rem;">
{{ person.role_label }}
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
{% set row = record.matrix.get(ref|string, {}) %}
<tr>
<td class="text-center">{{ ref }}</td>
<td>{{ label }}</td>
{% for col_key, _col_label in schema.COLUMNS %}
{% for person in people %}
<td class="text-center">
{% if scope != 'all' and col_key != 'admin' %}
<span class="text-muted">·</span>
{% elif row.get(col_key) %}
{% if not schema.task_applies(scope, person.role) %}
<span class="text-muted" title="Not available for this role">·</span>
{% elif schema.cell(record, ref, person.key) %}
<i class="bi bi-check-square-fill text-success"></i>
{% else %}
<span class="text-muted"></span>
@@ -50,7 +50,8 @@
</thead>
<tbody>
{% for r in records %}
{% set named = r.registrants | selectattr('email') | selectattr('name') | list %}
{# people_of() normalises both the current and the legacy stored shape #}
{% set named = schema.people_of(r) %}
{% set mobile_count = r.mobile_app.values() | select | list | length %}
<tr>
<td class="text-nowrap">
+286 -144
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; color:#1f2937; }
.sheet { max-width:1140px; margin:24px auto 60px; background:#fff;
.sheet { max-width:1180px; margin:24px auto 60px; background:#fff;
border:1px solid #d7dee6; border-radius:10px; padding:32px 34px 40px; }
.form-title { color:#1a6fb5; font-weight:800; font-size:2rem; text-align:center; margin:0; }
.form-sub { text-align:center; color:#6b7280; margin-bottom:26px; }
@@ -20,10 +20,9 @@
table.grid thead th { background:#dbeafe; font-weight:700; text-align:center; font-size:.86rem; line-height:1.25; }
table.grid thead th.left { text-align:left; }
.ref-col { width:52px; text-align:center; }
.chk-col { width:118px; text-align:center; }
.chk-col { min-width:104px; text-align:center; }
.chk-col input { width:18px; height:18px; }
.reg-table thead th { background:#dcfce7; }
.rec-table thead th { background:#fde4d3; }
.people-table thead th { background:#dcfce7; }
.hdr-table td { border:1px solid #cbd5e1; padding:6px 9px; }
.hdr-table .lbl { background:#f8fafc; font-weight:600; width:170px; white-space:nowrap; }
.hdr-table input { border:none; outline:none; width:100%; }
@@ -31,16 +30,21 @@
.cell-input { border:1px solid transparent; background:transparent; width:100%;
padding:2px 4px; border-radius:4px; }
.cell-input:focus { border-color:#1a6fb5; background:#fff; outline:none; }
.col-person { font-weight:700; font-size:.84rem; line-height:1.2; }
.col-role { font-weight:400; font-size:.76rem; color:#4b5563; display:block; margin-top:2px; }
.cell-na { color:#cbd5e1; }
.office-note { color:#6b7280; font-size:.82rem; }
.note-list { font-size:.92rem; }
.scroll-x { overflow-x:auto; }
.empty-hint { border:1px dashed #cbd5e1; border-radius:8px; padding:20px;
text-align:center; color:#6b7280; }
/* Honeypot — hidden from humans, visible to naive bots. Not type=hidden:
some bots skip those. */
.hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
@media (max-width: 820px) {
.sheet { padding:18px 14px 30px; margin:10px; }
table.grid { font-size:.8rem; }
.chk-col { width:64px; }
.scroll-x { overflow-x:auto; }
.chk-col { min-width:70px; }
}
@media print {
body { background:#fff; }
@@ -63,6 +67,14 @@
{% endif %}
{% endwith %}
<noscript>
<div class="alert alert-warning no-print">
This form needs JavaScript enabled — the task table is built from the
people you add. Please enable JavaScript, or contact us and we will send
you a printable copy.
</div>
</noscript>
<form method="POST" action="{{ url_for('enrollment.submit') }}" id="enrollForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -104,161 +116,69 @@
</div>
</div>
{# ── Step 1 ─────────────────────────────────────────────────────── #}
{# ── Step 1 — the people ────────────────────────────────────────── #}
<div class="step-head">
Step 1: <span>Please check the task/function for each user. Refer to our
recommendations at the bottom of this page.</span>
Step 1: <span>Please list everyone who needs access. Each person will
receive an email invitation at the address you provide.</span>
</div>
<div class="scroll-x">
<table class="grid">
<thead>
<tr>
<th class="ref-col">Ref</th>
<th class="left">Role Descriptions: Tasks and Functions</th>
{% for col_key, col_label in schema.COLUMNS %}
<th class="chk-col">{{ col_label.replace('\n', ' ') }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
<tr>
<td class="ref-col">{{ ref }}</td>
<td>{{ label }}</td>
{% for col_key, _col_label in schema.COLUMNS %}
{% if scope == 'all' or col_key == 'admin' %}
<td class="chk-col">
<input type="checkbox" class="form-check-input"
name="task_{{ ref }}_{{ col_key }}"
aria-label="{{ label }} — {{ _col_label.replace('\n',' ') }}"
{% if submitted and submitted.matrix[ref|string][col_key] %}checked{% endif %}>
</td>
{% else %}
{# Ref 10 is Admin/Director only on the printed form. #}
<td class="chk-col"></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── Step 2 ─────────────────────────────────────────────────────── #}
<div class="step-head">
Step 2: <span>Please provide the following information for setup. Each user
will receive a notification at the email provided below to sign in.</span>
</div>
<div class="scroll-x">
<table class="grid reg-table">
<table class="grid people-table">
<thead>
<tr>
<th class="ref-col">No.</th>
<th class="left">Role Descriptions Register</th>
<th class="left" style="min-width:190px;">Role</th>
<th class="left" style="min-width:190px;">First and last name</th>
<th class="left" style="min-width:160px;">Job Title</th>
<th class="left" style="min-width:210px;">Email Address</th>
<th style="width:52px;"></th>
<th class="left">First and last name</th>
<th class="left">Job Title</th>
<th class="left">Email Address</th>
</tr>
</thead>
<tbody>
{% for key, label in schema.REGISTRANTS %}
{% set reg = (submitted.registrants[loop.index0] if submitted else none) %}
<tr>
<td class="ref-col">{{ loop.index }}</td>
<td>{{ label }}</td>
<td class="text-center">
<input type="checkbox" class="form-check-input" name="reg_{{ key }}_include"
aria-label="Register {{ label }}"
{% if reg and reg.include %}checked{% endif %}>
</td>
<td><input type="text" class="cell-input" name="reg_{{ key }}_name" maxlength="200"
value="{{ reg.name if reg else '' }}"></td>
<td><input type="text" class="cell-input" name="reg_{{ key }}_job_title" maxlength="200"
value="{{ reg.job_title if reg else '' }}"></td>
<td><input type="email" class="cell-input" name="reg_{{ key }}_email" maxlength="200"
value="{{ reg.email if reg else '' }}"></td>
</tr>
{% endfor %}
</tbody>
<tbody id="peopleBody"><!-- rows injected by JS --></tbody>
</table>
</div>
{# ── Step 3 ─────────────────────────────────────────────────────── #}
<div class="mt-2 no-print">
<button type="button" class="btn btn-sm btn-outline-primary" id="addPersonBtn">
<i class="bi bi-plus-lg"></i> Add another person
</button>
<span class="text-muted small ms-2" id="peopleCount"></span>
</div>
{# ── Step 2 — the task matrix, built from Step 1 ────────────────── #}
<div class="step-head">
Step 2: <span>Please check the task/function for each user, or apply our
recommended selection and adjust it.</span>
</div>
<div class="mb-2 no-print">
<button type="button" class="btn btn-sm btn-primary" id="recommendBtn">
<i class="bi bi-magic"></i> Recommendation selection
</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="clearBtn">
Clear all
</button>
<div class="form-text">
Our recommendation keeps administrators and directors from receiving an
overwhelming number of email notifications. You can change any box afterwards.
</div>
</div>
<div class="scroll-x" id="matrixWrap"><!-- table injected by JS --></div>
{# ── Step 3 — mobile app ────────────────────────────────────────── #}
<div class="step-head">
Step 3: <span>Please check the box next to the user who will receive the
app for smart devices.</span>
</div>
<div class="scroll-x">
<table class="grid">
<thead>
<tr>
<th class="ref-col">No.</th>
<th class="left">Mobile App</th>
{% for col_key, col_label in schema.COLUMNS %}
<th class="chk-col">{{ '' if col_key == 'admin' else col_label.replace('\n',' ') }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td class="ref-col">7</td>
<td>{{ schema.MOBILE_APP_LABEL }}</td>
{% for col_key, col_label in schema.COLUMNS %}
<td class="chk-col">
<input type="checkbox" class="form-check-input" name="mobile_{{ col_key }}"
aria-label="Mobile app — {{ col_label.replace('\n',' ') }}"
{% if submitted and submitted.mobile_app[col_key] %}checked{% endif %}>
</td>
{% endfor %}
</tr>
</tbody>
</table>
</div>
<div class="scroll-x" id="mobileWrap"><!-- table injected by JS --></div>
{# ── Notes from the customer ────────────────────────────────────── #}
{# ── Notes ──────────────────────────────────────────────────────── #}
<div class="step-head">Anything else we should know? <span>(optional)</span></div>
<textarea name="notes" class="form-control" rows="3" maxlength="2000"
placeholder="Special requirements, timing, additional users…">{{ submitted.notes if submitted else '' }}</textarea>
{# ── Recommendation (reference only) ────────────────────────────── #}
<div class="step-head">RECOMMENDATION</div>
<p class="mb-2">{{ schema.RECOMMENDATION_INTRO }}</p>
<div class="scroll-x">
<table class="grid rec-table">
<thead>
<tr>
<th class="ref-col">Ref</th>
<th class="left">Role Descriptions: Tasks and Functions</th>
<th class="chk-col">Admin / Director</th>
<th class="chk-col">User / Inspectors</th>
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
{% set rec = schema.RECOMMENDATION[ref] %}
<tr>
<td class="ref-col">{{ ref }}</td>
<td>{{ label }}</td>
<td class="chk-col">
{% if rec[0] %}<i class="bi bi-check-square-fill text-success"></i>
{% else %}<i class="bi bi-square text-muted"></i>{% endif %}
</td>
<td class="chk-col">
{% if rec[1] is none %}—
{% elif rec[1] %}<i class="bi bi-check-square-fill text-success"></i>
{% else %}<i class="bi bi-square text-muted"></i>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="step-head">Note:</div>
<ol class="note-list">
{% for note in schema.NOTES %}<li>{{ note }}</li>{% endfor %}
@@ -277,12 +197,234 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Disable on first submit — a double tap must not file two enrollments.
document.getElementById('enrollForm').addEventListener('submit', function () {
var btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.innerHTML = 'Submitting…';
});
(function () {
'use strict';
// ── Data handed over from schema.py — the single source of truth ────────
var ROLES = {{ schema.ROLES | tojson }};
var TASKS = {{ schema.TASKS | tojson }};
var ADMIN_ROLES = {{ schema.ADMIN_ROLES | list | tojson }};
var RECOMMENDATION = {{ schema.recommendation_map() | tojson }};
var DEFAULT_ROLE = {{ schema.DEFAULT_FIRST_ROLE | tojson }};
var MOBILE_LABEL = {{ schema.MOBILE_APP_LABEL | tojson }};
var MAX_PEOPLE = {{ schema.MAX_PEOPLE | tojson }};
var SEED = {{ seed_people | tojson }};
var peopleBody = document.getElementById('peopleBody');
var matrixWrap = document.getElementById('matrixWrap');
var mobileWrap = document.getElementById('mobileWrap');
var countLabel = document.getElementById('peopleCount');
// Row indexes only ever increase, so removing a middle row can never make a
// new row reuse a departed row's field names. The server re-keys people by
// position on receipt, so gaps here are harmless.
var nextIndex = 0;
function isAdminRole(role) { return ADMIN_ROLES.indexOf(role) !== -1; }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Step 1 rows ─────────────────────────────────────────────────────────
function addPerson(seed) {
if (peopleBody.rows.length >= MAX_PEOPLE) return;
seed = seed || {};
var i = nextIndex++;
var tr = document.createElement('tr');
tr.dataset.index = i;
var options = ROLES.map(function (r) {
var sel = (seed.role || DEFAULT_ROLE) === r[0] ? ' selected' : '';
return '<option value="' + esc(r[0]) + '"' + sel + '>' + esc(r[1]) + '</option>';
}).join('');
tr.innerHTML =
'<td class="ref-col row-num"></td>' +
'<td><select class="form-select form-select-sm person-role" ' +
'name="person_' + i + '_role" aria-label="Role">' + options + '</select></td>' +
'<td><input type="text" class="cell-input person-name" name="person_' + i + '_name" ' +
'maxlength="200" placeholder="First and last name" value="' + esc(seed.name) + '"></td>' +
'<td><input type="text" class="cell-input" name="person_' + i + '_job_title" ' +
'maxlength="200" placeholder="Job title" value="' + esc(seed.job_title) + '"></td>' +
'<td><input type="email" class="cell-input" name="person_' + i + '_email" ' +
'maxlength="200" placeholder="name@company.com" value="' + esc(seed.email) + '"></td>' +
'<td class="text-center no-print">' +
'<button type="button" class="btn btn-sm btn-link text-danger p-0 remove-person" ' +
'title="Remove this person" aria-label="Remove this person">' +
'<i class="bi bi-x-circle"></i></button></td>';
peopleBody.appendChild(tr);
if (seed.tasks) { tr.dataset.seedTasks = seed.tasks.join(','); }
if (seed.mobile) { tr.dataset.seedMobile = '1'; }
return tr;
}
function renumber() {
Array.prototype.forEach.call(peopleBody.rows, function (tr, n) {
tr.querySelector('.row-num').textContent = n + 1;
});
var n = peopleBody.rows.length;
countLabel.textContent = n + (n === 1 ? ' person' : ' people')
+ (n >= MAX_PEOPLE ? ' (maximum reached)' : '');
// Never let the last row be removed — the form needs at least one person.
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
tr.querySelector('.remove-person').style.visibility = n > 1 ? '' : 'hidden';
});
document.getElementById('addPersonBtn').disabled = n >= MAX_PEOPLE;
}
// ── Read the current people out of Step 1 ───────────────────────────────
function currentPeople() {
return Array.prototype.map.call(peopleBody.rows, function (tr, n) {
var name = tr.querySelector('.person-name').value.trim();
var role = tr.querySelector('.person-role').value;
return {
index: tr.dataset.index,
role: role,
label: name || ('Person ' + (n + 1)),
roleLabel: (ROLES.filter(function (r) { return r[0] === role; })[0] || ['', role])[1]
};
});
}
// ── Step 2 + Step 3 tables ──────────────────────────────────────────────
// Rebuilt whenever Step 1 changes. Existing ticks are preserved by field
// name, so renaming someone or adding a colleague never clears the grid.
function renderMatrix() {
var people = currentPeople();
var checked = {};
document.querySelectorAll('.matrix-box:checked, .mobile-box:checked')
.forEach(function (cb) { checked[cb.name] = true; });
// Seeded state from a validation-error re-render, applied once.
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
if (tr.dataset.seedTasks) {
tr.dataset.seedTasks.split(',').filter(Boolean).forEach(function (ref) {
checked['task_' + ref + '_person_' + tr.dataset.index] = true;
});
delete tr.dataset.seedTasks;
}
if (tr.dataset.seedMobile) {
checked['mobile_person_' + tr.dataset.index] = true;
delete tr.dataset.seedMobile;
}
});
if (!people.length) {
matrixWrap.innerHTML = '<div class="empty-hint">Add someone in Step 1 and ' +
'their column will appear here.</div>';
mobileWrap.innerHTML = '';
return;
}
var head = '<tr><th class="ref-col">Ref</th>' +
'<th class="left">Role Descriptions: Tasks and Functions</th>' +
people.map(function (p) {
return '<th class="chk-col"><span class="col-person">' + esc(p.label) +
'</span><span class="col-role">' + esc(p.roleLabel) + '</span></th>';
}).join('') + '</tr>';
var body = TASKS.map(function (t) {
var ref = t[0], label = t[1], scope = t[2];
var cells = people.map(function (p) {
// An admin-only row offers no cell to an inspector — matching the
// server, which refuses to record one.
if (scope !== 'all' && !isAdminRole(p.role)) {
return '<td class="chk-col cell-na" title="Not available for this role">·</td>';
}
var nm = 'task_' + ref + '_person_' + p.index;
return '<td class="chk-col"><input type="checkbox" class="form-check-input matrix-box" ' +
'name="' + nm + '" data-ref="' + ref + '" data-index="' + p.index + '" ' +
'aria-label="' + esc(label) + ' — ' + esc(p.label) + '"' +
(checked[nm] ? ' checked' : '') + '></td>';
}).join('');
return '<tr><td class="ref-col">' + ref + '</td><td>' + esc(label) + '</td>' + cells + '</tr>';
}).join('');
matrixWrap.innerHTML = '<table class="grid"><thead>' + head + '</thead><tbody>' +
body + '</tbody></table>';
var mobileCells = people.map(function (p) {
var nm = 'mobile_person_' + p.index;
return '<td class="chk-col"><input type="checkbox" class="form-check-input mobile-box" ' +
'name="' + nm + '" aria-label="Mobile app — ' + esc(p.label) + '"' +
(checked[nm] ? ' checked' : '') + '></td>';
}).join('');
mobileWrap.innerHTML =
'<table class="grid"><thead><tr><th class="ref-col">No.</th>' +
'<th class="left">Mobile App</th>' +
people.map(function (p) {
return '<th class="chk-col"><span class="col-person">' + esc(p.label) + '</span></th>';
}).join('') +
'</tr></thead><tbody><tr><td class="ref-col">7</td><td>' + esc(MOBILE_LABEL) + '</td>' +
mobileCells + '</tr></tbody></table>';
}
// ── Recommendation preset ───────────────────────────────────────────────
// Applies the mapping from schema.RECOMMENDATION for each person's role.
// Overwrites the grid (that is what "apply the recommendation" means), and
// leaves Step 3 alone — who carries a tablet is not something we can guess.
function applyRecommendation() {
var roleByIndex = {};
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
roleByIndex[tr.dataset.index] = tr.querySelector('.person-role').value;
});
document.querySelectorAll('.matrix-box').forEach(function (cb) {
var preset = RECOMMENDATION[roleByIndex[cb.dataset.index]] || {};
cb.checked = !!preset[cb.dataset.ref];
});
}
// ── Wiring ──────────────────────────────────────────────────────────────
document.getElementById('addPersonBtn').addEventListener('click', function () {
addPerson(); renumber(); renderMatrix();
var rows = peopleBody.rows;
rows[rows.length - 1].querySelector('.person-name').focus();
});
peopleBody.addEventListener('click', function (e) {
var btn = e.target.closest('.remove-person');
if (!btn || peopleBody.rows.length <= 1) return;
btn.closest('tr').remove();
renumber(); renderMatrix();
});
// Role changes the available cells; the name changes the column heading.
peopleBody.addEventListener('change', function (e) {
if (e.target.classList.contains('person-role')) renderMatrix();
});
peopleBody.addEventListener('input', function (e) {
if (e.target.classList.contains('person-name')) renderMatrix();
});
document.getElementById('recommendBtn').addEventListener('click', applyRecommendation);
document.getElementById('clearBtn').addEventListener('click', function () {
document.querySelectorAll('.matrix-box, .mobile-box').forEach(function (cb) {
cb.checked = false;
});
});
// Disable on first submit — a double tap must not file two enrollments.
document.getElementById('enrollForm').addEventListener('submit', function () {
var btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.innerHTML = 'Submitting…';
});
// ── Initial state ───────────────────────────────────────────────────────
if (SEED && SEED.length) {
SEED.forEach(function (p) { addPerson(p); });
} else {
addPerson(); // one administrative contact to start
}
renumber();
renderMatrix();
})();
</script>
</body>
</html>