377 lines
18 KiB
HTML
377 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Inspections{% endblock %}
|
|
|
|
{#
|
|
MODERN inspections list (design A/B test).
|
|
|
|
Same context variables, same query params, same form field names and the same
|
|
three JS blocks as templates/inspections/list.html — only the chrome differs.
|
|
Nothing was dropped: every filter, column, badge, the pagination links and the
|
|
delete modal are carried over verbatim. `insp-list-link` is preserved on the
|
|
View/Continue buttons so filter-state restore on Back still works.
|
|
#}
|
|
|
|
{% block content %}
|
|
|
|
{# Any non-empty query param other than the page number means the user has
|
|
actually filtered — used to show the match count only when it is meaningful. #}
|
|
{% set active_filters = [] %}
|
|
{% for _k, _v in request.args.items() %}
|
|
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
|
|
{% endfor %}
|
|
|
|
{# ── Header ───────────────────────────────────────────────────────────── #}
|
|
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
|
<div>
|
|
<div class="jqc-page-title">Inspections</div>
|
|
</div>
|
|
{# Customer Directors schedule inspections for their own facilities, so the
|
|
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
|
|
<div class="d-flex gap-2">
|
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-primary">
|
|
<i class="bi bi-calendar-check"></i> Scheduled
|
|
</a>
|
|
{% if current_user.role != 'customer' %}
|
|
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
|
<i class="bi bi-plus-circle"></i> New Inspection
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
|
{# Layout: fields fill two rows on the left; the actions sit in a block on the
|
|
right that spans both rows — Filter full-height, Clear above Export PDF.
|
|
Below the md breakpoint the action block wraps underneath, full width. #}
|
|
<div class="jqc-filter-bar">
|
|
<form method="get">
|
|
<div class="d-flex flex-wrap gap-3 align-items-stretch">
|
|
|
|
{# ── Fields ──────────────────────────────────────────────────────── #}
|
|
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col-6 col-md-2">
|
|
<label class="form-label small mb-1">Inspection #</label>
|
|
<input type="number" name="inspection_id" class="form-control form-control-sm"
|
|
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
|
|
</div>
|
|
<div class="col-6 col-md-3">
|
|
<label class="form-label small mb-1">Status</label>
|
|
<select name="status" class="form-select form-select-sm">
|
|
<option value="">All Statuses</option>
|
|
{% for s in ['in_progress','completed','flagged'] %}
|
|
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
|
|
{% endfor %}
|
|
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
|
|
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-12 col-md-3">
|
|
<label class="form-label small mb-1">Contract</label>
|
|
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
|
|
<option value="">All Contracts</option>
|
|
{% for p in projects %}
|
|
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label small mb-1">Facility</label>
|
|
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
|
|
<option value="">All Facilities</option>
|
|
{% for f in facilities %}
|
|
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{# Second row. The score fields absorb the Inspector column's width when
|
|
the viewer is an inspector (they only ever see their own work, so the
|
|
dropdown is not rendered for them) — the row always totals 12. #}
|
|
<div class="row g-2 align-items-end mt-2">
|
|
{% if inspectors %}
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label small mb-1">Inspector</label>
|
|
<select name="inspector_id" class="form-select form-select-sm">
|
|
<option value="">All Inspectors</option>
|
|
{% for u in inspectors %}
|
|
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
{% endif %}
|
|
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
|
|
<label class="form-label small mb-1">Date From</label>
|
|
<input type="date" name="date_from" class="form-control form-control-sm"
|
|
value="{{ date_from_filter }}">
|
|
</div>
|
|
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
|
|
<label class="form-label small mb-1">Date To</label>
|
|
<input type="date" name="date_to" class="form-control form-control-sm"
|
|
value="{{ date_to_filter }}">
|
|
</div>
|
|
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
|
|
<label class="form-label small mb-1">Min Score</label>
|
|
<input type="number" name="score_min" class="form-control form-control-sm"
|
|
min="0" max="100" placeholder="0" value="{{ score_min_filter }}">
|
|
</div>
|
|
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
|
|
<label class="form-label small mb-1">Max Score</label>
|
|
<input type="number" name="score_max" class="form-control form-control-sm"
|
|
min="0" max="100" placeholder="100" value="{{ score_max_filter }}">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Actions — spans both field rows ─────────────────────────────── #}
|
|
{# mt-md-4 drops the block by roughly one label's height, so the buttons
|
|
line up with the first row's INPUTS rather than its labels — that is
|
|
what makes them shorter, since they still stretch to the bottom of the
|
|
second row. The margin is md-only; below that the block wraps
|
|
underneath the fields and needs its full width and natural height. #}
|
|
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
|
|
<button type="submit"
|
|
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
|
|
style="min-width:88px;">
|
|
<span><i class="bi bi-funnel"></i> Filter</span>
|
|
</button>
|
|
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
|
|
<a href="{{ url_for('inspections.index') }}"
|
|
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
|
|
style="min-width:104px;">Clear</a>
|
|
<a id="exportPdfBtn"
|
|
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
|
|
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
|
|
style="min-width:104px;">
|
|
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{# ── Results ──────────────────────────────────────────────────────────── #}
|
|
<div class="jqc-card">
|
|
{% if active_filters %}
|
|
<div class="text-muted small mb-2">
|
|
<i class="bi bi-funnel me-1"></i>
|
|
{{ inspections.total }} inspection{{ 's' if inspections.total != 1 }} match
|
|
{{ 'es' if inspections.total == 1 }} your filters
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if inspections.items %}
|
|
{% include 'partials/bulk_inspections_toolbar.html' %}
|
|
<div class="jqc-table-wrap table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:34px;">
|
|
<input type="checkbox" class="form-check-input bulk-check-all"
|
|
title="Select all on this page" aria-label="Select all">
|
|
</th>
|
|
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
|
<th>Template</th><th>Inspector</th><th>Score</th>
|
|
<th>Status</th><th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for ins in inspections.items %}
|
|
<tr>
|
|
<td>
|
|
<input type="checkbox" class="form-check-input bulk-check"
|
|
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
|
aria-label="Select inspection #{{ ins.id }}">
|
|
</td>
|
|
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
|
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
|
<td>{{ ins.facility.name }}</td>
|
|
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
|
<td>
|
|
{{ ins.template.name }}
|
|
{% if ins.scheduled_inspection_id %}
|
|
<span class="badge bg-info text-dark ms-1" title="From a scheduled inspection">
|
|
<i class="bi bi-calendar-check"></i> Scheduled
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ ins.inspector.display_name }}</td>
|
|
<td>
|
|
{% if ins.overall_score %}
|
|
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
|
|
{{ ins.overall_score }}%
|
|
</span>
|
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
|
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
|
|
</span>
|
|
{% if ins.status == 'in_progress' %}
|
|
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
|
|
{% if hours_open > 24 %}
|
|
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
|
|
<i class="bi bi-clock-history"></i> Stale
|
|
</span>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if ins.follow_up_required and not ins.follow_ups.count() %}
|
|
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
|
|
<i class="bi bi-arrow-repeat"></i> Follow-up
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-nowrap">
|
|
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
|
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
|
{% else %}
|
|
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
|
{% endif %}
|
|
{% if current_user.role in ['admin', 'director'] %}
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-danger ms-1"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#deleteInspectionModal"
|
|
data-inspection-id="{{ ins.id }}"
|
|
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
|
|
title="Delete inspection">
|
|
<i class="bi bi-trash3"></i>
|
|
</button>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{# Pagination #}
|
|
{% if inspections.pages > 1 %}
|
|
<div class="d-flex justify-content-center pt-3">
|
|
<nav><ul class="pagination pagination-sm mb-0">
|
|
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
|
{% if p %}
|
|
<li class="page-item {{ 'active' if p == inspections.page }}">
|
|
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
|
|
</li>
|
|
{% else %}
|
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</ul></nav>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% else %}
|
|
<div class="text-center py-5 text-muted">
|
|
<i class="bi bi-clipboard-x fs-2 d-block mb-2"></i>
|
|
No inspections found.
|
|
<div class="mt-2">
|
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if current_user.role in ['admin', 'director'] %}
|
|
<!-- Delete Inspection Confirmation Modal -->
|
|
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header bg-danger text-white">
|
|
<h5 class="modal-title" id="deleteInspectionModalLabel">
|
|
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
|
</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>You are about to permanently delete the following inspection:</p>
|
|
<p class="fw-bold" id="deleteInspectionLabel"></p>
|
|
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
<i class="bi bi-x-circle"></i> Cancel
|
|
</button>
|
|
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
|
<button type="submit" class="btn btn-danger">
|
|
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
{% include 'partials/bulk_select_js.html' %}
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
// Save current filtered URL so view/execute pages can restore it on Back
|
|
var links = document.querySelectorAll('.insp-list-link');
|
|
links.forEach(function (a) {
|
|
a.addEventListener('click', function () {
|
|
sessionStorage.setItem('insp_list_back_url', window.location.href);
|
|
});
|
|
});
|
|
}());
|
|
</script>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
var contractSel = document.getElementById('insp_filter_contract');
|
|
var facilitySel = document.getElementById('insp_filter_facility');
|
|
if (!contractSel || !facilitySel) return;
|
|
|
|
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
|
|
|
contractSel.addEventListener('change', function () {
|
|
var projectId = this.value;
|
|
facilitySel.value = '';
|
|
if (!projectId) {
|
|
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
|
return;
|
|
}
|
|
facilitySel.disabled = true;
|
|
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
|
fetch(FACILITIES_URL + projectId)
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var html = '<option value="">All Facilities</option>';
|
|
data.forEach(function (f) {
|
|
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
|
});
|
|
facilitySel.innerHTML = html;
|
|
facilitySel.disabled = false;
|
|
})
|
|
.catch(function () { facilitySel.disabled = false; });
|
|
});
|
|
}());
|
|
</script>
|
|
|
|
{% if current_user.role in ['admin', 'director'] %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const modal = document.getElementById('deleteInspectionModal');
|
|
modal.addEventListener('show.bs.modal', function (event) {
|
|
const btn = event.relatedTarget;
|
|
const id = btn.getAttribute('data-inspection-id');
|
|
const label = btn.getAttribute('data-inspection-label');
|
|
document.getElementById('deleteInspectionLabel').textContent = label;
|
|
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
|
|
});
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|