803 lines
38 KiB
HTML
803 lines
38 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ ticket.ticket_number }}{% endblock %}
|
|
{% block page_title %}{{ ticket.ticket_number }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row g-4">
|
|
<!-- Main column -->
|
|
<div class="col-lg-8">
|
|
|
|
<!-- Ticket header -->
|
|
<div class="ticket-header mb-4">
|
|
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
|
|
<h2 style="font-size:20px;font-weight:700;line-height:1.3;margin:0;">{{ ticket.title }}</h2>
|
|
<div class="d-flex gap-2 flex-shrink-0">
|
|
<span class="badge badge-{{ ticket.priority }}">{{ ticket.priority.upper() }}</span>
|
|
<span class="badge badge-{{ ticket.status }}">{{ ticket.status.replace('_',' ').upper() }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-3" style="font-size:12px;color:var(--muted);">
|
|
<span><i class="bi bi-hash me-1"></i><span class="mono">{{ ticket.ticket_number }}</span></span>
|
|
<span><i class="bi bi-person me-1"></i>{{ ticket.creator.full_name }}</span>
|
|
{% if ticket.filed_by_staff %}
|
|
<span style="background:var(--info-bg);color:var(--info);border:1px solid #bae6fd;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:600;">
|
|
<i class="bi bi-person-badge me-1"></i>Filed by {{ ticket.filed_by_staff.full_name }}
|
|
</span>
|
|
{% endif %}
|
|
<span><i class="bi bi-tag me-1"></i>{{ ticket.category.replace('_',' ').title() }}</span>
|
|
<span><i class="bi bi-calendar3 me-1"></i>{{ ticket.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
|
{% if ticket.location %}<span><i class="bi bi-geo-alt me-1"></i>{{ ticket.location }}</span>{% endif %}
|
|
{% if ticket.asset_tag %}<span><i class="bi bi-cpu me-1"></i>{{ ticket.asset_tag }}</span>{% endif %}
|
|
{% if ticket.ai_generated %}<span style="color:var(--accent3);"><i class="bi bi-robot me-1"></i>AI-generated</span>{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="card mb-4">
|
|
<div class="card-header"><i class="bi bi-file-text me-2"></i>Description</div>
|
|
<div class="card-body" style="white-space:pre-wrap;font-size:14px;line-height:1.7;">{{ ticket.description }}</div>
|
|
</div>
|
|
|
|
<!-- Attachments on ticket -->
|
|
{% set ticket_atts = ticket.attachments.filter_by(comment_id=None).all() %}
|
|
{% if ticket_atts %}
|
|
<div class="card mb-4">
|
|
<div class="card-header"><i class="bi bi-paperclip me-2"></i>Attachments</div>
|
|
<div class="card-body">
|
|
<div class="d-flex flex-wrap gap-2 align-items-start">
|
|
{% for att in ticket_atts %}
|
|
{% set ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else '' %}
|
|
{% set is_img = (att.mime_type and att.mime_type.startswith('image/')) or ext in ('png','jpg','jpeg','gif','webp','svg') %}
|
|
{% if is_img %}
|
|
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
|
target="_blank" class="comment-img-link" title="{{ att.filename }}">
|
|
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
|
alt="{{ att.filename }}"
|
|
class="comment-img-thumb"/>
|
|
</a>
|
|
{% else %}
|
|
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
|
class="btn btn-secondary btn-sm">
|
|
<i class="bi bi-download me-1"></i>{{ att.filename }}
|
|
<span style="font-size:10px;color:var(--muted);">({{ (att.file_size/1024)|int }}KB)</span>
|
|
</a>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Resolution notes (IT only, or if resolved) -->
|
|
{% if ticket.resolution_notes %}
|
|
<div class="card mb-4" style="border-color:var(--success);">
|
|
<div class="card-header" style="background:rgba(45,212,191,.08);color:var(--success);">
|
|
<i class="bi bi-check-circle me-2"></i>Resolution Notes
|
|
</div>
|
|
<div class="card-body" style="white-space:pre-wrap;font-size:14px;">{{ ticket.resolution_notes }}</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Comments -->
|
|
<div class="mb-3 d-flex align-items-center justify-content-between">
|
|
<h5 style="font-size:16px;font-weight:700;margin:0;">
|
|
<i class="bi bi-chat-dots me-2"></i>Comments
|
|
<span id="comment-count" style="font-size:13px;color:var(--muted);">({{ comments|length }})</span>
|
|
</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 id="comments-list">
|
|
{% for comment in comments %}
|
|
<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 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;overflow:hidden;flex-shrink:0;">
|
|
{% if comment.author.avatar_url %}
|
|
<img src="{{ url_for('auth.serve_avatar', filename=comment.author.avatar_url) }}"
|
|
alt="{{ comment.author.full_name[0].upper() }}"
|
|
style="width:28px;height:28px;object-fit:cover;display:block;"/>
|
|
{% else %}
|
|
{{ comment.author.full_name[0].upper() }}
|
|
{% endif %}
|
|
</div>
|
|
<span class="comment-author">{{ comment.author.full_name }}</span>
|
|
{% if comment.author.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>
|
|
{% endif %}
|
|
{% if comment.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>
|
|
{% endif %}
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="comment-time">{{ comment.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
|
{% if current_user.is_it_staff or comment.author_id == current_user.id %}
|
|
<form method="POST" action="{{ url_for('tickets.delete_comment', comment_id=comment.id) }}"
|
|
onsubmit="return confirm('Delete this comment?');">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<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>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="comment-body">{{ comment.body | safe }}</div>
|
|
{% set c_atts = comment.attachments.all() %}
|
|
{% if c_atts %}
|
|
<div class="mt-2">
|
|
{% for att in c_atts %}
|
|
{% set ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else '' %}
|
|
{% set is_img = (att.mime_type and att.mime_type.startswith('image/')) or ext in ('png','jpg','jpeg','gif','webp','svg') %}
|
|
{% if is_img %}
|
|
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
|
target="_blank" class="comment-img-link">
|
|
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
|
alt="{{ att.filename }}"
|
|
class="comment-img-thumb"/>
|
|
</a>
|
|
{% else %}
|
|
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
|
class="btn btn-secondary btn-sm me-1 mb-1">
|
|
<i class="bi bi-download me-1"></i>{{ att.filename }}
|
|
</a>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<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>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Add comment -->
|
|
{% if ticket.status not in ('closed',) %}
|
|
<div class="card mt-4">
|
|
<div class="card-header"><i class="bi bi-chat-plus me-2"></i>Add Comment</div>
|
|
<div class="card-body">
|
|
<form id="comment-form" enctype="multipart/form-data">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<div class="mb-3">
|
|
<textarea id="comment-body" class="form-control" name="body" rows="4" required
|
|
placeholder="Add your update… Markdown supported: **bold**, _italic_, `code`, - lists, > quotes (you can also paste images directly)"></textarea>
|
|
</div>
|
|
<!-- Image preview strip — populated by paste or file picker -->
|
|
<div id="img-preview-strip" style="display:none;flex-wrap:wrap;gap:8px;margin-bottom:12px;"></div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Attachments
|
|
<span style="font-size:11px;color:var(--muted);font-weight:400;"> · images, PDF, documents — or paste an image into the text box</span>
|
|
</label>
|
|
<input id="attachment-input" type="file" class="form-control" name="attachments" multiple
|
|
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
|
|
</div>
|
|
{% if current_user.is_it_staff %}
|
|
<!-- Canned responses picker -->
|
|
{% if canned_responses %}
|
|
<div class="mb-3">
|
|
<div class="dropdown">
|
|
<button type="button" class="btn btn-secondary btn-sm dropdown-toggle"
|
|
id="canned-responses-btn" data-bs-toggle="dropdown" aria-expanded="false">
|
|
<i class="bi bi-chat-square-text me-1"></i>Quick Replies
|
|
</button>
|
|
<ul class="dropdown-menu" style="max-height:260px;overflow-y:auto;min-width:300px;">
|
|
{% set ns = namespace(last_cat='') %}
|
|
{% for r in canned_responses %}
|
|
{% if r.category and r.category != ns.last_cat %}
|
|
{% if ns.last_cat %}<li><hr class="dropdown-divider"/></li>{% endif %}
|
|
<li><span class="dropdown-header" style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;">{{ r.category }}</span></li>
|
|
{% set ns.last_cat = r.category %}
|
|
{% endif %}
|
|
<li>
|
|
<button type="button" class="dropdown-item" style="font-size:13px;white-space:normal;"
|
|
onclick="insertCannedResponse({{ r.id }})">
|
|
{{ r.title }}
|
|
</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="mb-3 d-flex align-items-center gap-2">
|
|
<input type="checkbox" id="is_internal" name="is_internal" style="accent-color:var(--warning);"/>
|
|
<label for="is_internal" style="font-size:13px;color:var(--warning);margin:0;cursor:pointer;">
|
|
<i class="bi bi-lock me-1"></i>Internal note (IT staff only)
|
|
</label>
|
|
</div>
|
|
{% endif %}
|
|
<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
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<script>
|
|
const CANNED_RESPONSES = {
|
|
{% for r in canned_responses %}
|
|
{{ r.id }}: {{ r.body | tojson }},
|
|
{% endfor %}
|
|
};
|
|
function insertCannedResponse(id) {
|
|
const body = CANNED_RESPONSES[id];
|
|
if (!body) return;
|
|
const ta = document.getElementById('comment-body');
|
|
const cur = ta.value;
|
|
ta.value = cur ? cur + '\n\n' + body : body;
|
|
ta.focus();
|
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
}
|
|
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 ATTACHMENT_URL = '{{ url_for("tickets.download_attachment", att_id=0) }}'.replace('/0', '/');
|
|
const DELETE_COMMENT_URL = '{{ url_for("tickets.delete_comment", comment_id=0) }}'.replace('/0/', '/{id}/');
|
|
const AVATAR_URL = '{{ url_for("auth.serve_avatar", filename="__name__") }}'.replace('__name__', '');
|
|
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);
|
|
});
|
|
}
|
|
|
|
// ── Pasted-image accumulator ──────────────────────────────────────────────────
|
|
// Files added via clipboard paste are stored here and merged into the FormData
|
|
// on submit, since a paste event cannot modify a real <input type=file>.
|
|
let pastedFiles = [];
|
|
|
|
// ── Attachment limits (enforced client-side here AND server-side in tickets.py)
|
|
const MAX_ATTACHMENT_FILES = 5;
|
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB total per comment
|
|
|
|
function getAttachmentError(allFiles) {
|
|
if (allFiles.length > MAX_ATTACHMENT_FILES) {
|
|
return `Too many files — maximum ${MAX_ATTACHMENT_FILES} attachments per comment.`;
|
|
}
|
|
const totalBytes = allFiles.reduce((sum, f) => sum + f.size, 0);
|
|
if (totalBytes > MAX_ATTACHMENT_BYTES) {
|
|
const mb = (MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(0);
|
|
return `Total attachment size exceeds the ${mb} MB limit per comment.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rebuildPreviewStrip() {
|
|
// Collect all files: pasted images + files chosen via the picker
|
|
const pickerFiles = Array.from(document.getElementById('attachment-input').files || []);
|
|
const allFiles = [...pastedFiles, ...pickerFiles];
|
|
const strip = document.getElementById('img-preview-strip');
|
|
const err = document.getElementById('comment-error');
|
|
|
|
// Show/clear the limit error alongside the preview strip
|
|
const limitErr = getAttachmentError(allFiles);
|
|
if (limitErr) {
|
|
err.textContent = limitErr;
|
|
err.style.display = 'block';
|
|
} else {
|
|
// Only clear the error if it was an attachment limit message
|
|
if (err.textContent && err.textContent.includes('attachment')) {
|
|
err.style.display = 'none';
|
|
err.textContent = '';
|
|
}
|
|
}
|
|
|
|
if (allFiles.length === 0) { strip.style.display = 'none'; strip.innerHTML = ''; return; }
|
|
|
|
strip.style.display = 'flex';
|
|
strip.innerHTML = '';
|
|
allFiles.forEach((file, idx) => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.style.cssText = 'position:relative;display:inline-block;';
|
|
|
|
if (file.type.startsWith('image/')) {
|
|
const img = document.createElement('img');
|
|
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--border);cursor:pointer;';
|
|
img.title = file.name;
|
|
img.src = URL.createObjectURL(file);
|
|
img.onclick = () => window.open(img.src, '_blank');
|
|
wrapper.appendChild(img);
|
|
} else {
|
|
const label = document.createElement('div');
|
|
label.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);text-align:center;padding:4px;word-break:break-all;background:var(--surface);';
|
|
label.textContent = file.name;
|
|
wrapper.appendChild(label);
|
|
}
|
|
|
|
// Only pasted files (idx < pastedFiles.length) get a remove button
|
|
// Picker files are managed via the native file input
|
|
if (idx < pastedFiles.length) {
|
|
const rm = document.createElement('button');
|
|
rm.type = 'button';
|
|
rm.innerHTML = '×';
|
|
rm.style.cssText = 'position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:var(--danger);color:#fff;font-size:12px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;';
|
|
rm.onclick = () => { pastedFiles.splice(idx, 1); rebuildPreviewStrip(); };
|
|
wrapper.appendChild(rm);
|
|
}
|
|
strip.appendChild(wrapper);
|
|
});
|
|
}
|
|
|
|
// Paste images from clipboard into the textarea
|
|
document.getElementById('comment-body').addEventListener('paste', function(e) {
|
|
const items = (e.clipboardData || window.clipboardData).items;
|
|
let caught = false;
|
|
for (const item of items) {
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
// Give the file a deterministic name
|
|
const ext = item.type.split('/')[1] || 'png';
|
|
const named = new File([file], `paste-${Date.now()}.${ext}`, { type: item.type });
|
|
pastedFiles.push(named);
|
|
caught = true;
|
|
}
|
|
}
|
|
}
|
|
if (caught) { e.preventDefault(); rebuildPreviewStrip(); }
|
|
});
|
|
|
|
// Keep preview in sync when user changes the file picker selection
|
|
document.getElementById('attachment-input').addEventListener('change', rebuildPreviewStrip);
|
|
|
|
// ── 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…';
|
|
|
|
// Build FormData manually so we can append pasted files (which are not in
|
|
// the real <input type=file> and therefore not included automatically).
|
|
const fd = new FormData(this);
|
|
pastedFiles.forEach(f => fd.append('attachments', f, f.name));
|
|
|
|
// Client-side attachment limit check — mirrors server-side guard in tickets.py
|
|
const pickerFiles = Array.from(document.getElementById('attachment-input').files || []);
|
|
const allFiles = [...pastedFiles, ...pickerFiles];
|
|
const limitErr = getAttachmentError(allFiles);
|
|
if (limitErr) {
|
|
err.textContent = limitErr;
|
|
err.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-send me-2"></i>Post Comment';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(window.location.pathname, {
|
|
method : 'POST',
|
|
credentials : 'same-origin',
|
|
body : fd,
|
|
});
|
|
|
|
if (resp.redirected || resp.ok) {
|
|
document.getElementById('comment-body').value = '';
|
|
document.getElementById('attachment-input').value = '';
|
|
pastedFiles = [];
|
|
rebuildPreviewStrip();
|
|
const internalCb = document.getElementById('is_internal');
|
|
if (internalCb) internalCb.checked = false;
|
|
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="${DELETE_COMMENT_URL.replace('{id}', c.id)}" 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 IMG_EXTS = new Set(['png','jpg','jpeg','gif','webp','svg']);
|
|
const atts = (c.attachments || []).map(a => {
|
|
const url = ATTACHMENT_URL + a.id;
|
|
const ext = a.filename.includes('.') ? a.filename.split('.').pop().toLowerCase() : '';
|
|
const isImg = a.is_image || IMG_EXTS.has(ext);
|
|
if (isImg) {
|
|
return `<a href="${url}" target="_blank" class="comment-img-link">
|
|
<img src="${url}" alt="${a.filename}" class="comment-img-thumb"/>
|
|
</a>`;
|
|
}
|
|
return `<a href="${url}" class="btn btn-secondary btn-sm me-1 mb-1">
|
|
<i class="bi bi-download me-1"></i>${a.filename}
|
|
</a>`;
|
|
}).join('');
|
|
|
|
const avatarInner = c.author_avatar
|
|
? `<img src="${AVATAR_URL}${c.author_avatar}" alt="${c.author_init}"
|
|
style="width:28px;height:28px;object-fit:cover;display:block;border-radius:50%;"/>`
|
|
: c.author_init;
|
|
|
|
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;overflow:hidden;flex-shrink:0;">
|
|
${avatarInner}
|
|
</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; }
|
|
.comment-img-thumb {
|
|
max-width: 220px;
|
|
max-height: 180px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
cursor: pointer;
|
|
display: block;
|
|
margin: 4px 4px 0 0;
|
|
transition: opacity .15s;
|
|
}
|
|
.comment-img-thumb:hover { opacity: .85; }
|
|
.comment-img-link { display: inline-block; }
|
|
</style>
|
|
</div>
|
|
|
|
<!-- Sidebar column -->
|
|
<div class="col-lg-4">
|
|
|
|
<!-- IT Update Panel -->
|
|
{% if current_user.is_it_staff %}
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-pencil-square me-2"></i>Update Ticket</div>
|
|
<div class="card-body">
|
|
<form method="POST" action="{{ url_for('tickets.update_ticket', ticket_id=ticket.id) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<div class="mb-3">
|
|
<label class="form-label">Status</label>
|
|
<select class="form-select" name="status">
|
|
{% for s in statuses %}
|
|
<option value="{{ s }}" {% if s == ticket.status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Priority</label>
|
|
<select class="form-select" name="priority">
|
|
{% for p in priorities %}
|
|
<option value="{{ p }}" {% if p == ticket.priority %}selected{% endif %}>{{ p.title() }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Assign To</label>
|
|
<select class="form-select" name="assigned_to_id">
|
|
<option value="">— Unassigned —</option>
|
|
{% for staff in it_staff %}
|
|
<option value="{{ staff.id }}" {% if ticket.assigned_to_id == staff.id %}selected{% endif %}>
|
|
{{ staff.full_name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Due Date</label>
|
|
<input type="date" class="form-control" name="due_date"
|
|
value="{{ ticket.due_date.strftime('%Y-%m-%d') if ticket.due_date else '' }}"/>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Resolution Notes</label>
|
|
<textarea class="form-control" name="resolution_notes" rows="3"
|
|
placeholder="Describe how the issue was resolved…">{{ ticket.resolution_notes or '' }}</textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Internal Notes <span style="color:var(--warning);">(IT only)</span></label>
|
|
<textarea class="form-control" name="internal_notes" rows="2"
|
|
placeholder="Private notes for IT team…">{{ ticket.internal_notes or '' }}</textarea>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary w-100">
|
|
<i class="bi bi-check2 me-2"></i>Save Changes
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Linked Tickets -->
|
|
{% if current_user.is_it_staff %}
|
|
<div class="card mb-3">
|
|
<div class="card-header d-flex align-items-center justify-content-between">
|
|
<span><i class="bi bi-link-45deg me-2"></i>Linked Tickets</span>
|
|
<button class="btn btn-secondary btn-sm" type="button"
|
|
data-bs-toggle="collapse" data-bs-target="#link-form-collapse"
|
|
style="font-size:11px;">
|
|
<i class="bi bi-plus me-1"></i>Link
|
|
</button>
|
|
</div>
|
|
<div class="card-body" style="font-size:13px;">
|
|
{% if linked_tickets %}
|
|
{% for lnk, other in linked_tickets %}
|
|
<div class="d-flex align-items-center justify-content-between mb-2"
|
|
style="padding:6px 8px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);">
|
|
<div style="min-width:0;">
|
|
<a href="{{ url_for('tickets.ticket_detail', ticket_id=other.id) }}"
|
|
class="mono" style="font-size:11px;color:var(--accent3);">{{ other.ticket_number }}</a>
|
|
<span style="font-size:10px;background:var(--border);color:var(--muted);padding:1px 6px;border-radius:4px;margin-left:4px;">{{ lnk.link_type.replace('_',' ') }}</span>
|
|
<div style="font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ other.title }}</div>
|
|
</div>
|
|
<form method="POST"
|
|
action="{{ url_for('tickets.unlink_ticket', ticket_id=ticket.id, link_id=lnk.id) }}"
|
|
onsubmit="return confirm('Remove this link?')" style="margin:0;flex-shrink:0;">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<button type="submit" class="btn btn-sm" title="Remove link"
|
|
style="background:none;border:none;color:var(--muted);padding:2px 6px;">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p style="color:var(--muted);font-size:12px;margin:0;">No linked tickets yet.</p>
|
|
{% endif %}
|
|
|
|
<!-- Link form (collapsed by default) -->
|
|
<div class="collapse mt-3" id="link-form-collapse">
|
|
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<div class="mb-2">
|
|
<label class="form-label" style="font-size:12px;">Ticket ID or Number</label>
|
|
<input type="number" class="form-control form-control-sm"
|
|
name="linked_ticket_id" placeholder="e.g. 42" required min="1"/>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label" style="font-size:12px;">Relationship</label>
|
|
<select class="form-select form-select-sm" name="link_type">
|
|
<option value="related">Related</option>
|
|
<option value="duplicate">Duplicate</option>
|
|
<option value="follow_up">Follow-up</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
|
<i class="bi bi-link-45deg me-1"></i>Create Link
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Ticket Info -->
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Ticket Details</div>
|
|
<div class="card-body" style="font-size:13px;">
|
|
{% macro info_row(icon, label, value) %}
|
|
<div style="display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);">
|
|
<i class="bi {{ icon }}" style="color:var(--muted);width:16px;text-align:center;margin-top:1px;flex-shrink:0;"></i>
|
|
<div style="color:var(--muted);">{{ label }}</div>
|
|
<div style="margin-left:auto;text-align:right;max-width:55%;">{{ value | safe }}</div>
|
|
</div>
|
|
{% endmacro %}
|
|
{{ info_row('bi-hash', 'Number', '<span class="mono" style="font-size:11px;color:var(--accent3);">'~ticket.ticket_number~'</span>') }}
|
|
{{ info_row('bi-tag', 'Category', ticket.category.replace('_',' ').title()) }}
|
|
{{ info_row('bi-person', 'Submitted by', ticket.creator.full_name) }}
|
|
{% if ticket.filed_by_staff %}
|
|
{{ info_row('bi-person-badge', 'Filed by (IT)', '<span style="color:var(--accent);font-weight:600;">' ~ ticket.filed_by_staff.full_name ~ '</span>') }}
|
|
{% endif %}
|
|
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
|
|
{{ info_row('bi-calendar3', 'Created', ticket.created_at.strftime('%b %d, %Y')) }}
|
|
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%b %d, %Y')) }}
|
|
{% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date.strftime('%b %d, %Y')) }}{% endif %}
|
|
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at.strftime('%b %d, %Y')) }}{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- History -->
|
|
{% if history %}
|
|
<div class="card">
|
|
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
|
|
<div class="card-body p-2">
|
|
{% for h in history %}
|
|
<div class="history-item">
|
|
{# ── Field label ── #}
|
|
{% if h.field_name == 'assigned_to' %}
|
|
<strong>Assigned To</strong>
|
|
{% elif h.field_name == 'status' %}
|
|
<strong>Status</strong>
|
|
{% elif h.field_name == 'priority' %}
|
|
<strong>Priority</strong>
|
|
{% else %}
|
|
<strong>{{ h.field_name.replace('_',' ').title() }}</strong>
|
|
{% endif %}
|
|
|
|
{# ── Values — format status/priority nicely; keep others as-is ── #}
|
|
{% set old = h.old_value or 'Unassigned' %}
|
|
{% set new = h.new_value or 'Unassigned' %}
|
|
{% if h.field_name in ('status', 'priority') %}
|
|
{% set old = old.replace('_',' ').title() %}
|
|
{% set new = new.replace('_',' ').title() %}
|
|
{% endif %}
|
|
changed from <em>{{ old }}</em> to <em>{{ new }}</em>
|
|
<br>
|
|
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Re-open modal trigger — shown to ticket creator when resolved or closed -->
|
|
{% if ticket.status in ('resolved', 'closed') and
|
|
(current_user.is_it_staff or ticket.created_by_id == current_user.id) %}
|
|
<div class="card mt-4" style="border-color:var(--warning);max-width:760px;">
|
|
<div class="card-body d-flex align-items-center gap-3" style="padding:16px 20px;">
|
|
<i class="bi bi-arrow-repeat" style="font-size:22px;color:var(--warning);flex-shrink:0;"></i>
|
|
<div style="flex:1;">
|
|
<div style="font-weight:600;font-size:14px;">Has this issue returned?</div>
|
|
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
|
|
Re-open this ticket to notify IT staff and resume tracking.
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-outline-warning btn-sm"
|
|
data-bs-toggle="modal" data-bs-target="#reopen-modal">
|
|
<i class="bi bi-arrow-repeat me-1"></i>Re-open Ticket
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Re-open modal -->
|
|
<div class="modal fade" id="reopen-modal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-arrow-repeat me-2"></i>Re-open Ticket</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form method="POST" action="{{ url_for('tickets.reopen_ticket', ticket_id=ticket.id) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<div class="modal-body">
|
|
<p style="font-size:13px;color:var(--muted);">
|
|
Please describe why the issue has returned. This will be posted as a comment
|
|
and IT staff will be notified immediately.
|
|
</p>
|
|
<textarea class="form-control" name="reopen_reason" rows="4" required
|
|
placeholder="e.g. The problem came back after the weekend restart. The error message is now different: …"></textarea>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-warning">
|
|
<i class="bi bi-arrow-repeat me-1"></i>Re-open & Notify IT
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %} |