363 lines
16 KiB
HTML
363 lines
16 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}All Tickets{% endblock %}
|
||
{% block page_title %}All Tickets{% endblock %}
|
||
|
||
{% block content %}
|
||
<!-- Filters -->
|
||
<div class="card mb-4">
|
||
<div class="card-body">
|
||
<form method="GET" class="row g-2 align-items-end">
|
||
<div class="col-md-3">
|
||
<label class="form-label">Search</label>
|
||
<input type="text" class="form-control" name="q" value="{{ search }}"
|
||
placeholder="Title, ticket #, submitter, assignee, comments…"/>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">Status</label>
|
||
<select class="form-select" name="status">
|
||
<option value="">All Statuses</option>
|
||
{% for s in ['open','in_progress','pending','resolved','closed'] %}
|
||
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">Priority</label>
|
||
<select class="form-select" name="priority">
|
||
<option value="">All Priorities</option>
|
||
{% for p in ['low','medium','high','critical'] %}
|
||
<option value="{{ p }}" {% if p == priority %}selected{% endif %}>{{ p.title() }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">Assignment</label>
|
||
<select class="form-select" name="assigned">
|
||
<option value="">All</option>
|
||
<option value="me" {% if assigned == 'me' %}selected{% endif %}>Assigned to Me</option>
|
||
<option value="unassigned" {% if assigned == 'unassigned' %}selected{% endif %}>Unassigned</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3 d-flex gap-2">
|
||
<button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button>
|
||
<a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary" title="Clear filters"><i class="bi bi-x-lg"></i></a>
|
||
<a href="{{ url_for('admin.export_tickets', status=status, priority=priority, assigned=assigned, q=search) }}"
|
||
class="btn btn-secondary" title="Export current results to CSV">
|
||
<i class="bi bi-download me-1"></i>CSV
|
||
</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<i class="bi bi-collection me-2"></i>Tickets
|
||
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
|
||
</div>
|
||
<a href="{{ url_for('tickets.create_ticket_behalf') }}" class="btn btn-primary btn-sm">
|
||
<i class="bi bi-person-plus me-1"></i>File on Behalf
|
||
</a>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
{% if tickets.items %}
|
||
<table class="table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:36px;">
|
||
<input type="checkbox" id="select-all" title="Select all"
|
||
style="accent-color:var(--accent);width:15px;height:15px;cursor:pointer;"/>
|
||
</th>
|
||
<th>Ticket #</th>
|
||
<th>Title</th>
|
||
<th>Category</th>
|
||
<th>Status</th>
|
||
<th>Priority</th>
|
||
<th>Submitted By</th>
|
||
<th>Assigned To</th>
|
||
<th>Date</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for t in tickets.items %}
|
||
<tr>
|
||
<td>
|
||
<input type="checkbox" class="ticket-cb" value="{{ t.id }}"
|
||
style="accent-color:var(--accent);width:15px;height:15px;cursor:pointer;"/>
|
||
</td>
|
||
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</a></td>
|
||
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.title }}</td>
|
||
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
|
||
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
|
||
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
|
||
<td style="font-size:13px;color:var(--muted);">{{ t.assignee.full_name if t.assignee else '—' }}</td>
|
||
<td style="font-size:11px;color:var(--muted);">{{ t.created_at | localtime("%b %d") }}</td>
|
||
<td>
|
||
<div class="d-flex gap-1">
|
||
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"><i class="bi bi-eye"></i></a>
|
||
{% if current_user.is_admin %}
|
||
<button type="button"
|
||
class="btn btn-sm delete-ticket-btn"
|
||
style="background:#fee2e2;color:#dc2626;border:1px solid #fca5a5;"
|
||
data-ticket-id="{{ t.id }}"
|
||
data-ticket-number="{{ t.ticket_number }}"
|
||
data-ticket-title="{{ t.title | e }}"
|
||
title="Delete ticket">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
|
||
{% if tickets.pages > 1 %}
|
||
<div class="d-flex justify-content-center py-3">
|
||
<nav><ul class="pagination mb-0">
|
||
{% if tickets.has_prev %}
|
||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.prev_num, status=status, priority=priority, assigned=assigned, q=search) }}">‹</a></li>
|
||
{% endif %}
|
||
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||
{% if p %}
|
||
<li class="page-item {% if p==tickets.page %}active{% endif %}">
|
||
<a class="page-link" href="{{ url_for('admin.all_tickets', page=p, status=status, priority=priority, assigned=assigned, q=search) }}">{{ p }}</a>
|
||
</li>
|
||
{% else %}
|
||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||
{% endif %}
|
||
{% endfor %}
|
||
{% if tickets.has_next %}
|
||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.next_num, status=status, priority=priority, assigned=assigned, q=search) }}">›</a></li>
|
||
{% endif %}
|
||
</ul></nav>
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="p-5 text-center" style="color:var(--muted);">
|
||
<i class="bi bi-inbox" style="font-size:40px;display:block;margin-bottom:12px;"></i>No tickets match the current filters.
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Single-ticket delete form (hidden, submitted via JS) ────────────────── -->
|
||
{% if current_user.is_admin %}
|
||
<form id="single-delete-form" method="POST" action="" style="display:none;">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||
</form>
|
||
|
||
<!-- ── Confirmation modal for delete actions ──────────────────────────────── -->
|
||
<div id="delete-modal-overlay"
|
||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);
|
||
z-index:2000;align-items:center;justify-content:center;">
|
||
<div style="background:var(--card-bg,#fff);border-radius:12px;padding:28px 32px;
|
||
max-width:480px;width:90%;box-shadow:0 16px 48px rgba(0,0,0,.25);">
|
||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
||
<span style="background:#fee2e2;border-radius:50%;width:40px;height:40px;
|
||
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||
<i class="bi bi-exclamation-triangle-fill" style="color:#dc2626;font-size:18px;"></i>
|
||
</span>
|
||
<h5 id="modal-title" style="margin:0;font-size:16px;font-weight:700;">Delete Ticket</h5>
|
||
</div>
|
||
<p id="modal-body" style="font-size:14px;color:var(--muted,#64748b);margin-bottom:24px;line-height:1.6;"></p>
|
||
<div class="d-flex gap-2 justify-content-end">
|
||
<button type="button" id="modal-cancel-btn" class="btn btn-secondary btn-sm">Cancel</button>
|
||
<button type="button" id="modal-confirm-btn"
|
||
class="btn btn-sm" style="background:#dc2626;color:#fff;border:none;">
|
||
<i class="bi bi-trash me-1"></i>Delete Permanently
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Bulk action bar: fixed at bottom, hidden until checkboxes are ticked -->
|
||
<!-- NOTE: display is set entirely via JS — no inline display property here -->
|
||
<div id="bulk-action-bar"
|
||
style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
|
||
background:#1e293b;color:#fff;border-radius:12px;padding:12px 20px;
|
||
box-shadow:0 8px 32px rgba(0,0,0,.3);z-index:1000;
|
||
align-items:center;gap:12px;min-width:420px;">
|
||
<span id="bulk-count" style="font-size:13px;font-weight:600;white-space:nowrap;"></span>
|
||
<div style="flex:1;"></div>
|
||
<form method="POST" action="{{ url_for('admin.bulk_ticket_action') }}" id="bulk-form">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||
<input type="hidden" name="status" value="{{ status }}"/>
|
||
<input type="hidden" name="priority" value="{{ priority }}"/>
|
||
<input type="hidden" name="assigned" value="{{ assigned }}"/>
|
||
<input type="hidden" name="q" value="{{ search }}"/>
|
||
<div id="bulk-hidden-ids"></div>
|
||
<div class="d-flex gap-2">
|
||
<button type="submit" name="action" value="resolve"
|
||
class="btn btn-sm" style="background:#059669;color:#fff;border:none;">
|
||
<i class="bi bi-check2-circle me-1"></i>Resolve
|
||
</button>
|
||
<button type="submit" name="action" value="close"
|
||
class="btn btn-sm" style="background:#6b7280;color:#fff;border:none;">
|
||
<i class="bi bi-archive me-1"></i>Close
|
||
</button>
|
||
<button type="submit" name="action" value="assign_me"
|
||
class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;">
|
||
<i class="bi bi-person-check me-1"></i>Assign to Me
|
||
</button>
|
||
<button type="submit" name="action" value="unassign"
|
||
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
|
||
<i class="bi bi-person-dash me-1"></i>Unassign
|
||
</button>
|
||
{% if current_user.is_admin %}
|
||
<button type="button" id="bulk-delete-btn"
|
||
class="btn btn-sm" style="background:#dc2626;color:#fff;border:none;">
|
||
<i class="bi bi-trash me-1"></i>Delete
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</form>
|
||
<button type="button" onclick="clearSelection()"
|
||
style="background:none;border:none;color:#9ca3af;font-size:20px;cursor:pointer;
|
||
line-height:1;padding:0 0 0 4px;flex-shrink:0;" title="Clear selection">×</button>
|
||
</div>
|
||
|
||
<!-- Script is AFTER all DOM elements so querySelectorAll finds the checkboxes -->
|
||
<script>
|
||
(function () {
|
||
const bar = document.getElementById('bulk-action-bar');
|
||
const countEl = document.getElementById('bulk-count');
|
||
const hiddenIds = document.getElementById('bulk-hidden-ids');
|
||
const selectAll = document.getElementById('select-all');
|
||
|
||
// Start hidden — JS controls visibility entirely
|
||
bar.style.display = 'none';
|
||
|
||
function getChecked() {
|
||
return Array.from(document.querySelectorAll('.ticket-cb:checked'));
|
||
}
|
||
|
||
function updateBar() {
|
||
const checked = getChecked();
|
||
if (checked.length === 0) {
|
||
bar.style.display = 'none';
|
||
return;
|
||
}
|
||
bar.style.display = 'flex';
|
||
countEl.textContent = `${checked.length} ticket${checked.length !== 1 ? 's' : ''} selected`;
|
||
hiddenIds.innerHTML = checked.map(cb =>
|
||
`<input type="hidden" name="ticket_ids" value="${cb.value}"/>`
|
||
).join('');
|
||
}
|
||
|
||
window.clearSelection = function () {
|
||
document.querySelectorAll('.ticket-cb').forEach(cb => cb.checked = false);
|
||
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
|
||
updateBar();
|
||
};
|
||
|
||
if (selectAll) {
|
||
selectAll.addEventListener('change', () => {
|
||
document.querySelectorAll('.ticket-cb').forEach(cb => {
|
||
cb.checked = selectAll.checked;
|
||
});
|
||
updateBar();
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.ticket-cb').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
const all = document.querySelectorAll('.ticket-cb');
|
||
const done = document.querySelectorAll('.ticket-cb:checked');
|
||
if (selectAll) {
|
||
selectAll.indeterminate = done.length > 0 && done.length < all.length;
|
||
selectAll.checked = done.length === all.length && all.length > 0;
|
||
}
|
||
updateBar();
|
||
});
|
||
});
|
||
|
||
// ── Delete modal ──────────────────────────────────────────────────────────
|
||
const overlay = document.getElementById('delete-modal-overlay');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
const modalBody = document.getElementById('modal-body');
|
||
const cancelBtn = document.getElementById('modal-cancel-btn');
|
||
const confirmBtn = document.getElementById('modal-confirm-btn');
|
||
|
||
if (!overlay) return; // non-admin: modal not rendered
|
||
|
||
let pendingAction = null;
|
||
|
||
function showModal(title, body, onConfirm) {
|
||
modalTitle.textContent = title;
|
||
modalBody.innerHTML = body;
|
||
pendingAction = onConfirm;
|
||
overlay.style.display = 'flex';
|
||
}
|
||
|
||
function hideModal() {
|
||
overlay.style.display = 'none';
|
||
pendingAction = null;
|
||
}
|
||
|
||
cancelBtn.addEventListener('click', hideModal);
|
||
overlay.addEventListener('click', function (e) {
|
||
if (e.target === overlay) hideModal();
|
||
});
|
||
confirmBtn.addEventListener('click', function () {
|
||
if (typeof pendingAction === 'function') pendingAction();
|
||
hideModal();
|
||
});
|
||
|
||
// ── Single-ticket delete ──────────────────────────────────────────────────
|
||
document.querySelectorAll('.delete-ticket-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
const ticketId = this.dataset.ticketId;
|
||
const ticketNumber = this.dataset.ticketNumber;
|
||
const ticketTitle = this.dataset.ticketTitle;
|
||
|
||
showModal(
|
||
'Delete Ticket',
|
||
'This will <strong>permanently delete</strong> ticket ' +
|
||
'<code>' + ticketNumber + '</code> — <em>' + ticketTitle + '</em> ' +
|
||
'and all associated comments, attachments, and history.' +
|
||
'<br><br>This action <strong>cannot be undone</strong>.',
|
||
function () {
|
||
const form = document.getElementById('single-delete-form');
|
||
form.action = '/admin/tickets/' + ticketId + '/delete';
|
||
form.submit();
|
||
}
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── Bulk delete ───────────────────────────────────────────────────────────
|
||
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||
if (bulkDeleteBtn) {
|
||
bulkDeleteBtn.addEventListener('click', function () {
|
||
const checked = getChecked();
|
||
if (checked.length === 0) return;
|
||
const n = checked.length;
|
||
|
||
showModal(
|
||
'Delete Selected Tickets',
|
||
'This will <strong>permanently delete ' + n + ' ticket' + (n !== 1 ? 's' : '') + '</strong> ' +
|
||
'and all associated comments, attachments, and history.' +
|
||
'<br><br>This action <strong>cannot be undone</strong>.',
|
||
function () {
|
||
const form = document.getElementById('bulk-form');
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = 'action';
|
||
input.value = 'delete';
|
||
form.appendChild(input);
|
||
form.submit();
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
})();
|
||
</script>
|
||
{% endblock %}
|