Fix ticket details page, comments updated real-time
This commit is contained in:
@@ -59,6 +59,45 @@ def mark_all_read():
|
|||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Comments API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route('/tickets/<int:ticket_id>/comments')
|
||||||
|
@login_required
|
||||||
|
def get_comments(ticket_id):
|
||||||
|
"""Return all visible comments for a ticket as JSON."""
|
||||||
|
from app.models import Ticket, Comment, UserRole
|
||||||
|
ticket = Ticket.query.get_or_404(ticket_id)
|
||||||
|
# Employees may only see their own tickets
|
||||||
|
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
|
||||||
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
|
||||||
|
q = Comment.query.filter_by(ticket_id=ticket_id)
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
q = q.filter_by(is_internal=False)
|
||||||
|
comments = q.order_by(Comment.created_at.asc()).all()
|
||||||
|
|
||||||
|
return jsonify({'comments': [
|
||||||
|
{
|
||||||
|
'id' : c.id,
|
||||||
|
'author_name': c.author.full_name,
|
||||||
|
'author_init': c.author.full_name[0].upper(),
|
||||||
|
'is_it_staff': c.author.is_it_staff,
|
||||||
|
'is_internal': c.is_internal,
|
||||||
|
'body' : c.body,
|
||||||
|
'created_at' : c.created_at.strftime('%b %d, %Y %H:%M'),
|
||||||
|
'can_delete' : current_user.is_it_staff or c.author_id == current_user.id,
|
||||||
|
'attachments': [
|
||||||
|
{
|
||||||
|
'id' : a.id,
|
||||||
|
'filename': a.filename,
|
||||||
|
}
|
||||||
|
for a in c.attachments.all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for c in comments
|
||||||
|
]})
|
||||||
|
|
||||||
|
|
||||||
# ─── Ticket Stats API (IT) ────────────────────────────────────────────────────
|
# ─── Ticket Stats API (IT) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@api_bp.route('/stats/tickets')
|
@api_bp.route('/stats/tickets')
|
||||||
|
|||||||
@@ -214,6 +214,29 @@ def notify_comment_added(comment):
|
|||||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
|
# ── Real-time push to everyone viewing this ticket ────────────────────────
|
||||||
|
# Emit to the ticket room so all users currently on the ticket detail page
|
||||||
|
# receive the new comment immediately without needing to reload.
|
||||||
|
payload = {
|
||||||
|
'id' : comment.id,
|
||||||
|
'author_name': comment.author.full_name,
|
||||||
|
'author_init': comment.author.full_name[0].upper(),
|
||||||
|
'is_it_staff': comment.author.is_it_staff,
|
||||||
|
'is_internal': comment.is_internal,
|
||||||
|
'body' : comment.body,
|
||||||
|
'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'),
|
||||||
|
'author_id' : comment.author_id,
|
||||||
|
}
|
||||||
|
def _emit_comment():
|
||||||
|
socketio.emit(
|
||||||
|
'new_comment',
|
||||||
|
payload,
|
||||||
|
to = f'ticket_{ticket.id}',
|
||||||
|
namespace = '/',
|
||||||
|
)
|
||||||
|
socketio.start_background_task(_emit_comment)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
notified = set()
|
notified = set()
|
||||||
|
|
||||||
def _notify(user_id, is_internal=False):
|
def _notify(user_id, is_internal=False):
|
||||||
|
|||||||
@@ -67,14 +67,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table mb-0">
|
<table class="table mb-0">
|
||||||
<thead><tr><th>Ticket #</th><th>User</th><th>Category</th><th>Priority</th></tr></thead>
|
<thead><tr><th>Ticket #</th><th>User</th><th>Priority</th><th>Status</th><th>Assignee</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for t in recent_tickets %}
|
{% for t in recent_tickets %}
|
||||||
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
|
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
|
||||||
<td><span class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
|
<td><span class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
|
||||||
<td style="font-size:13px;">{{ t.creator.full_name.split()[0] }}</td>
|
<td style="font-size:13px;">{{ t.creator.full_name.split()[0] }}</td>
|
||||||
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
|
|
||||||
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">
|
||||||
|
{% if t.assignee %}
|
||||||
|
<span title="{{ t.assignee.full_name }}">{{ t.assignee.full_name.split()[0] }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--muted);font-style:italic;">Unassigned</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -101,4 +108,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -64,10 +64,15 @@
|
|||||||
<div class="mb-3 d-flex align-items-center justify-content-between">
|
<div class="mb-3 d-flex align-items-center justify-content-between">
|
||||||
<h5 style="font-size:16px;font-weight:700;margin:0;">
|
<h5 style="font-size:16px;font-weight:700;margin:0;">
|
||||||
<i class="bi bi-chat-dots me-2"></i>Comments
|
<i class="bi bi-chat-dots me-2"></i>Comments
|
||||||
<span style="font-size:13px;color:var(--muted);">({{ comments|length }})</span>
|
<span id="comment-count" style="font-size:13px;color:var(--muted);">({{ comments|length }})</span>
|
||||||
</h5>
|
</h5>
|
||||||
|
<button id="refresh-comments-btn" onclick="refreshComments()"
|
||||||
|
class="btn btn-secondary btn-sm" title="Refresh comments">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="comments-list">
|
||||||
{% for comment in comments %}
|
{% for comment in comments %}
|
||||||
<div class="comment-card {% if comment.is_internal %}internal{% endif %}" id="comment-{{ comment.id }}">
|
<div class="comment-card {% if comment.is_internal %}internal{% endif %}" id="comment-{{ comment.id }}">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
@@ -98,7 +103,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-body">{{ comment.body }}</div>
|
<div class="comment-body">{{ comment.body }}</div>
|
||||||
<!-- Comment attachments -->
|
|
||||||
{% set c_atts = comment.attachments.all() %}
|
{% set c_atts = comment.attachments.all() %}
|
||||||
{% if c_atts %}
|
{% if c_atts %}
|
||||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||||
@@ -111,21 +115,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">
|
<div id="no-comments-placeholder" class="p-4 text-center" style="color:var(--muted);font-size:13px;">
|
||||||
<i class="bi bi-chat" style="font-size:28px;display:block;margin-bottom:8px;"></i>
|
<i class="bi bi-chat" style="font-size:28px;display:block;margin-bottom:8px;"></i>
|
||||||
No comments yet. Be the first to add an update.
|
No comments yet. Be the first to add an update.
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add comment -->
|
<!-- Add comment -->
|
||||||
{% if ticket.status not in ('closed',) %}
|
{% if ticket.status not in ('closed',) %}
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header"><i class="bi bi-chat-plus me-2"></i>Add Comment</div>
|
<div class="card-header"><i class="bi bi-chat-plus me-2"></i>Add Comment</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form id="comment-form" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<textarea class="form-control" name="body" rows="4" required
|
<textarea id="comment-body" class="form-control" name="body" rows="4" required
|
||||||
placeholder="Add your update, follow-up, or response here…"></textarea>
|
placeholder="Add your update, follow-up, or response here…"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -141,13 +146,205 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-primary">
|
<div id="comment-error" style="display:none;color:var(--danger);font-size:13px;margin-bottom:8px;"></div>
|
||||||
|
<button type="submit" id="comment-submit-btn" class="btn btn-primary">
|
||||||
<i class="bi bi-send me-2"></i>Post Comment
|
<i class="bi bi-send me-2"></i>Post Comment
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TICKET_ID = {{ ticket.id }};
|
||||||
|
const CURRENT_UID = {{ current_user.id }};
|
||||||
|
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
|
||||||
|
const DELETE_URLS = {}; // populated dynamically for new comments
|
||||||
|
let seenCommentIds = new Set([{% for c in comments %}{{ c.id }},{% endfor %}]);
|
||||||
|
|
||||||
|
// ── Join the ticket's socket room ─────────────────────────────────────────────
|
||||||
|
if (typeof socket !== 'undefined') {
|
||||||
|
socket.emit('join_ticket', { ticket_id: TICKET_ID });
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
socket.emit('leave_ticket', { ticket_id: TICKET_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Real-time: new comment pushed from server ─────────────────────────────
|
||||||
|
socket.on('new_comment', function(c) {
|
||||||
|
// Ignore if we already rendered this comment (e.g. the author submitted it
|
||||||
|
// via AJAX and we already appended it optimistically).
|
||||||
|
if (seenCommentIds.has(c.id)) return;
|
||||||
|
// Also skip internal notes for non-IT-staff users
|
||||||
|
if (c.is_internal && !IS_IT_STAFF) return;
|
||||||
|
seenCommentIds.add(c.id);
|
||||||
|
appendComment(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX comment form submit ──────────────────────────────────────────────────
|
||||||
|
document.getElementById('comment-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const body = document.getElementById('comment-body').value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('comment-submit-btn');
|
||||||
|
const err = document.getElementById('comment-error');
|
||||||
|
err.style.display = 'none';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Posting…';
|
||||||
|
|
||||||
|
const fd = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(window.location.pathname, {
|
||||||
|
method : 'POST',
|
||||||
|
credentials : 'same-origin',
|
||||||
|
body : fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.redirected || resp.ok) {
|
||||||
|
// Success — clear the form
|
||||||
|
document.getElementById('comment-body').value = '';
|
||||||
|
const fileInput = this.querySelector('input[type="file"]');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
const internalCb = document.getElementById('is_internal');
|
||||||
|
if (internalCb) internalCb.checked = false;
|
||||||
|
// The server will push the new comment via socket to all viewers including us.
|
||||||
|
// Fetch the latest comments to make sure we have it (handles the case where
|
||||||
|
// the socket push arrives before or after the AJAX response).
|
||||||
|
await refreshComments();
|
||||||
|
} else {
|
||||||
|
err.textContent = 'Failed to post comment (HTTP ' + resp.status + '). Please try again.';
|
||||||
|
err.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = 'Network error. Please check your connection.';
|
||||||
|
err.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-send me-2"></i>Post Comment';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Refresh comments from server ──────────────────────────────────────────────
|
||||||
|
async function refreshComments() {
|
||||||
|
const btn = document.getElementById('refresh-comments-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.querySelector('i').classList.add('spin'); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/tickets/' + TICKET_ID + '/comments', {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
renderComments(data.comments);
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.querySelector('i').classList.remove('spin'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render a full comment list from API data ──────────────────────────────────
|
||||||
|
function renderComments(comments) {
|
||||||
|
const list = document.getElementById('comments-list');
|
||||||
|
const countEl = document.getElementById('comment-count');
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
list.innerHTML =
|
||||||
|
'<div id="no-comments-placeholder" class="p-4 text-center" style="color:var(--muted);font-size:13px;">' +
|
||||||
|
'<i class="bi bi-chat" style="font-size:28px;display:block;margin-bottom:8px;"></i>' +
|
||||||
|
'No comments yet. Be the first to add an update.</div>';
|
||||||
|
seenCommentIds = new Set();
|
||||||
|
if (countEl) countEl.textContent = '(0)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild only if the set of IDs has changed (avoids unnecessary DOM churn)
|
||||||
|
const newIds = new Set(comments.map(c => c.id));
|
||||||
|
const changed = comments.some(c => !seenCommentIds.has(c.id)) ||
|
||||||
|
[...seenCommentIds].some(id => !newIds.has(id));
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
seenCommentIds = new Set();
|
||||||
|
comments.forEach(c => {
|
||||||
|
seenCommentIds.add(c.id);
|
||||||
|
list.appendChild(buildCommentEl(c));
|
||||||
|
});
|
||||||
|
if (countEl) countEl.textContent = '(' + comments.length + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Append a single new comment (from socket push) ────────────────────────────
|
||||||
|
function appendComment(c) {
|
||||||
|
const placeholder = document.getElementById('no-comments-placeholder');
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
|
const list = document.getElementById('comments-list');
|
||||||
|
list.appendChild(buildCommentEl(c));
|
||||||
|
|
||||||
|
const countEl = document.getElementById('comment-count');
|
||||||
|
if (countEl) {
|
||||||
|
const n = parseInt(countEl.textContent.replace(/\D/g, '')) || 0;
|
||||||
|
countEl.textContent = '(' + (n + 1) + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll new comment into view smoothly
|
||||||
|
const el = document.getElementById('comment-' + c.id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build a comment DOM element from API data ─────────────────────────────────
|
||||||
|
function buildCommentEl(c) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'comment-card' + (c.is_internal ? ' internal' : '');
|
||||||
|
wrap.id = 'comment-' + c.id;
|
||||||
|
|
||||||
|
const itBadge = c.is_it_staff
|
||||||
|
? '<span style="font-size:10px;background:rgba(0,180,216,.15);color:var(--accent3);padding:1px 7px;border-radius:4px;font-weight:600;">IT STAFF</span>'
|
||||||
|
: '';
|
||||||
|
const intBadge = c.is_internal
|
||||||
|
? '<span style="font-size:10px;background:rgba(251,191,36,.15);color:var(--warning);padding:1px 7px;border-radius:4px;font-weight:600;">INTERNAL NOTE</span>'
|
||||||
|
: '';
|
||||||
|
const deleteBtn = c.can_delete
|
||||||
|
? `<form method="POST" action="/comments/${c.id}/delete" onsubmit="return confirm('Delete this comment?');" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="${document.querySelector('meta[name=csrf-token]').content}"/>
|
||||||
|
<button type="submit" class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" title="Delete comment">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>`
|
||||||
|
: '';
|
||||||
|
const atts = (c.attachments || []).map(a =>
|
||||||
|
`<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>${a.filename}
|
||||||
|
</a>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
|
||||||
|
${c.author_init}
|
||||||
|
</div>
|
||||||
|
<span class="comment-author">${c.author_name}</span>
|
||||||
|
${itBadge}${intBadge}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="comment-time">${c.created_at}</span>
|
||||||
|
${deleteBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">${c.body}</div>
|
||||||
|
${atts ? '<div class="mt-2 d-flex flex-wrap gap-2">' + atts + '</div>' : ''}
|
||||||
|
`;
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.spin { animation: spin .6s linear infinite; display: inline-block; }
|
||||||
|
</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar column -->
|
<!-- Sidebar column -->
|
||||||
|
|||||||
Reference in New Issue
Block a user