355 lines
16 KiB
HTML
355 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Issues{% endblock %}
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
|
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
|
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
|
<i class="bi bi-plus-circle"></i> Log Issue
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-body py-2">
|
|
<form method="get" class="row g-2 align-items-end">
|
|
<div class="col-md-1">
|
|
<label class="form-label small mb-1">Issue #</label>
|
|
<input type="number" name="issue_id" class="form-control form-control-sm"
|
|
min="1" placeholder="ID" value="{{ issue_id_filter }}">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Severity</label>
|
|
<select name="severity" class="form-select form-select-sm">
|
|
<option value="">All</option>
|
|
{% for s in ['critical','high','medium','low'] %}
|
|
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Status</label>
|
|
<select name="status" class="form-select form-select-sm">
|
|
<option value="">All</option>
|
|
{% for s in ['open','in_progress','pending_verification','resolved'] %}
|
|
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">SLA</label>
|
|
<select name="sla" class="form-select form-select-sm">
|
|
<option value="">All</option>
|
|
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
|
|
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
|
|
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Contract</label>
|
|
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
|
|
<option value="">All Contracts</option>
|
|
{% for p in projects %}
|
|
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Facility</label>
|
|
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
|
|
<option value="">All Facilities</option>
|
|
{% for f in facilities %}
|
|
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Reported From</label>
|
|
<input type="date" name="date_from" class="form-control form-control-sm"
|
|
value="{{ date_from_filter }}">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Reported To</label>
|
|
<input type="date" name="date_to" class="form-control form-control-sm"
|
|
value="{{ date_to_filter }}">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Reporter</label>
|
|
<select name="reporter_id" class="form-select form-select-sm">
|
|
<option value="">All Reporters</option>
|
|
{% for u in reporters %}
|
|
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
{% if current_user.role not in ['customer'] %}
|
|
<div class="col-md-2">
|
|
<label class="form-label small mb-1">Handled By</label>
|
|
<select name="handler_type" class="form-select form-select-sm">
|
|
<option value="">All</option>
|
|
<option value="internal" {{ 'selected' if handler_type_filter == 'internal' }}>Janitorial Staff</option>
|
|
<option value="facility" {{ 'selected' if handler_type_filter == 'facility' }}>Facility Staff</option>
|
|
<option value="vendor" {{ 'selected' if handler_type_filter == 'vendor' }}>External Vendor</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" name="unassigned" value="1" id="unassignedCheck"
|
|
{{ 'checked' if unassigned_filter }}>
|
|
<label class="form-check-label small" for="unassignedCheck">Unassigned only</label>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
|
|
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
|
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
|
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
|
|
class="btn btn-sm btn-outline-danger">
|
|
<i class="bi bi-file-earmark-pdf"></i> Export PDF
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm">
|
|
<div class="card-body p-0">
|
|
{% if issues.items %}
|
|
{% include 'partials/bulk_issues_toolbar.html' %}
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<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>Reported</th>
|
|
<th>Severity</th>
|
|
<th>Contract</th>
|
|
<th>Facility / Area</th>
|
|
<th>Description</th>
|
|
<th>Status</th>
|
|
<th>SLA</th>
|
|
<th>Reporter</th>
|
|
<th>Assigned</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for issue in issues.items %}
|
|
{% set is_following = issue.id in followed_ids %}
|
|
{% set sla = sla_status(issue) %}
|
|
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
|
<td>
|
|
<input type="checkbox" class="form-check-input bulk-check"
|
|
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
|
|
aria-label="Select issue #{{ issue.id }}">
|
|
</td>
|
|
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
|
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
|
<td>
|
|
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
|
{{ issue.severity|title }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
|
|
<small>{{ _c.name if _c else '—' }}</small>
|
|
</td>
|
|
<td>
|
|
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
|
|
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
|
|
</td>
|
|
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
|
|
<td>
|
|
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
|
{{ issue.status|replace('_',' ')|title }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{% if sla == 'breached' %}
|
|
<span class="badge bg-danger" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
|
|
{% elif sla == 'at_risk' %}
|
|
{% set hrs = sla_hours_remaining(issue) %}
|
|
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
|
|
{% elif sla == 'ok' %}
|
|
<span class="badge bg-secondary">OK</span>
|
|
{% else %}
|
|
<span class="text-muted small">—</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if issue.reporter %}
|
|
<small>{{ issue.reporter.display_name }}</small>
|
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
|
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
|
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
|
<option value="">— Unassigned —</option>
|
|
{% for u in staff %}
|
|
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
|
</div>
|
|
{% else %}
|
|
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
|
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-nowrap">
|
|
{# Following badge + inline unfollow #}
|
|
{% if is_following %}
|
|
<span class="badge bg-primary me-1" title="You are following this issue">
|
|
<i class="bi bi-bell-fill"></i> Following
|
|
</span>
|
|
<form method="post"
|
|
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
|
|
class="d-inline"
|
|
title="Unfollow this issue">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
|
title="Unfollow">
|
|
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
|
|
class="btn btn-sm btn-outline-secondary">
|
|
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
|
<i class="bi bi-pencil"></i> Edit
|
|
{% else %}
|
|
<i class="bi bi-eye"></i> View
|
|
{% endif %}
|
|
</a>
|
|
{% if current_user.role in ['admin', 'director'] %}
|
|
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
|
|
class="d-inline"
|
|
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger"
|
|
title="Delete Issue #{{ issue.id }}">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{% if issues.pages > 1 %}
|
|
<div class="d-flex justify-content-center py-3">
|
|
<nav><ul class="pagination pagination-sm mb-0">
|
|
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
|
|
{% if p %}
|
|
<li class="page-item {{ 'active' if p == issues.page }}">
|
|
<a class="page-link"
|
|
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_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="alert alert-info m-3"><i class="bi bi-info-circle"></i> No issues found.</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
{% include 'partials/bulk_select_js.html' %}
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
var contractSel = document.getElementById('filter_contract_id');
|
|
var facilitySel = document.getElementById('filter_facility_id');
|
|
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 = ''; // reset facility selection
|
|
if (!projectId) {
|
|
// No contract selected — restore all-facilities placeholder and submit
|
|
// (server will return unfiltered facility list)
|
|
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', 'auditor'] %}
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
|
|
sel.dataset.previous = sel.value;
|
|
|
|
sel.addEventListener('change', function () {
|
|
const wrap = sel.closest('.quick-assign-wrap');
|
|
const issueId = wrap.dataset.issueId;
|
|
const spinner = wrap.querySelector('.quick-assign-spinner');
|
|
const userId = sel.value || null;
|
|
|
|
sel.disabled = true;
|
|
spinner.classList.remove('d-none');
|
|
|
|
fetch('/issues/' + issueId + '/quick-assign', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': '{{ csrf_token() }}',
|
|
},
|
|
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.ok) {
|
|
alert('Assignment failed: ' + (data.error || 'Unknown error'));
|
|
sel.value = sel.dataset.previous;
|
|
} else {
|
|
sel.dataset.previous = sel.value;
|
|
}
|
|
})
|
|
.catch(function () {
|
|
alert('Network error — assignment not saved.');
|
|
sel.value = sel.dataset.previous;
|
|
})
|
|
.finally(function () {
|
|
sel.disabled = false;
|
|
spinner.classList.add('d-none');
|
|
});
|
|
});
|
|
});
|
|
}());
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %} |