Aug 19 - Update New Inspection Schedule page layout
This commit is contained in:
@@ -530,6 +530,31 @@ def index():
|
|||||||
completed_count=completed_count)
|
completed_count=completed_count)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_contracts():
|
||||||
|
"""Contracts offered in the UI-only contract selector on the form.
|
||||||
|
|
||||||
|
Narrowed to a Customer Director's own contracts so the selector cannot even
|
||||||
|
name another customer's contract. The selector is not submitted — the
|
||||||
|
facility is what the route validates (rule 61) — so this is presentation,
|
||||||
|
with _scope_errors() doing the enforcing.
|
||||||
|
"""
|
||||||
|
q = Project.query.filter_by(active=True)
|
||||||
|
if _is_customer_director(current_user):
|
||||||
|
pids = _customer_project_ids()
|
||||||
|
q = q.filter(Project.id.in_(pids)) if pids else q.filter(False)
|
||||||
|
return q.order_by(Project.name).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _project_for_facility(facility_id):
|
||||||
|
"""The contract a facility belongs to — seeds the contract selector so an
|
||||||
|
edit, or a re-render after a validation error, comes back with both
|
||||||
|
dropdowns as the user left them."""
|
||||||
|
if not facility_id:
|
||||||
|
return None
|
||||||
|
fac = db.session.get(Facility, facility_id)
|
||||||
|
return fac.project_id if fac else None
|
||||||
|
|
||||||
|
|
||||||
def _form_choices():
|
def _form_choices():
|
||||||
"""Lists offered on the schedule form, narrowed to the actor's scope.
|
"""Lists offered on the schedule form, narrowed to the actor's scope.
|
||||||
|
|
||||||
@@ -569,6 +594,8 @@ def _form_choices():
|
|||||||
@schedule_manager_required
|
@schedule_manager_required
|
||||||
def create():
|
def create():
|
||||||
templates, facilities, inspectors = _form_choices()
|
templates, facilities, inspectors = _form_choices()
|
||||||
|
projects = _active_contracts()
|
||||||
|
selected_project_id = None
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
@@ -576,6 +603,8 @@ def create():
|
|||||||
facility_id = request.form.get('facility_id', type=int)
|
facility_id = request.form.get('facility_id', type=int)
|
||||||
area_id = request.form.get('area_id', type=int) or None
|
area_id = request.form.get('area_id', type=int) or None
|
||||||
inspector_id = request.form.get('inspector_id', type=int)
|
inspector_id = request.form.get('inspector_id', type=int)
|
||||||
|
# Re-seeds the contract selector when this POST comes back invalid.
|
||||||
|
selected_project_id = _project_for_facility(facility_id)
|
||||||
frequency = request.form.get('frequency', 'weekly')
|
frequency = request.form.get('frequency', 'weekly')
|
||||||
mode = request.form.get('mode', 'auto')
|
mode = request.form.get('mode', 'auto')
|
||||||
notes = request.form.get('notes', '').strip() or None
|
notes = request.form.get('notes', '').strip() or None
|
||||||
@@ -600,6 +629,8 @@ def create():
|
|||||||
for e in errors:
|
for e in errors:
|
||||||
flash(e, 'warning')
|
flash(e, 'warning')
|
||||||
return render_template('inspection_schedules/form.html',
|
return render_template('inspection_schedules/form.html',
|
||||||
|
projects=projects,
|
||||||
|
selected_project_id=selected_project_id,
|
||||||
templates=templates, facilities=facilities,
|
templates=templates, facilities=facilities,
|
||||||
inspectors=inspectors, frequencies=_FREQUENCIES,
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
||||||
@@ -633,6 +664,8 @@ def create():
|
|||||||
for e in end_errors:
|
for e in end_errors:
|
||||||
flash(e, 'warning')
|
flash(e, 'warning')
|
||||||
return render_template('inspection_schedules/form.html',
|
return render_template('inspection_schedules/form.html',
|
||||||
|
projects=projects,
|
||||||
|
selected_project_id=selected_project_id,
|
||||||
templates=templates, facilities=facilities,
|
templates=templates, facilities=facilities,
|
||||||
inspectors=inspectors, frequencies=_FREQUENCIES,
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
||||||
@@ -652,6 +685,8 @@ def create():
|
|||||||
return redirect(url_for('inspection_schedules.index'))
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
return render_template('inspection_schedules/form.html',
|
return render_template('inspection_schedules/form.html',
|
||||||
|
projects=projects,
|
||||||
|
selected_project_id=selected_project_id,
|
||||||
templates=templates, facilities=facilities,
|
templates=templates, facilities=facilities,
|
||||||
inspectors=inspectors, frequencies=_FREQUENCIES,
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
||||||
@@ -668,6 +703,8 @@ def edit(schedule_id):
|
|||||||
if not _schedule_in_scope(schedule):
|
if not _schedule_in_scope(schedule):
|
||||||
abort(403)
|
abort(403)
|
||||||
templates, facilities, inspectors = _form_choices()
|
templates, facilities, inspectors = _form_choices()
|
||||||
|
projects = _active_contracts()
|
||||||
|
selected_project_id = _project_for_facility(schedule.facility_id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
old_inspector_id = schedule.inspector_id
|
old_inspector_id = schedule.inspector_id
|
||||||
@@ -675,6 +712,8 @@ def edit(schedule_id):
|
|||||||
template_id = request.form.get('template_id', type=int)
|
template_id = request.form.get('template_id', type=int)
|
||||||
facility_id = request.form.get('facility_id', type=int)
|
facility_id = request.form.get('facility_id', type=int)
|
||||||
inspector_id = request.form.get('inspector_id', type=int)
|
inspector_id = request.form.get('inspector_id', type=int)
|
||||||
|
selected_project_id = (_project_for_facility(facility_id)
|
||||||
|
or selected_project_id)
|
||||||
frequency = request.form.get('frequency', schedule.frequency)
|
frequency = request.form.get('frequency', schedule.frequency)
|
||||||
|
|
||||||
if frequency not in _FREQUENCIES:
|
if frequency not in _FREQUENCIES:
|
||||||
@@ -688,6 +727,8 @@ def edit(schedule_id):
|
|||||||
for e in errors:
|
for e in errors:
|
||||||
flash(e, 'warning')
|
flash(e, 'warning')
|
||||||
return render_template('inspection_schedules/form.html',
|
return render_template('inspection_schedules/form.html',
|
||||||
|
projects=projects,
|
||||||
|
selected_project_id=selected_project_id,
|
||||||
schedule=schedule, templates=templates,
|
schedule=schedule, templates=templates,
|
||||||
facilities=facilities, inspectors=inspectors,
|
facilities=facilities, inspectors=inspectors,
|
||||||
frequencies=_FREQUENCIES,
|
frequencies=_FREQUENCIES,
|
||||||
@@ -736,6 +777,8 @@ def edit(schedule_id):
|
|||||||
for e in end_errors:
|
for e in end_errors:
|
||||||
flash(e, 'warning')
|
flash(e, 'warning')
|
||||||
return render_template('inspection_schedules/form.html',
|
return render_template('inspection_schedules/form.html',
|
||||||
|
projects=projects,
|
||||||
|
selected_project_id=selected_project_id,
|
||||||
schedule=schedule, templates=templates,
|
schedule=schedule, templates=templates,
|
||||||
facilities=facilities, inspectors=inspectors,
|
facilities=facilities, inspectors=inspectors,
|
||||||
frequencies=_FREQUENCIES,
|
frequencies=_FREQUENCIES,
|
||||||
@@ -763,7 +806,9 @@ def edit(schedule_id):
|
|||||||
flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
|
flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
|
||||||
return redirect(url_for('inspection_schedules.index'))
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
return render_template('inspection_schedules/form.html', schedule=schedule,
|
return render_template('inspection_schedules/form.html',
|
||||||
|
projects=projects,
|
||||||
|
selected_project_id=selected_project_id, schedule=schedule,
|
||||||
templates=templates, facilities=facilities,
|
templates=templates, facilities=facilities,
|
||||||
inspectors=inspectors, frequencies=_FREQUENCIES,
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
frequency_labels=InspectionSchedule.FREQUENCY_LABELS,
|
||||||
|
|||||||
@@ -476,10 +476,23 @@ def areas_for_facility(facility_id):
|
|||||||
@bp.route('/facilities_for_project/<int:project_id>')
|
@bp.route('/facilities_for_project/<int:project_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def facilities_for_project(project_id):
|
def facilities_for_project(project_id):
|
||||||
facilities = (Facility.query
|
"""Active facilities on one contract, for the Contract -> Facility cascade.
|
||||||
.filter_by(active=True, project_id=project_id)
|
|
||||||
.order_by(Facility.name)
|
**Scoped to the caller.** Every page that renders a facility dropdown
|
||||||
.all())
|
already limits it to what the viewer may see; this endpoint refills that
|
||||||
|
same dropdown, so without the same scope it would happily list another
|
||||||
|
customer's building names to anyone who guessed a contract id — the leak
|
||||||
|
rule 96 describes on the mobile API. Empty list rather than 403, so it does
|
||||||
|
not confirm whether the contract exists either.
|
||||||
|
"""
|
||||||
|
q = Facility.query.filter_by(active=True, project_id=project_id)
|
||||||
|
if current_user.is_inspector:
|
||||||
|
fids = get_inspector_scope(current_user) or []
|
||||||
|
q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False)
|
||||||
|
elif current_user.role == 'customer':
|
||||||
|
fids = get_customer_scope(current_user) or []
|
||||||
|
q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False)
|
||||||
|
facilities = q.order_by(Facility.name).all()
|
||||||
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
|
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +1,234 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{# Laid out to match the single-tenant scheduled-inspection form: one narrow
|
||||||
|
card, Contract → Facility cascade at the top, then what/who, then when.
|
||||||
|
|
||||||
|
MT keeps three fields ST does not have — the schedule NAME (required by
|
||||||
|
inspection_schedules.name), the AREA (a schedule may target one area) and
|
||||||
|
the auto/plan MODE. They are placed next to the field they qualify rather
|
||||||
|
than in a block of their own. #}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# ── Sticky values ────────────────────────────────────────────────────────
|
||||||
|
This form is hand-built (no WTForms), so a re-render after a validation
|
||||||
|
error would otherwise come back blank and the user would retype everything.
|
||||||
|
On POST every field reads back from request.form; otherwise from the saved
|
||||||
|
schedule (edit) or its default (create). ST gets this free from WTForms —
|
||||||
|
this is the equivalent. #}
|
||||||
|
{% set posted = request.form if request.method == 'POST' else None %}
|
||||||
|
{% set v_name = posted.get('name') if posted else (schedule.name if schedule else '') %}
|
||||||
|
{% set v_facility = (posted.get('facility_id')|int(0)) if posted else (schedule.facility_id if schedule else 0) %}
|
||||||
|
{% set v_area = (posted.get('area_id')|int(0)) if posted else (schedule.area_id if schedule and schedule.area_id else 0) %}
|
||||||
|
{% set v_template = (posted.get('template_id')|int(0)) if posted else (schedule.template_id if schedule else 0) %}
|
||||||
|
{% set v_inspector = (posted.get('inspector_id')|int(0)) if posted else (schedule.inspector_id if schedule else 0) %}
|
||||||
|
{% set v_frequency = posted.get('frequency') if posted else (schedule.frequency if schedule else 'weekly') %}
|
||||||
|
{% set v_due = posted.get('next_due_date') if posted else (schedule.due_date.isoformat() if schedule and schedule.due_date else '') %}
|
||||||
|
{% set v_end = posted.get('end_date') if posted else (schedule.end_date.isoformat() if schedule and schedule.end_date else '') %}
|
||||||
|
{% set v_mode = posted.get('mode') if posted else (schedule.mode if schedule else 'auto') %}
|
||||||
|
{% set v_notes = posted.get('notes') if posted else (schedule.notes if schedule and schedule.notes else '') %}
|
||||||
|
{% set v_month_mode = posted.get('month_mode') if posted else (schedule.month_mode if schedule else 'day_of_month') %}
|
||||||
|
{% set v_dom = posted.get('day_of_month') if posted else (schedule.day_of_month if schedule and schedule.day_of_month else '') %}
|
||||||
|
{% set v_nth_week = (posted.get('nth_week')|int(0)) if posted else (schedule.nth_week if schedule and schedule.nth_week else 0) %}
|
||||||
|
{% set v_nth_weekday = (posted.get('nth_weekday')|int(-1)) if posted else (schedule.nth_weekday if schedule and schedule.nth_weekday is not none else -1) %}
|
||||||
|
{% set v_weekdays = (posted.getlist('weekdays')|map('int')|list) if posted else (schedule.weekday_list if schedule else []) %}
|
||||||
|
{% set v_active = (posted.get('active') is not none) if posted else (schedule.active if schedule else True) %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-7">
|
||||||
<h2 class="mb-4"><i class="bi bi-calendar2-week"></i> {{ title }}</h2>
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
|
||||||
<form method="POST" class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Schedule name</label>
|
<label class="form-label fw-semibold" for="name">Schedule name</label>
|
||||||
<input type="text" name="name" class="form-control" required
|
<input type="text" name="name" id="name" class="form-control" required
|
||||||
value="{{ schedule.name if schedule else '' }}"
|
value="{{ v_name }}"
|
||||||
placeholder="e.g. Weekly restroom check — Main Office">
|
placeholder="e.g. Weekly restroom check — Main Office">
|
||||||
</div>
|
<div class="form-text">Shown in the schedule list and in the inspector's reminder.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
{# Contract selector — UI only; narrows the facility list via AJAX.
|
||||||
<div class="col-md-6 mb-3">
|
It carries no name attribute and is never submitted: the facility
|
||||||
<label class="form-label">Template</label>
|
is what the route validates (rule 61). #}
|
||||||
<select name="template_id" class="form-select" required>
|
<div class="mb-3">
|
||||||
<option value="">— Choose a template —</option>
|
<label class="form-label fw-semibold" for="contract_select">Contract</label>
|
||||||
|
<select id="contract_select" class="form-select">
|
||||||
|
<option value="">— Select Contract —</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="facility_id">Facility</label>
|
||||||
|
<select name="facility_id" id="facility_id" class="form-select" required>
|
||||||
|
<option value="">— Select a Contract first —</option>
|
||||||
|
{% for f in facilities %}
|
||||||
|
<option value="{{ f.id }}"
|
||||||
|
{{ 'selected' if v_facility == f.id }}>{{ f.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="area_id">
|
||||||
|
Area <span class="text-muted small fw-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select name="area_id" id="area_id" class="form-select">
|
||||||
|
<option value="">— Whole facility —</option>
|
||||||
|
{# Refilled by JS from the chosen facility; this keeps the saved
|
||||||
|
or just-submitted area selected until that call returns. #}
|
||||||
|
{% if schedule and schedule.area %}
|
||||||
|
<option value="{{ schedule.area.id }}"
|
||||||
|
{{ 'selected' if v_area == schedule.area.id }}>{{ schedule.area.name }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="template_id">Inspection Form</label>
|
||||||
|
<select name="template_id" id="template_id" class="form-select" required>
|
||||||
|
<option value="">— Choose a form —</option>
|
||||||
{% for t in templates %}
|
{% for t in templates %}
|
||||||
<option value="{{ t.id }}"
|
<option value="{{ t.id }}"
|
||||||
{{ 'selected' if schedule and schedule.template_id == t.id }}>{{ t.name }}</option>
|
{{ 'selected' if v_template == t.id }}>{{ t.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Shared forms plus any built for this contract. A form belonging to
|
||||||
|
another contract is rejected on save, not merely hidden here.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Frequency</label>
|
|
||||||
<select name="frequency" id="frequencySelect" class="form-select" required>
|
|
||||||
{% for f in frequencies %}
|
|
||||||
<option value="{{ f }}"
|
|
||||||
{{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}>
|
|
||||||
{{ frequency_labels.get(f, f|title) }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── Recurrence detail (phase46) ────────────────────────────────────
|
<div class="mb-3">
|
||||||
Only the block matching the chosen frequency is shown; the server
|
<label class="form-label fw-semibold" for="inspector_id">Assign to inspector</label>
|
||||||
validates the same block and clears the others on save. #}
|
<select name="inspector_id" id="inspector_id" class="form-select" required>
|
||||||
<div class="row" id="weeklyBlock" style="display:none;">
|
<option value="">— Choose an inspector —</option>
|
||||||
<div class="col-12 mb-3">
|
{% for u in inspectors %}
|
||||||
<label class="form-label">Days of the Week</label>
|
<option value="{{ u.id }}"
|
||||||
|
{{ 'selected' if v_inspector == u.id }}>
|
||||||
|
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="frequency">Frequency</label>
|
||||||
|
<select name="frequency" id="frequency" class="form-select" required>
|
||||||
|
{% for f in frequencies %}
|
||||||
|
<option value="{{ f }}" {{ 'selected' if v_frequency == f }}>
|
||||||
|
{{ frequency_labels.get(f, f|title) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" id="due_date_label" for="next_due_date">
|
||||||
|
{{ 'Next Due Date' if schedule else 'Start Date' }}
|
||||||
|
</label>
|
||||||
|
<input type="date" name="next_due_date" id="next_due_date" class="form-control"
|
||||||
|
value="{{ v_due }}">
|
||||||
|
<div class="form-text">
|
||||||
|
{% if schedule %}
|
||||||
|
Snapped forward to the first matching day. Leave it unchanged
|
||||||
|
and saving will not move it.
|
||||||
|
{% else %}
|
||||||
|
Snapped forward to the first matching day. Leave blank to start
|
||||||
|
one full period from now.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── End date ──
|
||||||
|
Hidden for one-time schedules, which end by deactivating when
|
||||||
|
completed. syncFrequency() toggles it; the route clears the column
|
||||||
|
for 'once', so a stale DOM value cannot survive a frequency change. #}
|
||||||
|
<div class="row" id="end_date_row" hidden>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="end_date">
|
||||||
|
End Date <span class="text-muted small fw-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" name="end_date" id="end_date" class="form-control"
|
||||||
|
value="{{ v_end }}">
|
||||||
|
<div class="form-text">
|
||||||
|
The last date this schedule may run — leave blank to repeat indefinitely.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Weekly: which days of the week ──────────────────────────── #}
|
||||||
|
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
|
||||||
|
<label class="form-label fw-semibold d-block">Days of the Week</label>
|
||||||
<div class="d-flex flex-wrap gap-3">
|
<div class="d-flex flex-wrap gap-3">
|
||||||
{% set picked = schedule.weekday_list if schedule else [] %}
|
|
||||||
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
|
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="weekdays"
|
<input class="form-check-input" type="checkbox" name="weekdays"
|
||||||
value="{{ i }}" id="wd{{ i }}" {{ 'checked' if i in picked }}>
|
id="weekday_{{ i }}" value="{{ i }}" {{ 'checked' if i in v_weekdays }}>
|
||||||
<label class="form-check-label" for="wd{{ i }}">{{ day }}</label>
|
<label class="form-check-label" for="weekday_{{ i }}">{{ day }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">
|
<div class="form-text mb-0">
|
||||||
Leave the start date on any day — it snaps forward to the first
|
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
|
||||||
day you pick here.
|
inspections a week. The due date rolls to the next selected day each
|
||||||
|
time one is submitted.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row" id="monthlyBlock" style="display:none;">
|
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
|
||||||
<div class="col-md-4 mb-3">
|
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
|
||||||
<label class="form-label">Rule</label>
|
<label class="form-label fw-semibold d-block">Monthly rule</label>
|
||||||
<select name="month_mode" id="monthModeSelect" class="form-select">
|
|
||||||
<option value="day_of_month"
|
<div class="form-check">
|
||||||
{{ 'selected' if not schedule or schedule.month_mode != 'nth_weekday' }}>
|
<input class="form-check-input" type="radio" name="month_mode"
|
||||||
On a day of the month</option>
|
id="month_mode_day" value="day_of_month"
|
||||||
<option value="nth_weekday"
|
{% if v_month_mode != 'nth_weekday' %}checked{% endif %}>
|
||||||
{{ 'selected' if schedule and schedule.month_mode == 'nth_weekday' }}>
|
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
|
||||||
On a weekday of the month</option>
|
</div>
|
||||||
</select>
|
<div class="ms-4 mb-2" id="dom_row">
|
||||||
</div>
|
<div class="input-group input-group-sm" style="max-width:16rem;">
|
||||||
<div class="col-md-4 mb-3" id="dayOfMonthField">
|
<span class="input-group-text">Day</span>
|
||||||
<label class="form-label">Day of Month</label>
|
<input type="number" name="day_of_month" class="form-control"
|
||||||
<input type="number" name="day_of_month" class="form-control"
|
min="1" max="31" placeholder="15"
|
||||||
min="1" max="31"
|
value="{{ v_dom }}">
|
||||||
value="{{ schedule.day_of_month if schedule and schedule.day_of_month else '' }}">
|
</div>
|
||||||
<div class="form-text">Clamped to the last day in shorter months.</div>
|
<div class="form-text mb-0">Months without that day use their last day.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3" id="nthWeekdayField">
|
|
||||||
<div class="row g-2">
|
<div class="form-check">
|
||||||
<div class="col-6">
|
<input class="form-check-input" type="radio" name="month_mode"
|
||||||
<label class="form-label">Week</label>
|
id="month_mode_nth" value="nth_weekday"
|
||||||
<select name="nth_week" class="form-select">
|
{% if v_month_mode == 'nth_weekday' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
|
||||||
|
</div>
|
||||||
|
<div class="ms-4" id="nth_row">
|
||||||
|
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
|
||||||
|
<select name="nth_week" class="form-select form-select-sm" style="max-width:7rem;">
|
||||||
{% for v, lbl in [(1,'1st'),(2,'2nd'),(3,'3rd'),(4,'4th'),(5,'5th'),(-1,'Last')] %}
|
{% for v, lbl in [(1,'1st'),(2,'2nd'),(3,'3rd'),(4,'4th'),(5,'5th'),(-1,'Last')] %}
|
||||||
<option value="{{ v }}"
|
<option value="{{ v }}" {{ 'selected' if v_nth_week == v }}>{{ lbl }}</option>
|
||||||
{{ 'selected' if schedule and schedule.nth_week == v }}>{{ lbl }}</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
<select name="nth_weekday" class="form-select form-select-sm" style="max-width:11rem;">
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Weekday</label>
|
|
||||||
<select name="nth_weekday" class="form-select">
|
|
||||||
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
|
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
|
||||||
<option value="{{ i }}"
|
<option value="{{ i }}" {{ 'selected' if v_nth_weekday == i }}>{{ day }}</option>
|
||||||
{{ 'selected' if schedule and schedule.nth_weekday == i }}>{{ day }}</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="mb-3">
|
||||||
<div class="col-md-6 mb-3">
|
<label class="form-label fw-semibold" for="mode">Mode</label>
|
||||||
<label class="form-label" id="dueDateLabel">
|
<select name="mode" id="mode" class="form-select">
|
||||||
{{ 'Next Due Date' if schedule else 'Start Date' }}
|
<option value="auto" {{ 'selected' if v_mode != 'plan' }}>
|
||||||
</label>
|
|
||||||
<input type="date" name="next_due_date" class="form-control"
|
|
||||||
value="{{ schedule.due_date.isoformat() if schedule and schedule.due_date else '' }}">
|
|
||||||
<div class="form-text">
|
|
||||||
{% if schedule %}
|
|
||||||
When the next occurrence is due. Leave unchanged and saving will
|
|
||||||
not move it.
|
|
||||||
{% else %}
|
|
||||||
Leave blank to start one full period from now.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3" id="endDateBlock">
|
|
||||||
<label class="form-label">End Date <span class="text-muted small">(optional)</span></label>
|
|
||||||
<input type="date" name="end_date" class="form-control"
|
|
||||||
value="{{ schedule.end_date.isoformat() if schedule and schedule.end_date else '' }}">
|
|
||||||
<div class="form-text">
|
|
||||||
Last date this schedule may produce an occurrence. Blank = repeats
|
|
||||||
indefinitely.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Mode</label>
|
|
||||||
<select name="mode" class="form-select">
|
|
||||||
<option value="auto" {{ 'selected' if not schedule or schedule.mode != 'plan' }}>
|
|
||||||
Auto — create the inspection automatically each period</option>
|
Auto — create the inspection automatically each period</option>
|
||||||
<option value="plan" {{ 'selected' if schedule and schedule.mode == 'plan' }}>
|
<option value="plan" {{ 'selected' if v_mode == 'plan' }}>
|
||||||
Plan — inspector presses Start (with due/overdue reminders)</option>
|
Plan — inspector presses Start (with due/overdue reminders)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
@@ -147,151 +237,191 @@
|
|||||||
the day, and alerts managers once it's overdue.
|
the day, and alerts managers once it's overdue.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Notes for the inspector <span class="text-muted small">(optional)</span></label>
|
|
||||||
<textarea name="notes" class="form-control" rows="2"
|
|
||||||
placeholder="Anything the inspector should know before starting">{{ schedule.notes if schedule and schedule.notes else '' }}</textarea>
|
|
||||||
<div class="form-text">Copied onto the inspection when it starts.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="mb-3">
|
||||||
<div class="col-md-6 mb-3">
|
<label class="form-label fw-semibold" for="notes">
|
||||||
<label class="form-label">Facility</label>
|
Notes for the inspector <span class="text-muted small fw-normal">(optional)</span>
|
||||||
<select name="facility_id" id="facilitySelect" class="form-select" required>
|
</label>
|
||||||
<option value="">— Choose a facility —</option>
|
<textarea name="notes" id="notes" class="form-control" rows="3"
|
||||||
{% for f in facilities %}
|
placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.">{{ v_notes }}</textarea>
|
||||||
<option value="{{ f.id }}"
|
<div class="form-text">
|
||||||
{{ 'selected' if schedule and schedule.facility_id == f.id }}>{{ f.name }}</option>
|
<i class="bi bi-info-circle"></i>
|
||||||
{% endfor %}
|
Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Area <span class="text-muted small">(optional)</span></label>
|
{% if schedule %}
|
||||||
<select name="area_id" id="areaSelect" class="form-select">
|
<div class="form-check mb-3">
|
||||||
<option value="">— Whole facility —</option>
|
<input class="form-check-input" type="checkbox" name="active" id="active"
|
||||||
{% if schedule and schedule.area %}
|
{{ 'checked' if v_active }}>
|
||||||
<option value="{{ schedule.area.id }}" selected>{{ schedule.area.name }}</option>
|
<label class="form-check-label" for="active">Active</label>
|
||||||
{% endif %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Assign to inspector</label>
|
|
||||||
<select name="inspector_id" class="form-select" required>
|
|
||||||
<option value="">— Choose an inspector —</option>
|
|
||||||
{% for u in inspectors %}
|
|
||||||
<option value="{{ u.id }}"
|
|
||||||
{{ 'selected' if schedule and schedule.inspector_id == u.id }}>
|
|
||||||
{{ u.display_name }} ({{ u.role }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if schedule %}
|
|
||||||
<div class="form-check form-switch mb-1">
|
|
||||||
<input class="form-check-input" type="checkbox" name="active" id="activeSwitch"
|
|
||||||
{{ 'checked' if schedule.active }}>
|
|
||||||
<label class="form-check-label" for="activeSwitch">Active</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted small">
|
|
||||||
Current {{ 'due date' if schedule.mode == 'plan' else 'run' }}:
|
|
||||||
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
|
||||||
{% if schedule.last_completed_at %}
|
|
||||||
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if schedule.end_date %}· ends {{ schedule.end_date.strftime('%Y-%m-%d') }}{% endif %}
|
|
||||||
<br>
|
|
||||||
Reminders for this occurrence are only reset if the due date actually
|
|
||||||
moves, so renaming or re-noting a schedule will not re-send them.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer d-flex justify-content-between">
|
<div class="d-flex gap-2">
|
||||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
<button type="submit" class="btn btn-primary">
|
||||||
<button type="submit" class="btn btn-primary">
|
<i class="bi bi-check-lg"></i> Save
|
||||||
<i class="bi bi-check-lg"></i> Save Schedule
|
</button>
|
||||||
</button>
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted small mt-2">
|
||||||
|
Recurring schedules automatically roll their due date forward each time the
|
||||||
|
inspection is completed. The assigned inspector is reminded the day before
|
||||||
|
and on the due date; managers are alerted if it becomes overdue.
|
||||||
|
{% if schedule %}
|
||||||
|
<br>
|
||||||
|
Current {{ 'due date' if schedule.mode == 'plan' else 'run' }}:
|
||||||
|
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
||||||
|
{% if schedule.last_completed_at %}
|
||||||
|
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
{% if schedule.end_date %}· ends {{ schedule.end_date.strftime('%Y-%m-%d') }}{% endif %}.
|
||||||
|
Reminders for this occurrence are only reset if the due date actually moves,
|
||||||
|
so renaming or re-noting a schedule will not re-send them.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
// Recurrence blocks follow the chosen frequency (phase46/47).
|
// ── Contract → Facility → Area cascade ──────────────────────────────────────
|
||||||
// Display only — the server re-validates and clears the unused blocks on save.
|
// The contract selector is UI-only (no name attribute): it never reaches the
|
||||||
|
// server, it only narrows the facility list. facilities_for_project is scoped
|
||||||
|
// to the caller, so a Customer Director asking for another contract's id gets
|
||||||
|
// an empty list rather than that customer's building names.
|
||||||
(function () {
|
(function () {
|
||||||
var freq = document.getElementById('frequencySelect');
|
'use strict';
|
||||||
var weekly = document.getElementById('weeklyBlock');
|
var contractSel = document.getElementById('contract_select');
|
||||||
var monthly = document.getElementById('monthlyBlock');
|
var facilitySel = document.getElementById('facility_id');
|
||||||
var monthMode = document.getElementById('monthModeSelect');
|
var areaSel = document.getElementById('area_id');
|
||||||
var domField = document.getElementById('dayOfMonthField');
|
if (!contractSel || !facilitySel) { return; }
|
||||||
var nthField = document.getElementById('nthWeekdayField');
|
|
||||||
var endBlock = document.getElementById('endDateBlock');
|
|
||||||
var dueLabel = document.getElementById('dueDateLabel');
|
|
||||||
var isEdit = {{ 'true' if schedule else 'false' }};
|
|
||||||
var MONTHLY = ['monthly', 'quarterly', 'bi-annually', 'annually'];
|
|
||||||
|
|
||||||
function syncMonthMode() {
|
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||||
if (!monthMode || !domField || !nthField) return;
|
var AREAS_URL = '{{ url_for("inspections.areas_for_facility", facility_id=0) }}'.replace('/0', '/');
|
||||||
var nth = monthMode.value === 'nth_weekday';
|
var preProjectId = {{ selected_project_id | tojson }};
|
||||||
domField.style.display = nth ? 'none' : '';
|
var preFacilityId = {{ v_facility | tojson }};
|
||||||
nthField.style.display = nth ? '' : 'none';
|
var preAreaId = {{ v_area | tojson }};
|
||||||
|
|
||||||
|
function setPlaceholder() {
|
||||||
|
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
|
||||||
|
facilitySel.disabled = true;
|
||||||
|
loadAreas('', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sync() {
|
|
||||||
if (!freq) return;
|
|
||||||
var v = freq.value;
|
|
||||||
if (weekly) weekly.style.display = (v === 'weekly') ? '' : 'none';
|
|
||||||
if (monthly) monthly.style.display = (MONTHLY.indexOf(v) !== -1) ? '' : 'none';
|
|
||||||
// A one-time schedule has no end date — it closes when it is completed.
|
|
||||||
if (endBlock) endBlock.style.display = (v === 'once') ? 'none' : '';
|
|
||||||
if (dueLabel) {
|
|
||||||
dueLabel.textContent = isEdit ? 'Next Due Date'
|
|
||||||
: (v === 'once' ? 'Date' : 'Start Date');
|
|
||||||
}
|
|
||||||
syncMonthMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (freq) freq.addEventListener('change', sync);
|
|
||||||
if (monthMode) monthMode.addEventListener('change', syncMonthMode);
|
|
||||||
sync();
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Facility → Area cascade, reusing the existing inspections AJAX endpoint.
|
|
||||||
(function () {
|
|
||||||
var facilitySelect = document.getElementById('facilitySelect');
|
|
||||||
var areaSelect = document.getElementById('areaSelect');
|
|
||||||
if (!facilitySelect || !areaSelect) return;
|
|
||||||
|
|
||||||
var preselectedAreaId = {{ (schedule.area_id if schedule and schedule.area_id else 0) | tojson }};
|
|
||||||
|
|
||||||
function loadAreas(facilityId, keepSelection) {
|
function loadAreas(facilityId, keepSelection) {
|
||||||
areaSelect.innerHTML = '<option value="">— Whole facility —</option>';
|
if (!areaSel) { return; }
|
||||||
if (!facilityId) return;
|
areaSel.innerHTML = '<option value="">— Whole facility —</option>';
|
||||||
fetch('{{ url_for('inspections.areas_for_facility', facility_id=0) }}'.replace('/0', '/' + facilityId))
|
if (!facilityId) { return; }
|
||||||
|
fetch(AREAS_URL + facilityId)
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (areas) {
|
.then(function (areas) {
|
||||||
areas.forEach(function (a) {
|
areas.forEach(function (a) {
|
||||||
var opt = document.createElement('option');
|
var opt = document.createElement('option');
|
||||||
opt.value = a.id;
|
opt.value = a.id;
|
||||||
opt.textContent = a.name;
|
opt.textContent = a.name;
|
||||||
if (keepSelection && a.id === preselectedAreaId) opt.selected = true;
|
if (keepSelection && a.id === preAreaId) { opt.selected = true; }
|
||||||
areaSelect.appendChild(opt);
|
areaSel.appendChild(opt);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function () { /* leave the whole-facility default in place */ });
|
.catch(function () { /* leave the whole-facility default in place */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
facilitySelect.addEventListener('change', function () {
|
function loadFacilities(projectId, restoreFacilityId) {
|
||||||
preselectedAreaId = 0;
|
facilitySel.disabled = true;
|
||||||
|
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
fetch(FACILITIES_URL + projectId)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
facilitySel.innerHTML = '<option value="">— Select Facility —</option>';
|
||||||
|
data.forEach(function (f) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = f.id;
|
||||||
|
opt.textContent = f.name;
|
||||||
|
if (restoreFacilityId && f.id === restoreFacilityId) { opt.selected = true; }
|
||||||
|
facilitySel.appendChild(opt);
|
||||||
|
});
|
||||||
|
facilitySel.disabled = false;
|
||||||
|
if (restoreFacilityId) { loadAreas(restoreFacilityId, true); }
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
facilitySel.innerHTML = '<option value="">Could not load facilities</option>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
contractSel.addEventListener('change', function () {
|
||||||
|
if (this.value) { loadFacilities(this.value, null); }
|
||||||
|
else { setPlaceholder(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
facilitySel.addEventListener('change', function () {
|
||||||
|
preAreaId = 0; // a new facility invalidates the saved area
|
||||||
loadAreas(this.value, false);
|
loadAreas(this.value, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// On edit load, refresh the area list for the saved facility and keep the saved area.
|
// Initial state: restore the contract, facility and area on edit / re-render.
|
||||||
if (facilitySelect.value) loadAreas(facilitySelect.value, true);
|
if (preProjectId) {
|
||||||
})();
|
contractSel.value = String(preProjectId);
|
||||||
|
loadFacilities(preProjectId, preFacilityId);
|
||||||
|
} else if (preFacilityId) {
|
||||||
|
// Facility on no contract (or one the selector cannot name): keep the
|
||||||
|
// server-rendered options and the current choice rather than clearing it.
|
||||||
|
facilitySel.disabled = false;
|
||||||
|
loadAreas(String(preFacilityId), true);
|
||||||
|
} else {
|
||||||
|
setPlaceholder();
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
|
||||||
|
// ── Recurrence blocks follow the chosen frequency ───────────────────────────
|
||||||
|
// Display only — the server re-validates and clears the unused blocks on save,
|
||||||
|
// so stale values left in the DOM never take effect.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var freq = document.getElementById('frequency');
|
||||||
|
var weekly = document.getElementById('weekly_block');
|
||||||
|
var monthly = document.getElementById('monthly_block');
|
||||||
|
var endRow = document.getElementById('end_date_row');
|
||||||
|
var dueLabel = document.getElementById('due_date_label');
|
||||||
|
if (!freq || !weekly || !monthly) { return; }
|
||||||
|
|
||||||
|
var domRadio = document.getElementById('month_mode_day');
|
||||||
|
var nthRadio = document.getElementById('month_mode_nth');
|
||||||
|
var domRow = document.getElementById('dom_row');
|
||||||
|
var nthRow = document.getElementById('nth_row');
|
||||||
|
var isEdit = {{ 'true' if schedule else 'false' }};
|
||||||
|
// Every frequency that repeats on a month boundary uses the monthly rule.
|
||||||
|
var MONTHLY = ['monthly', 'quarterly', 'bi-annually', 'annually'];
|
||||||
|
|
||||||
|
function syncMonthMode() {
|
||||||
|
if (!domRow || !nthRow) { return; }
|
||||||
|
var useNth = nthRadio && nthRadio.checked;
|
||||||
|
domRow.style.opacity = useNth ? '.45' : '1';
|
||||||
|
nthRow.style.opacity = useNth ? '1' : '.45';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFrequency() {
|
||||||
|
weekly.hidden = freq.value !== 'weekly';
|
||||||
|
monthly.hidden = MONTHLY.indexOf(freq.value) === -1;
|
||||||
|
// End date is a recurring-only concept.
|
||||||
|
if (endRow) { endRow.hidden = freq.value === 'once'; }
|
||||||
|
if (dueLabel) {
|
||||||
|
dueLabel.textContent = isEdit ? 'Next Due Date'
|
||||||
|
: (freq.value === 'once' ? 'Date' : 'Start Date');
|
||||||
|
}
|
||||||
|
syncMonthMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
freq.addEventListener('change', syncFrequency);
|
||||||
|
[domRadio, nthRadio].forEach(function (r) {
|
||||||
|
if (r) { r.addEventListener('change', syncMonthMode); }
|
||||||
|
});
|
||||||
|
syncFrequency();
|
||||||
|
}());
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,11 +9,19 @@
|
|||||||
['admin','director','project_manager','auditor','customer'] %}
|
['admin','director','project_manager','auditor','customer'] %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
||||||
{% if can_manage_schedules %}
|
{# This page is reached from the Inspections list ("Scheduled") and has no
|
||||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
entry of its own in the main nav, so without this button the only way back
|
||||||
<i class="bi bi-plus-circle"></i> New Schedule
|
is the browser control. #}
|
||||||
</a>
|
<div class="d-flex gap-2">
|
||||||
{% endif %}
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Inspections
|
||||||
|
</a>
|
||||||
|
{% if can_manage_schedules %}
|
||||||
|
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-muted small mb-4">
|
<p class="text-muted small mb-4">
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
or request.endpoint == 'auth.notification_matrix'
|
or request.endpoint == 'auth.notification_matrix'
|
||||||
or request.endpoint.startswith('broadcast.')
|
or request.endpoint.startswith('broadcast.')
|
||||||
or request.endpoint.startswith('devices.')
|
or request.endpoint.startswith('devices.')
|
||||||
|
or request.endpoint.startswith('enrollment.')
|
||||||
or request.endpoint.startswith('tenant_settings.')
|
or request.endpoint.startswith('tenant_settings.')
|
||||||
) %}
|
) %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
@@ -268,6 +269,14 @@
|
|||||||
<i class="bi bi-tablet me-2"></i>Devices
|
<i class="bi bi-tablet me-2"></i>Devices
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
{# The enrollment intake form is public (no login);
|
||||||
|
its submissions are read here. Admin-only. #}
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
|
||||||
|
href="{{ url_for('enrollment.admin_list') }}">
|
||||||
|
<i class="bi bi-person-plus-fill me-2"></i>Enrollment Forms
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||||
|
|||||||
@@ -273,6 +273,7 @@
|
|||||||
or request.endpoint == 'auth.notification_matrix'
|
or request.endpoint == 'auth.notification_matrix'
|
||||||
or request.endpoint.startswith('broadcast.')
|
or request.endpoint.startswith('broadcast.')
|
||||||
or request.endpoint.startswith('devices.')
|
or request.endpoint.startswith('devices.')
|
||||||
|
or request.endpoint.startswith('enrollment.')
|
||||||
or request.endpoint.startswith('tenant_settings.')
|
or request.endpoint.startswith('tenant_settings.')
|
||||||
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||||
) %}
|
) %}
|
||||||
@@ -292,8 +293,10 @@
|
|||||||
href="{{ url_for('broadcast.index') }}">Broadcast</a>
|
href="{{ url_for('broadcast.index') }}">Broadcast</a>
|
||||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||||
href="{{ url_for('devices.index') }}">Devices</a>
|
href="{{ url_for('devices.index') }}">Devices</a>
|
||||||
{# MT: tenant self-service settings replace ST's enrollment entry —
|
{# The enrollment intake form is public (no login) and its
|
||||||
the enrollment blueprint does not exist in this codebase. #}
|
submissions are read here. Admin-only, same as ST. #}
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
|
||||||
|
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</a>
|
||||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||||
href="{{ url_for('tenant_settings.branding') }}">Workspace Settings</a>
|
href="{{ url_for('tenant_settings.branding') }}">Workspace Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,44 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# The enrollment intake form is served by THIS app and is public (no login),
|
||||||
|
so it is linked with url_for() rather than an absolute URL: the link then
|
||||||
|
stays on whatever host the user is already on — which in MT is the tenant's
|
||||||
|
own subdomain or custom domain — and cannot rot if that domain changes.
|
||||||
|
Submissions are read at Admin -> Enrollment Forms. #}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<a class="jqc-hub-card" href="{{ url_for('enrollment.form') }}">
|
||||||
|
<div class="d-flex gap-3 align-items-start">
|
||||||
|
<span class="jqc-tile-icon lg"><i class="bi bi-clipboard-plus"></i></span>
|
||||||
|
<div>
|
||||||
|
<div class="jqc-hub-title">Enrollment Form</div>
|
||||||
|
<div class="jqc-hub-text">
|
||||||
|
Collect who needs access and what each person should be able to do.
|
||||||
|
No login is required to fill it in, so the link can be forwarded to a
|
||||||
|
customer; submissions arrive under Admin → Enrollment Forms.
|
||||||
|
</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('enrollment.admin_list') }}">
|
||||||
|
<div class="d-flex gap-3 align-items-start">
|
||||||
|
<span class="jqc-tile-icon lg"><i class="bi bi-inboxes"></i></span>
|
||||||
|
<div>
|
||||||
|
<div class="jqc-hub-title">Enrollment Submissions</div>
|
||||||
|
<div class="jqc-hub-text">
|
||||||
|
Every enrollment form that has been submitted, with its office
|
||||||
|
notes, status and CSV export.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="jqc-hub-open">Open →</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<a class="jqc-hub-card" href="{{ url_for('tenant_settings.branding') }}">
|
<a class="jqc-hub-card" href="{{ url_for('tenant_settings.branding') }}">
|
||||||
<div class="d-flex gap-3 align-items-start">
|
<div class="d-flex gap-3 align-items-start">
|
||||||
|
|||||||
Reference in New Issue
Block a user