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
+19 -3
View File
@@ -1655,9 +1655,25 @@ app/enrollment/
If it ever needs to *create* the accounts it describes, do that as a **separate explicit admin action** that reads a stored submission. Do not let the public form reach into the models. If it ever needs to *create* the accounts it describes, do that as a **separate explicit admin action** that reads a stored submission. Do not let the public form reach into the models.
### Form flow (people first, then the matrix)
The printed form had six fixed seats (Admin/Director + Inspector 15) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that:
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown** (`schema.ROLES`: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE`; **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
3. **Step 3 — mobile app**, likewise one column per person.
A **"Recommendation selection"** button applies `schema.recommendation_map()` per person's role — admin-side roles get the Admin/Director column of the old table, inspector roles the Inspectors column — after which any box can be changed. The RECOMMENDATION table itself is **no longer rendered**; `schema.RECOMMENDATION` remains the authority behind the button. The button deliberately leaves **Step 3 alone** — who carries a tablet is not something a preset can guess.
**Field naming — the client index and the stored key are independent.** The browser names fields `person_<n>_*`, `task_<ref>_person_<n>`, `mobile_person_<n>` where `<n>` is a monotonic row counter (gaps appear when rows are removed). The server discovers which indexes were actually posted (`_PERSON_FIELD_RE`, never a client-supplied count), drops entirely blank rows, and re-keys people **by position** into `p1, p2, …` for storage. So a customer deleting a middle row cannot shift anyone's answers, and stored matrix keys are always dense.
**Admin-only tasks are enforced server-side.** `task_applies()` gates ref 10 (Search/Export Reports) to `ADMIN_ROLES`; the POST parser only reads cells the person's role offers, so a crafted POST cannot record an admin-only task against an inspector — verified.
### `schema.py` is the source of truth ### `schema.py` is the source of truth
`COLUMNS` (Admin/Director + Inspector 15), `TASKS` (the 10 rows; ref 10 "Search/Export Reports" is `admin_only` and renders a single cell), `REGISTRANTS` (6 seats), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it, the POST parser iterates it, and the admin detail view re-renders stored answers through it — so adding a task row or a 6th inspector seat is a one-line edit with no template or parser change. `ROLES`, `ADMIN_ROLES`, `TASKS` (10 rows; ref 10 is `admin_only`), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it *and hands it to the page as JSON* (`ROLES`, `TASKS`, `ADMIN_ROLES`, `recommendation_map()`), the POST parser iterates it, and the admin views re-render stored answers through it — so adding a task row or a role is a one-line edit with no template, JS or parser change.
**Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file.
### Storage ### Storage
@@ -1670,8 +1686,8 @@ One JSON document per submission in `ENROLLMENT_DIR`, named `<YYYYmmdd-HHMMSS>-<
### Public page hardening (same posture as rule 74) ### Public page hardening (same posture as rule 74)
Login-free, so: CSRF-protected form, `@limiter.limit('5 per hour')` on POST only, honeypot field (`website`, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, `noindex` meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, and at least one registrant with **both** a name and an email (a half-filled row cannot be set up, so it must not pass as one); on failure it re-renders with the customer's input intact and returns 400. Login-free, so: CSRF-protected form, `@limiter.limit('5 per hour')` on POST only, honeypot field (`website`, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, `noindex` meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, at least one person with **both** a name and an email (a half-filled row cannot be set up, so it must not pass as one), and no duplicate email addresses; on failure it re-renders with the customer's input intact — including their ticked boxes, folded per-person into the `seed_people` payload — and returns 400.
### Admin ### Admin
`/enrollment/admin` (admin-only, linked from the **Admin** nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). `GET /admin/<id>.json` downloads the raw file; `GET /admin/export.csv` emits **one row per registrant, not per submission** — that is the unit of work when actually creating the accounts. `/enrollment/admin` (admin-only, linked from the **Admin** nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). `GET /admin/<id>.json` downloads the raw file; `GET /admin/export.csv` emits **one row per person, not per submission** — that is the unit of work when actually creating the accounts. Task cells a person's role cannot have export as `n/a`, distinct from an unticked `''`.
+128 -62
View File
@@ -26,6 +26,7 @@ its own standalone template with no authenticated nav.
import csv import csv
import io import io
import logging import logging
import re
from datetime import datetime from datetime import datetime
from flask import (Blueprint, render_template, request, redirect, url_for, 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] 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 ────────────────────────────────────────────────────────────── # ── Public form ──────────────────────────────────────────────────────────────
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@bp.route('/', methods=['GET']) @bp.route('/', methods=['GET'])
def form(): def form():
"""Render the blank enrollment form. No login — the link is emailed out.""" """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']) @bp.route('', methods=['POST'])
@@ -82,74 +148,72 @@ def submit():
project_name = _clean(request.form.get('project_name')) project_name = _clean(request.form.get('project_name'))
request_by = _clean(request.form.get('request_by')) 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 = {} matrix = {}
for ref, _label, scope in schema.TASKS: for ref, _label, scope in schema.TASKS:
row = {} row = {}
for col_key, _col_label in schema.columns_for(scope): for person in people:
row[col_key] = bool(request.form.get(f'task_{ref}_{col_key}')) 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 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 = { mobile_app = {
col_key: bool(request.form.get(f'mobile_{col_key}')) p['key']: bool(request.form.get(f'mobile_person_{p["form_index"]}'))
for col_key, _ in schema.COLUMNS for p in people
} }
# ── Validation ─────────────────────────────────────────────────────── # ── Validation ───────────────────────────────────────────────────────
# A row counts as a real person only when it has BOTH a name and an email — # A person counts only with BOTH a name and an email — a half-filled row
# a half-filled row cannot be set up, so it must not pass as one. # cannot be set up, so it must not pass as one.
named = [r for r in registrants if r['name'] and r['email']] named = [p for p in people if p['name'] and p['email']]
errors = [] errors = []
if not project_name: if not project_name:
errors.append('Project Name is required.') errors.append('Project Name is required.')
if not request_by: if not request_by:
errors.append('Request by is required.') errors.append('Request by is required.')
if not named: if not named:
errors.append('Please provide at least one user with both a name and ' errors.append('Please add at least one person with both a name and an '
'an email address in Step 2.') 'email address.')
for r in registrants: for p in people:
if r['email'] and '@' not in r['email']: if p['email'] and '@' not in p['email']:
errors.append(f'"{r["label"]}" has an email address that does not ' errors.append(f'"{p["name"] or p["key"]}" has an email address that '
f'look valid.') 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: if errors:
for e in errors: for e in errors:
flash(e, 'danger') flash(e, 'danger')
# Re-render with what they typed so nothing is retyped. # Re-render with what they typed so nothing is retyped.
return render_template( return render_template(
'enrollment/form.html', schema=schema, 'enrollment/form.html', schema=schema, submitted=prior,
submitted={'project_name': project_name, 'request_by': request_by, seed_people=_seed_people(people, matrix, mobile_app)), 400
'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
now = datetime.now() now = datetime.now()
record = { record = dict(prior)
record.update({
'id': storage.new_id(now), 'id': storage.new_id(now),
'submitted_at': now.isoformat(timespec='seconds'), '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,
# Filled in later by staff on the admin page. # Filled in later by staff on the admin page.
'office': {k: '' for k, _ in schema.OFFICE_FIELDS}, 'office': {k: '' for k, _ in schema.OFFICE_FIELDS},
'status': 'new', 'status': 'new',
@@ -157,7 +221,7 @@ def submit():
'ip': request.remote_addr, 'ip': request.remote_addr,
'user_agent': (request.headers.get('User-Agent') or '')[:300], 'user_agent': (request.headers.get('User-Agent') or '')[:300],
}, },
} })
try: try:
storage.save(record) storage.save(record)
@@ -165,9 +229,11 @@ def submit():
logger.exception('ENROLLMENT | save failed | project=%r', project_name) logger.exception('ENROLLMENT | save failed | project=%r', project_name)
flash('Sorry — we could not save your form. Please try again, or ' flash('Sorry — we could not save your form. Please try again, or '
'email us directly.', 'danger') '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) record['id'], project_name, len(named), request.remote_addr)
return render_template('enrollment/submitted.html', reference=record['id']) return render_template('enrollment/submitted.html', reference=record['id'])
@@ -229,22 +295,20 @@ def admin_download(submission_id):
@login_required @login_required
@admin_required @admin_required
def admin_export_csv(): def admin_export_csv():
"""One row per REGISTRANT (not per submission) — that is the unit of work """One row per PERSON (not per submission) — that is the unit of work when
when actually setting the accounts up.""" 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() records = storage.load_all()
buf = io.StringIO() buf = io.StringIO()
w = csv.writer(buf) w = csv.writer(buf)
task_headers = [f'{ref}. {label}' for ref, label, _ in schema.TASKS] task_headers = [f'{ref}. {label}' for ref, label, _ in schema.TASKS]
w.writerow(['Submission ID', 'Submitted At', 'Status', 'Project Name', 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) 'Email', 'Mobile App'] + task_headers)
for rec in records: for rec in records:
for reg in rec.get('registrants', []): for person in schema.people_of(rec):
if not (reg.get('name') or reg.get('email')):
continue
key = reg.get('key')
row = [ row = [
rec.get('id', ''), rec.get('id', ''),
rec.get('submitted_at', ''), rec.get('submitted_at', ''),
@@ -252,15 +316,17 @@ def admin_export_csv():
rec.get('project_name', ''), rec.get('project_name', ''),
rec.get('request_by', ''), rec.get('request_by', ''),
rec.get('date_requested', ''), rec.get('date_requested', ''),
reg.get('label', ''), person['role_label'],
reg.get('name', ''), person['name'],
reg.get('job_title', ''), person['job_title'],
reg.get('email', ''), person['email'],
'Yes' if rec.get('mobile_app', {}).get(key) else '', 'Yes' if schema.wants_mobile(rec, person['key']) else '',
] ]
for ref, _label, _scope in schema.TASKS: for ref, _label, scope in schema.TASKS:
cell = rec.get('matrix', {}).get(str(ref), {}).get(key) if not schema.task_applies(scope, person['role']):
row.append('Yes' if cell else '') row.append('n/a')
else:
row.append('Yes' if schema.cell(rec, ref, person['key']) else '')
w.writerow(row) w.writerow(row)
logger.info('ENROLLMENT | csv export | submissions=%d', len(records)) 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 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 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 re-renders a stored submission through it. Changing a task label or adding a
inspector column is a one-line edit here no template or parser change. role is a one-line edit here no template or parser change.
Deliberately free of any app model / DB import: the enrollment form describes 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 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 ─────────────────────────────────────────────────────────── # ── Roles a person can be enrolled as ────────────────────────────────────────
# key -> display label. 'admin' is the Admin/Director column; the rest are the # key -> label, shown in the Step 1 role dropdown.
# five inspector seats on the printed form. ROLES = [
COLUMNS = [ ('admin', 'Admin'),
('admin', 'Admin /\nDirector'), ('director', 'Director'),
('inspector_1', 'User /\nInspector 1'), ('auditor', 'Auditor'),
('inspector_2', 'User /\nInspector 2'), ('inspector', 'Inspector'),
('inspector_3', 'User /\nInspector 3'), ('external_inspector', 'External Inspector'),
('inspector_4', 'User /\nInspector 4'),
('inspector_5', 'User /\nInspector 5'),
] ]
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 ────────────────────────────────────────────────────────────── def is_admin_role(role):
# ref, label, columns_offered. Ref 10 (Search/Export Reports) is an return role in ADMIN_ROLES
# Admin/Director-only capability on the printed form, so it offers one cell.
# ── 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 = [ TASKS = [
(1, 'Receive new inspection submitted notification', 'all'), (1, 'Receive new inspection submitted notification', 'all'),
(2, 'Receive issue-related notification', 'all'), (2, 'Receive issue-related notification', 'all'),
@@ -44,30 +62,26 @@ TASKS = [
(10, 'Search/Export Reports (inspection/issue)', 'admin_only'), (10, 'Search/Export Reports (inspection/issue)', 'admin_only'),
] ]
TASK_LABELS = {ref: label for ref, label, _ in TASKS}
def columns_for(scope):
"""Return the column list a task row offers."""
return COLUMNS if scope == 'all' else [('admin', 'Admin /\nDirector')]
# ── Step 2 registrants ─────────────────────────────────────────────────────── def task_applies(scope, role):
# key -> row label on the printed form. """True when a task row offers a checkbox to someone in `role`."""
REGISTRANTS = [ return scope == 'all' or is_admin_role(role)
('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'),
]
# ── Step 3 ─────────────────────────────────────────────────────────────────── # ── Step 3 ───────────────────────────────────────────────────────────────────
MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device' MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device'
# ── Recommendation block (static reference, not an input) ──────────────────── # ── Recommended defaults ─────────────────────────────────────────────────────
# ref -> (admin_recommended, inspector_recommended). None = no cell on the form. # 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 = { RECOMMENDATION = {
1: (False, True), 1: (False, True),
2: (False, True), 2: (False, True),
@@ -81,10 +95,30 @@ RECOMMENDATION = {
10: (True, None), 10: (True, None),
} }
RECOMMENDATION_INTRO = (
'To prevent the administrator or director from receiving an overwhelming ' def recommendation_for(role):
'number of email notifications, we recommend the following:' """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 = [ NOTES = [
'Each user will receive instructions on how to sign up and install the app ' 'Each user will receive instructions on how to sign up and install the app '
@@ -108,3 +142,62 @@ STATUS_LABELS = {
'in_progress': 'In Progress', 'in_progress': 'In Progress',
'completed': 'Completed', '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>
</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 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="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table mb-0"> <table class="table mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <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> <th class="text-center">Mobile App</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reg in record.registrants %} {% for person in people %}
{% if reg.name or reg.email %}
<tr> <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> <td>
{{ reg.label }} {% if person.email %}
{% if reg.include %} <a href="mailto:{{ person.email }}">{{ person.email }}</a>
<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>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td class="text-center"> <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> <i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
{% else %}<span class="text-muted"></span>{% endif %} {% else %}<span class="text-muted"></span>{% endif %}
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -125,7 +123,7 @@
</div> </div>
</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 shadow-sm mt-3">
<div class="card-header fw-semibold">Requested Tasks &amp; Functions</div> <div class="card-header fw-semibold">Requested Tasks &amp; Functions</div>
<div class="card-body p-0"> <div class="card-body p-0">
@@ -134,25 +132,27 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:50px;">Ref</th> <th style="width:50px;">Ref</th>
<th>Task / Function</th> <th style="min-width:280px;">Task / Function</th>
{% for col_key, col_label in schema.COLUMNS %} {% for person in people %}
<th class="text-center" style="width:110px;"> <th class="text-center" style="min-width:120px;">
{{ col_label.replace('\n', ' ') }} {{ person.name or 'Person ' ~ loop.index }}
<div class="fw-normal text-muted" style="font-size:.75rem;">
{{ person.role_label }}
</div>
</th> </th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for ref, label, scope in schema.TASKS %} {% for ref, label, scope in schema.TASKS %}
{% set row = record.matrix.get(ref|string, {}) %}
<tr> <tr>
<td class="text-center">{{ ref }}</td> <td class="text-center">{{ ref }}</td>
<td>{{ label }}</td> <td>{{ label }}</td>
{% for col_key, _col_label in schema.COLUMNS %} {% for person in people %}
<td class="text-center"> <td class="text-center">
{% if scope != 'all' and col_key != 'admin' %} {% if not schema.task_applies(scope, person.role) %}
<span class="text-muted">·</span> <span class="text-muted" title="Not available for this role">·</span>
{% elif row.get(col_key) %} {% elif schema.cell(record, ref, person.key) %}
<i class="bi bi-check-square-fill text-success"></i> <i class="bi bi-check-square-fill text-success"></i>
{% else %} {% else %}
<span class="text-muted"></span> <span class="text-muted"></span>
@@ -50,7 +50,8 @@
</thead> </thead>
<tbody> <tbody>
{% for r in records %} {% 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 %} {% set mobile_count = r.mobile_app.values() | select | list | length %}
<tr> <tr>
<td class="text-nowrap"> <td class="text-nowrap">
+283 -141
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style> <style>
body { background:#f1f5f9; color:#1f2937; } 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; } 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-title { color:#1a6fb5; font-weight:800; font-size:2rem; text-align:center; margin:0; }
.form-sub { text-align:center; color:#6b7280; margin-bottom:26px; } .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 { background:#dbeafe; font-weight:700; text-align:center; font-size:.86rem; line-height:1.25; }
table.grid thead th.left { text-align:left; } table.grid thead th.left { text-align:left; }
.ref-col { width:52px; text-align:center; } .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; } .chk-col input { width:18px; height:18px; }
.reg-table thead th { background:#dcfce7; } .people-table thead th { background:#dcfce7; }
.rec-table thead th { background:#fde4d3; }
.hdr-table td { border:1px solid #cbd5e1; padding:6px 9px; } .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 .lbl { background:#f8fafc; font-weight:600; width:170px; white-space:nowrap; }
.hdr-table input { border:none; outline:none; width:100%; } .hdr-table input { border:none; outline:none; width:100%; }
@@ -31,16 +30,21 @@
.cell-input { border:1px solid transparent; background:transparent; width:100%; .cell-input { border:1px solid transparent; background:transparent; width:100%;
padding:2px 4px; border-radius:4px; } padding:2px 4px; border-radius:4px; }
.cell-input:focus { border-color:#1a6fb5; background:#fff; outline:none; } .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; } .office-note { color:#6b7280; font-size:.82rem; }
.note-list { font-size:.92rem; } .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: /* Honeypot — hidden from humans, visible to naive bots. Not type=hidden:
some bots skip those. */ some bots skip those. */
.hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; } .hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
@media (max-width: 820px) { @media (max-width: 820px) {
.sheet { padding:18px 14px 30px; margin:10px; } .sheet { padding:18px 14px 30px; margin:10px; }
table.grid { font-size:.8rem; } table.grid { font-size:.8rem; }
.chk-col { width:64px; } .chk-col { min-width:70px; }
.scroll-x { overflow-x:auto; }
} }
@media print { @media print {
body { background:#fff; } body { background:#fff; }
@@ -63,6 +67,14 @@
{% endif %} {% endif %}
{% endwith %} {% 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"> <form method="POST" action="{{ url_for('enrollment.submit') }}" id="enrollForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -104,161 +116,69 @@
</div> </div>
</div> </div>
{# ── Step 1 ─────────────────────────────────────────────────────── #} {# ── Step 1 — the people ────────────────────────────────────────── #}
<div class="step-head"> <div class="step-head">
Step 1: <span>Please check the task/function for each user. Refer to our Step 1: <span>Please list everyone who needs access. Each person will
recommendations at the bottom of this page.</span> receive an email invitation at the address you provide.</span>
</div> </div>
<div class="scroll-x"> <div class="scroll-x">
<table class="grid"> <table class="grid people-table">
<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">
<thead> <thead>
<tr> <tr>
<th class="ref-col">No.</th> <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 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> </tr>
</thead> </thead>
<tbody> <tbody id="peopleBody"><!-- rows injected by JS --></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>
</table> </table>
</div> </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"> <div class="step-head">
Step 3: <span>Please check the box next to the user who will receive the Step 3: <span>Please check the box next to the user who will receive the
app for smart devices.</span> app for smart devices.</span>
</div> </div>
<div class="scroll-x"> <div class="scroll-x" id="mobileWrap"><!-- table injected by JS --></div>
<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>
{# ── Notes from the customer ────────────────────────────────────── #} {# ── Notes ──────────────────────────────────────────────────────── #}
<div class="step-head">Anything else we should know? <span>(optional)</span></div> <div class="step-head">Anything else we should know? <span>(optional)</span></div>
<textarea name="notes" class="form-control" rows="3" maxlength="2000" <textarea name="notes" class="form-control" rows="3" maxlength="2000"
placeholder="Special requirements, timing, additional users…">{{ submitted.notes if submitted else '' }}</textarea> 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> <div class="step-head">Note:</div>
<ol class="note-list"> <ol class="note-list">
{% for note in schema.NOTES %}<li>{{ note }}</li>{% endfor %} {% 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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Disable on first submit — a double tap must not file two enrollments. (function () {
document.getElementById('enrollForm').addEventListener('submit', 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'); var btn = document.getElementById('submitBtn');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = 'Submitting…'; 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> </script>
</body> </body>
</html> </html>