Implement comprehensive improvement plan (Phases 1-6)
Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
// XSS prevention helper
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Main initialization
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded, initializing dashboard...');
|
||||
@@ -999,6 +1006,10 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
||||
};
|
||||
const icon = icons[type] || icons.warning;
|
||||
|
||||
// Escape user-provided content to prevent XSS
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeMessage = escapeHtml(message);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="${modalId}">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
@@ -1006,14 +1017,14 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header" style="color: ${color};">
|
||||
${icon} ${title}
|
||||
${icon} ${safeTitle}
|
||||
</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1.5rem; text-align: center;">
|
||||
<p style="color: var(--terminal-green); white-space: pre-line;">
|
||||
${message}
|
||||
${safeMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1070,6 +1081,11 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
const modalId = 'inputModal' + Date.now();
|
||||
const inputId = modalId + '_input';
|
||||
|
||||
// Escape user-provided content to prevent XSS
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeLabel = escapeHtml(label);
|
||||
const safePlaceholder = escapeHtml(placeholder);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="${modalId}">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
@@ -1077,20 +1093,20 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">
|
||||
${title}
|
||||
${safeTitle}
|
||||
</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1.5rem;">
|
||||
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">
|
||||
${label}
|
||||
${safeLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="${inputId}"
|
||||
class="terminal-input"
|
||||
placeholder="${placeholder}"
|
||||
placeholder="${safePlaceholder}"
|
||||
style="width: 100%; padding: 0.5rem; background: var(--bg-primary); border: 1px solid var(--terminal-green); color: var(--terminal-green); font-family: var(--font-mono);"
|
||||
/>
|
||||
</div>
|
||||
@@ -1149,3 +1165,177 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// QUICK ACTIONS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Quick status change from dashboard
|
||||
*/
|
||||
function quickStatusChange(ticketId, currentStatus) {
|
||||
const statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
const otherStatuses = statuses.filter(s => s !== currentStatus);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="quickStatusModal">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Quick Status Change</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1rem;">
|
||||
<p style="margin-bottom: 1rem;">Ticket #${escapeHtml(ticketId)}</p>
|
||||
<p style="margin-bottom: 0.5rem; color: var(--terminal-amber);">Current: ${escapeHtml(currentStatus)}</p>
|
||||
<label for="quickStatusSelect">New Status:</label>
|
||||
<select id="quickStatusSelect" class="editable" style="width: 100%; margin-top: 0.5rem;">
|
||||
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="modal-footer">
|
||||
<button onclick="performQuickStatusChange('${ticketId}')" class="btn btn-primary">Update</button>
|
||||
<button onclick="closeQuickStatusModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
}
|
||||
|
||||
function closeQuickStatusModal() {
|
||||
const modal = document.getElementById('quickStatusModal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
function performQuickStatusChange(ticketId) {
|
||||
const newStatus = document.getElementById('quickStatusSelect').value;
|
||||
|
||||
fetch('/api/update_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
status: newStatus
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
closeQuickStatusModal();
|
||||
if (data.success) {
|
||||
toast.success(`Status updated to ${newStatus}`, 3000);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
closeQuickStatusModal();
|
||||
console.error('Error:', error);
|
||||
toast.error('Error updating status', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick assign from dashboard
|
||||
*/
|
||||
function quickAssign(ticketId) {
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="quickAssignModal">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Quick Assign</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1rem;">
|
||||
<p style="margin-bottom: 1rem;">Ticket #${escapeHtml(ticketId)}</p>
|
||||
<label for="quickAssignSelect">Assign to:</label>
|
||||
<select id="quickAssignSelect" class="editable" style="width: 100%; margin-top: 0.5rem;">
|
||||
<option value="">Unassigned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="modal-footer">
|
||||
<button onclick="performQuickAssign('${ticketId}')" class="btn btn-primary">Assign</button>
|
||||
<button onclick="closeQuickAssignModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// Load users
|
||||
fetch('/api/get_users.php')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.users) {
|
||||
const select = document.getElementById('quickAssignSelect');
|
||||
data.users.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.user_id;
|
||||
option.textContent = user.display_name || user.username;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error loading users:', error));
|
||||
}
|
||||
|
||||
function closeQuickAssignModal() {
|
||||
const modal = document.getElementById('quickAssignModal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
function performQuickAssign(ticketId) {
|
||||
const assignedTo = document.getElementById('quickAssignSelect').value || null;
|
||||
|
||||
fetch('/api/assign_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
assigned_to: assignedTo
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
closeQuickAssignModal();
|
||||
if (data.success) {
|
||||
toast.success('Assignment updated', 3000);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
closeQuickAssignModal();
|
||||
console.error('Error:', error);
|
||||
toast.error('Error updating assignment', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,8 +27,15 @@ function parseMarkdown(markdown) {
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
// Links [text](url) - only allow safe protocols
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
||||
// Only allow http, https, mailto protocols
|
||||
if (/^(https?:|mailto:|\/)/i.test(url)) {
|
||||
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
|
||||
}
|
||||
// Block potentially dangerous protocols (javascript:, data:, etc.)
|
||||
return text;
|
||||
});
|
||||
|
||||
// Headers (# H1, ## H2, etc.)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
@@ -75,3 +82,191 @@ document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
||||
// Expose for manual use
|
||||
window.parseMarkdown = parseMarkdown;
|
||||
window.renderMarkdownElements = renderMarkdownElements;
|
||||
|
||||
// ========================================
|
||||
// Rich Text Editor Toolbar Functions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Insert markdown formatting around selection
|
||||
*/
|
||||
function insertMarkdownFormat(textareaId, prefix, suffix) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selectedText = text.substring(start, end);
|
||||
|
||||
// Insert formatting
|
||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
||||
textarea.value = newText;
|
||||
|
||||
// Set cursor position
|
||||
if (selectedText) {
|
||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||||
} else {
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
|
||||
// Trigger input event to update preview if enabled
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert markdown at cursor position
|
||||
*/
|
||||
function insertMarkdownText(textareaId, text) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const value = textarea.value;
|
||||
|
||||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar button handlers
|
||||
*/
|
||||
function toolbarBold(textareaId) {
|
||||
insertMarkdownFormat(textareaId, '**', '**');
|
||||
}
|
||||
|
||||
function toolbarItalic(textareaId) {
|
||||
insertMarkdownFormat(textareaId, '_', '_');
|
||||
}
|
||||
|
||||
function toolbarCode(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
// Use code block for multi-line, inline code for single line
|
||||
if (selectedText.includes('\n')) {
|
||||
insertMarkdownFormat(textareaId, '```\n', '\n```');
|
||||
} else {
|
||||
insertMarkdownFormat(textareaId, '`', '`');
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarLink(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
if (selectedText) {
|
||||
// Wrap selected text as link text
|
||||
insertMarkdownFormat(textareaId, '[', '](url)');
|
||||
} else {
|
||||
insertMarkdownText(textareaId, '[link text](url)');
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarList(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert list marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarHeading(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert heading marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 3, start + 3);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarQuote(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert quote marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and insert toolbar HTML for a textarea
|
||||
*/
|
||||
function createEditorToolbar(textareaId, containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'editor-toolbar';
|
||||
toolbar.innerHTML = `
|
||||
<button type="button" onclick="toolbarBold('${textareaId}')" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" onclick="toolbarItalic('${textareaId}')" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" onclick="toolbarCode('${textareaId}')" title="Code"></></button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" onclick="toolbarHeading('${textareaId}')" title="Heading">H</button>
|
||||
<button type="button" onclick="toolbarList('${textareaId}')" title="List">≡</button>
|
||||
<button type="button" onclick="toolbarQuote('${textareaId}')" title="Quote">"</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" onclick="toolbarLink('${textareaId}')" title="Link">🔗</button>
|
||||
`;
|
||||
|
||||
container.insertBefore(toolbar, container.firstChild);
|
||||
}
|
||||
|
||||
// Expose toolbar functions globally
|
||||
window.toolbarBold = toolbarBold;
|
||||
window.toolbarItalic = toolbarItalic;
|
||||
window.toolbarCode = toolbarCode;
|
||||
window.toolbarLink = toolbarLink;
|
||||
window.toolbarList = toolbarList;
|
||||
window.toolbarHeading = toolbarHeading;
|
||||
window.toolbarQuote = toolbarQuote;
|
||||
window.createEditorToolbar = createEditorToolbar;
|
||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||
window.insertMarkdownText = insertMarkdownText;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// XSS prevention helper
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function saveTicket() {
|
||||
const editables = document.querySelectorAll('.editable');
|
||||
const data = {};
|
||||
@@ -167,8 +174,8 @@ function addComment() {
|
||||
// Format the comment text for display
|
||||
let displayText;
|
||||
if (isMarkdownEnabled) {
|
||||
// For markdown, use marked.parse
|
||||
displayText = marked.parse(commentText);
|
||||
// For markdown, use parseMarkdown (sanitizes HTML)
|
||||
displayText = parseMarkdown(commentText);
|
||||
} else {
|
||||
// For non-markdown, convert line breaks to <br> and escape HTML
|
||||
displayText = commentText
|
||||
@@ -521,6 +528,8 @@ function showTab(tabName) {
|
||||
// Hide all tab contents
|
||||
const descriptionTab = document.getElementById('description-tab');
|
||||
const commentsTab = document.getElementById('comments-tab');
|
||||
const attachmentsTab = document.getElementById('attachments-tab');
|
||||
const dependenciesTab = document.getElementById('dependencies-tab');
|
||||
const activityTab = document.getElementById('activity-tab');
|
||||
|
||||
if (!descriptionTab || !commentsTab) {
|
||||
@@ -531,6 +540,12 @@ function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
descriptionTab.style.display = 'none';
|
||||
commentsTab.style.display = 'none';
|
||||
if (attachmentsTab) {
|
||||
attachmentsTab.style.display = 'none';
|
||||
}
|
||||
if (dependenciesTab) {
|
||||
dependenciesTab.style.display = 'none';
|
||||
}
|
||||
if (activityTab) {
|
||||
activityTab.style.display = 'none';
|
||||
}
|
||||
@@ -543,4 +558,627 @@ function showTab(tabName) {
|
||||
// Show selected tab and activate its button
|
||||
document.getElementById(`${tabName}-tab`).style.display = 'block';
|
||||
document.querySelector(`[onclick="showTab('${tabName}')"]`).classList.add('active');
|
||||
|
||||
// Load attachments when tab is shown
|
||||
if (tabName === 'attachments') {
|
||||
loadAttachments();
|
||||
initializeUploadZone();
|
||||
}
|
||||
|
||||
// Load dependencies when tab is shown
|
||||
if (tabName === 'dependencies') {
|
||||
loadDependencies();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Dependency Management Functions
|
||||
// ========================================
|
||||
|
||||
function loadDependencies() {
|
||||
const ticketId = window.ticketData.id;
|
||||
|
||||
fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderDependencies(data.dependencies);
|
||||
renderDependents(data.dependents);
|
||||
} else {
|
||||
console.error('Error loading dependencies:', data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading dependencies:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDependencies(dependencies) {
|
||||
const container = document.getElementById('dependenciesList');
|
||||
if (!container) return;
|
||||
|
||||
const typeLabels = {
|
||||
'blocks': 'Blocks',
|
||||
'blocked_by': 'Blocked By',
|
||||
'relates_to': 'Relates To',
|
||||
'duplicates': 'Duplicates'
|
||||
};
|
||||
|
||||
let html = '';
|
||||
let hasAny = false;
|
||||
|
||||
for (const [type, items] of Object.entries(dependencies)) {
|
||||
if (items.length > 0) {
|
||||
hasAny = true;
|
||||
html += `<div class="dependency-group">
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0;">${typeLabels[type]}</h4>`;
|
||||
|
||||
items.forEach(dep => {
|
||||
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
||||
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
|
||||
<div>
|
||||
<a href="/ticket/${escapeHtml(dep.depends_on_id)}" style="color: var(--terminal-green);">
|
||||
#${escapeHtml(dep.depends_on_id)}
|
||||
</a>
|
||||
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
|
||||
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
|
||||
</div>
|
||||
<button onclick="removeDependency('${dep.dependency_id}')" class="btn btn-small" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Remove</button>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
html = '<p style="color: var(--terminal-green-dim);">No dependencies configured.</p>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderDependents(dependents) {
|
||||
const container = document.getElementById('dependentsList');
|
||||
if (!container) return;
|
||||
|
||||
if (dependents.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No tickets depend on this one.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
dependents.forEach(dep => {
|
||||
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
||||
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
|
||||
<div>
|
||||
<a href="/ticket/${escapeHtml(dep.ticket_id)}" style="color: var(--terminal-green);">
|
||||
#${escapeHtml(dep.ticket_id)}
|
||||
</a>
|
||||
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
|
||||
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
|
||||
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${escapeHtml(dep.dependency_type)})</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function addDependency() {
|
||||
const ticketId = window.ticketData.id;
|
||||
const dependsOnId = document.getElementById('dependencyTicketId').value.trim();
|
||||
const dependencyType = document.getElementById('dependencyType').value;
|
||||
|
||||
if (!dependsOnId) {
|
||||
toast.warning('Please enter a ticket ID', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/ticket_dependencies.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
depends_on_id: dependsOnId,
|
||||
dependency_type: dependencyType
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Dependency added', 3000);
|
||||
document.getElementById('dependencyTicketId').value = '';
|
||||
loadDependencies();
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error adding dependency:', error);
|
||||
toast.error('Error adding dependency', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
function removeDependency(dependencyId) {
|
||||
if (!confirm('Are you sure you want to remove this dependency?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/ticket_dependencies.php', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dependency_id: dependencyId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Dependency removed', 3000);
|
||||
loadDependencies();
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error removing dependency:', error);
|
||||
toast.error('Error removing dependency', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Attachment Management Functions
|
||||
// ========================================
|
||||
|
||||
let uploadZoneInitialized = false;
|
||||
|
||||
function initializeUploadZone() {
|
||||
if (uploadZoneInitialized) return;
|
||||
|
||||
const uploadZone = document.getElementById('uploadZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
if (!uploadZone || !fileInput) return;
|
||||
|
||||
// Drag and drop events
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
uploadZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
});
|
||||
|
||||
// File input change event
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFileUpload(e.target.files);
|
||||
}
|
||||
});
|
||||
|
||||
// Click on upload zone to trigger file input
|
||||
uploadZone.addEventListener('click', (e) => {
|
||||
if (e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT') {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
uploadZoneInitialized = true;
|
||||
}
|
||||
|
||||
function handleFileUpload(files) {
|
||||
const ticketId = window.ticketData.id;
|
||||
const progressDiv = document.getElementById('uploadProgress');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const statusText = document.getElementById('uploadStatus');
|
||||
|
||||
let uploadedCount = 0;
|
||||
const totalFiles = files.length;
|
||||
|
||||
progressDiv.style.display = 'block';
|
||||
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
|
||||
progressFill.style.width = '0%';
|
||||
|
||||
Array.from(files).forEach((file, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ticket_id', ticketId);
|
||||
formData.append('csrf_token', window.CSRF_TOKEN);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const fileProgress = (e.loaded / e.total) * 100;
|
||||
const overallProgress = ((uploadedCount * 100) + fileProgress) / totalFiles;
|
||||
progressFill.style.width = overallProgress + '%';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
uploadedCount++;
|
||||
statusText.textContent = `Uploading ${uploadedCount} of ${totalFiles} files...`;
|
||||
progressFill.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
||||
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.success) {
|
||||
if (uploadedCount === totalFiles) {
|
||||
toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
||||
loadAttachments();
|
||||
resetUploadUI();
|
||||
}
|
||||
} else {
|
||||
toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`Error parsing response for ${file.name}`, 4000);
|
||||
}
|
||||
} else {
|
||||
toast.error(`Error uploading ${file.name}: Server error`, 4000);
|
||||
}
|
||||
|
||||
if (uploadedCount === totalFiles) {
|
||||
setTimeout(resetUploadUI, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
uploadedCount++;
|
||||
toast.error(`Error uploading ${file.name}: Network error`, 4000);
|
||||
if (uploadedCount === totalFiles) {
|
||||
setTimeout(resetUploadUI, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/upload_attachment.php');
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function resetUploadUI() {
|
||||
const progressDiv = document.getElementById('uploadProgress');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
progressDiv.style.display = 'none';
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loadAttachments() {
|
||||
const ticketId = window.ticketData.id;
|
||||
const container = document.getElementById('attachmentsList');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderAttachments(data.attachments || []);
|
||||
} else {
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading attachments:', error);
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderAttachments(attachments) {
|
||||
const container = document.getElementById('attachmentsList');
|
||||
if (!container) return;
|
||||
|
||||
if (attachments.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No files attached to this ticket.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="attachments-grid">';
|
||||
|
||||
attachments.forEach(att => {
|
||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||
const uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
||||
<div class="attachment-icon">${escapeHtml(att.icon || '📎')}</div>
|
||||
<div class="attachment-info">
|
||||
<div class="attachment-name" title="${escapeHtml(att.original_filename)}">
|
||||
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank" style="color: var(--terminal-green);">
|
||||
${escapeHtml(att.original_filename)}
|
||||
</a>
|
||||
</div>
|
||||
<div class="attachment-meta">
|
||||
${escapeHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${escapeHtml(uploaderName)} • ${escapeHtml(uploadDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="btn btn-small" title="Download">⬇</a>
|
||||
<button onclick="deleteAttachment(${att.attachment_id})" class="btn btn-small btn-danger" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes >= 1073741824) {
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
} else if (bytes >= 1048576) {
|
||||
return (bytes / 1048576).toFixed(2) + ' MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return (bytes / 1024).toFixed(2) + ' KB';
|
||||
} else {
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAttachment(attachmentId) {
|
||||
if (!confirm('Are you sure you want to delete this attachment?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/delete_attachment.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
attachment_id: attachmentId,
|
||||
csrf_token: window.CSRF_TOKEN
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Attachment deleted', 3000);
|
||||
loadAttachments();
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting attachment:', error);
|
||||
toast.error('Error deleting attachment', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// @Mention Autocomplete Functions
|
||||
// ========================================
|
||||
|
||||
let mentionAutocomplete = null;
|
||||
let mentionUsers = [];
|
||||
let mentionStartPos = -1;
|
||||
let selectedMentionIndex = 0;
|
||||
|
||||
/**
|
||||
* Initialize mention autocomplete for a textarea
|
||||
*/
|
||||
function initMentionAutocomplete() {
|
||||
const textarea = document.getElementById('newComment');
|
||||
if (!textarea) return;
|
||||
|
||||
// Create autocomplete dropdown
|
||||
mentionAutocomplete = document.createElement('div');
|
||||
mentionAutocomplete.className = 'mention-autocomplete';
|
||||
mentionAutocomplete.id = 'mentionAutocomplete';
|
||||
textarea.parentElement.style.position = 'relative';
|
||||
textarea.parentElement.appendChild(mentionAutocomplete);
|
||||
|
||||
// Fetch users list
|
||||
fetchMentionUsers();
|
||||
|
||||
// Input event to detect @ symbol
|
||||
textarea.addEventListener('input', handleMentionInput);
|
||||
textarea.addEventListener('keydown', handleMentionKeydown);
|
||||
textarea.addEventListener('blur', () => {
|
||||
// Delay hiding to allow click on option
|
||||
setTimeout(hideMentionAutocomplete, 200);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available users for mentions
|
||||
*/
|
||||
function fetchMentionUsers() {
|
||||
fetch('/api/get_users.php')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.users) {
|
||||
mentionUsers = data.users;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching users for mentions:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input events to detect @ mentions
|
||||
*/
|
||||
function handleMentionInput(e) {
|
||||
const textarea = e.target;
|
||||
const text = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
|
||||
// Find @ symbol before cursor
|
||||
let atPos = -1;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
const char = text[i];
|
||||
if (char === '@') {
|
||||
atPos = i;
|
||||
break;
|
||||
}
|
||||
if (char === ' ' || char === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atPos >= 0) {
|
||||
const query = text.substring(atPos + 1, cursorPos).toLowerCase();
|
||||
mentionStartPos = atPos;
|
||||
showMentionSuggestions(query, textarea);
|
||||
} else {
|
||||
hideMentionAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in autocomplete
|
||||
*/
|
||||
function handleMentionKeydown(e) {
|
||||
if (!mentionAutocomplete || !mentionAutocomplete.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = mentionAutocomplete.querySelectorAll('.mention-option');
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedMentionIndex = Math.min(selectedMentionIndex + 1, options.length - 1);
|
||||
updateMentionSelection(options);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
|
||||
updateMentionSelection(options);
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (options[selectedMentionIndex]) {
|
||||
selectMention(options[selectedMentionIndex].dataset.username);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
hideMentionAutocomplete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual selection in autocomplete
|
||||
*/
|
||||
function updateMentionSelection(options) {
|
||||
options.forEach((opt, i) => {
|
||||
opt.classList.toggle('selected', i === selectedMentionIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show mention suggestions
|
||||
*/
|
||||
function showMentionSuggestions(query, textarea) {
|
||||
const filtered = mentionUsers.filter(user => {
|
||||
const username = (user.username || '').toLowerCase();
|
||||
const displayName = (user.display_name || '').toLowerCase();
|
||||
return username.includes(query) || displayName.includes(query);
|
||||
}).slice(0, 5);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
hideMentionAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
filtered.forEach((user, index) => {
|
||||
const isSelected = index === 0 ? 'selected' : '';
|
||||
html += `<div class="mention-option ${isSelected}" data-username="${escapeHtml(user.username)}" onclick="selectMention('${escapeHtml(user.username)}')">
|
||||
<span class="mention-username">@${escapeHtml(user.username)}</span>
|
||||
${user.display_name ? `<span class="mention-displayname">${escapeHtml(user.display_name)}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
mentionAutocomplete.innerHTML = html;
|
||||
mentionAutocomplete.classList.add('active');
|
||||
selectedMentionIndex = 0;
|
||||
|
||||
// Position dropdown below cursor
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
mentionAutocomplete.style.left = '0';
|
||||
mentionAutocomplete.style.top = (textarea.offsetTop + textarea.offsetHeight) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide mention autocomplete
|
||||
*/
|
||||
function hideMentionAutocomplete() {
|
||||
if (mentionAutocomplete) {
|
||||
mentionAutocomplete.classList.remove('active');
|
||||
}
|
||||
mentionStartPos = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a mention from autocomplete
|
||||
*/
|
||||
function selectMention(username) {
|
||||
const textarea = document.getElementById('newComment');
|
||||
if (!textarea || mentionStartPos < 0) return;
|
||||
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, mentionStartPos);
|
||||
const after = text.substring(textarea.selectionStart);
|
||||
|
||||
textarea.value = before + '@' + username + ' ' + after;
|
||||
textarea.focus();
|
||||
const newPos = mentionStartPos + username.length + 2;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
|
||||
hideMentionAutocomplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight mentions in comment text
|
||||
*/
|
||||
function highlightMentions(text) {
|
||||
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>');
|
||||
}
|
||||
|
||||
// Initialize mention autocomplete when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMentionAutocomplete();
|
||||
|
||||
// Highlight existing mentions in comments
|
||||
document.querySelectorAll('.comment-text').forEach(el => {
|
||||
if (!el.hasAttribute('data-markdown')) {
|
||||
el.innerHTML = highlightMentions(el.innerHTML);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user