Files
IT_Ticket_System/app/templates/tickets/detail.html
T

467 lines
21 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>
<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 d-flex flex-wrap gap-2">
{% for att in ticket_atts %}
<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>
{% endfor %}
</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;">
{{ comment.author.full_name[0].upper() }}
</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 }}</div>
{% set c_atts = comment.attachments.all() %}
{% if c_atts %}
<div class="mt-2 d-flex flex-wrap gap-2">
{% for att in c_atts %}
<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 }}
</a>
{% 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, follow-up, or response here…"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Attachments</label>
<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 %}
<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 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>
<!-- 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 %}
<!-- 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) }}
{{ 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>
{% endblock %}