Files
2026-08-19 16:26:32 -04:00

482 lines
24 KiB
HTML

{% extends "base.html" %}
{% 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. #}
{# ── 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) %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
<div class="card-body">
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold" for="name">Schedule name</label>
<input type="text" name="name" id="name" class="form-control" required
value="{{ v_name }}"
placeholder="e.g. Weekly restroom check — Main Office">
<div class="form-text">Shown in the schedule list and in the inspector's reminder.</div>
</div>
{# Contract selector — UI only; narrows the facility list via AJAX.
It carries no name attribute and is never submitted: the facility
is what the route validates (rule 61). #}
<div class="mb-3">
<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 %}
<option value="{{ t.id }}"
{{ 'selected' if v_template == t.id }}>{{ t.name }}</option>
{% endfor %}
</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 class="mb-3">
<label class="form-label fw-semibold" for="inspector_id">Assign to inspector</label>
<select name="inspector_id" id="inspector_id" class="form-select" required>
<option value="">— Select a Contract first —</option>
{% for u in inspectors %}
<option value="{{ u.id }}"
{{ 'selected' if v_inspector == u.id }}>
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<div class="form-text">
Inspectors assigned to this contract — the person named here has
to be able to open the inspection. Managers are never listed: a
manager who will do the work holds a contract assignment like
anyone else. If the contract has nobody assigned yet, every
inspector is offered so the schedule is not blocked.
</div>
</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">
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays"
id="weekday_{{ i }}" value="{{ i }}" {{ 'checked' if i in v_weekdays }}>
<label class="form-check-label" for="weekday_{{ i }}">{{ day }}</label>
</div>
{% endfor %}
</div>
<div class="form-text mb-0">
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
inspections a week. The due date rolls to the next selected day each
time one is submitted.
</div>
</div>
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
<label class="form-label fw-semibold d-block">Monthly rule</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_day" value="day_of_month"
{% if v_month_mode != 'nth_weekday' %}checked{% endif %}>
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
</div>
<div class="ms-4 mb-2" id="dom_row">
<div class="input-group input-group-sm" style="max-width:16rem;">
<span class="input-group-text">Day</span>
<input type="number" name="day_of_month" class="form-control"
min="1" max="31" placeholder="15"
value="{{ v_dom }}">
</div>
<div class="form-text mb-0">Months without that day use their last day.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_nth" value="nth_weekday"
{% 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')] %}
<option value="{{ v }}" {{ 'selected' if v_nth_week == v }}>{{ lbl }}</option>
{% endfor %}
</select>
<select name="nth_weekday" class="form-select form-select-sm" style="max-width:11rem;">
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
<option value="{{ i }}" {{ 'selected' if v_nth_weekday == i }}>{{ day }}</option>
{% endfor %}
</select>
</div>
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="mode">Mode</label>
<select name="mode" id="mode" class="form-select">
<option value="auto" {{ 'selected' if v_mode != 'plan' }}>
Auto — create the inspection automatically each period</option>
<option value="plan" {{ 'selected' if v_mode == 'plan' }}>
Plan — inspector presses Start (with due/overdue reminders)</option>
</select>
<div class="form-text">
Auto drops an in-progress inspection into the inspector's queue on
schedule. Plan assigns a due date and reminds them the day before, on
the day, and alerts managers once it's overdue.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="notes">
Instructions for the inspector <span class="text-muted small fw-normal">(optional)</span>
</label>
<textarea name="notes" id="notes" class="form-control" rows="3"
placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.">{{ v_notes }}</textarea>
<div class="form-text">
<i class="bi bi-info-circle"></i>
Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
</div>
</div>
{% if schedule %}
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="active" id="active"
{{ 'checked' if v_active }}>
<label class="form-check-label" for="active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Save
</button>
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</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>
{% endblock %}
{% block extra_js %}
<script>
// ── Contract → Facility → Area cascade ──────────────────────────────────────
// 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 () {
'use strict';
var contractSel = document.getElementById('contract_select');
var facilitySel = document.getElementById('facility_id');
var areaSel = document.getElementById('area_id');
var inspectorSel = document.getElementById('inspector_id');
if (!contractSel || !facilitySel) { return; }
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
var INSPECTORS_URL = '{{ url_for("inspection_schedules.inspectors_for_contract", project_id=0) }}'.replace('/0', '/');
var AREAS_URL = '{{ url_for("inspections.areas_for_facility", facility_id=0) }}'.replace('/0', '/');
var preProjectId = {{ selected_project_id | tojson }};
var preFacilityId = {{ v_facility | tojson }};
var preAreaId = {{ v_area | tojson }};
var preInspectorId = {{ v_inspector | tojson }};
function setPlaceholder() {
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
facilitySel.disabled = true;
loadAreas('', false);
if (inspectorSel) {
inspectorSel.innerHTML = '<option value="">— Select a Contract first —</option>';
inspectorSel.disabled = true;
}
}
// Inspectors come from the CONTRACT, not the facility: assignment rows are
// per contract. The server re-checks the chosen id against this same list.
function loadInspectors(projectId, restoreInspectorId) {
if (!inspectorSel) { return; }
inspectorSel.disabled = true;
inspectorSel.innerHTML = '<option value="">Loading…</option>';
fetch(INSPECTORS_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.length) {
// Only reachable for a Customer Director: our own staff fall back to
// the full inspector pool server-side. Say what is wrong rather than
// leaving an empty dropdown.
inspectorSel.innerHTML =
'<option value="">— No inspectors on this contract —</option>';
inspectorSel.disabled = false;
return;
}
inspectorSel.innerHTML = '<option value="">— Choose an inspector —</option>';
data.forEach(function (u) {
var opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.name;
if (restoreInspectorId && u.id === restoreInspectorId) { opt.selected = true; }
inspectorSel.appendChild(opt);
});
inspectorSel.disabled = false;
})
.catch(function () {
inspectorSel.innerHTML = '<option value="">Could not load inspectors</option>';
});
}
function loadAreas(facilityId, keepSelection) {
if (!areaSel) { return; }
areaSel.innerHTML = '<option value="">— Whole facility —</option>';
if (!facilityId) { return; }
fetch(AREAS_URL + facilityId)
.then(function (r) { return r.json(); })
.then(function (areas) {
areas.forEach(function (a) {
var opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name;
if (keepSelection && a.id === preAreaId) { opt.selected = true; }
areaSel.appendChild(opt);
});
})
.catch(function () { /* leave the whole-facility default in place */ });
}
function loadFacilities(projectId, restoreFacilityId) {
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);
loadInspectors(this.value, null);
} else {
setPlaceholder();
}
});
facilitySel.addEventListener('change', function () {
preAreaId = 0; // a new facility invalidates the saved area
loadAreas(this.value, false);
});
// Initial state: restore the contract, facility and area on edit / re-render.
if (preProjectId) {
contractSel.value = String(preProjectId);
loadFacilities(preProjectId, preFacilityId);
loadInspectors(preProjectId, preInspectorId);
} 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);
if (inspectorSel) { inspectorSel.disabled = false; }
} 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>
{% endblock %}