V.1.2 - Improved Secrurity, Better Ticket Handling
This commit is contained in:
1 parent
cb5d091be2
commit
3136c81533
16 files changed
+879
-738
No files matched your search
+143
-41
@@ -9,21 +9,130 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const statOpen = document.getElementById('open-tickets');
|
||||
const statInProgress = document.getElementById('in-progress-tickets');
|
||||
const statClosed = document.getElementById('closed-tickets');
|
||||
const statTotalTime = document.getElementById('time-tickets'); // Neu: Gesamtzeit Anzeige
|
||||
const statTotalTime = document.getElementById('time-tickets');
|
||||
|
||||
// Accordion elements
|
||||
const closedToggle = document.getElementById('closed-toggle');
|
||||
const closedAccordion = document.getElementById('closed-accordion');
|
||||
const closedCount = document.getElementById('closed-count');
|
||||
|
||||
// PFP elements
|
||||
const updatePfpBtn = document.getElementById('update-pfp-btn');
|
||||
const pfpModal = document.getElementById('update-pfp-modal');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('pfp-file-input');
|
||||
const uploadPfpBtn = document.getElementById('upload-pfp-btn');
|
||||
|
||||
let selectedPfpFile = null;
|
||||
let accordionOpen = false;
|
||||
|
||||
searchInput.addEventListener('input', refreshTickets);
|
||||
statusFilter.addEventListener('change', refreshTickets);
|
||||
priorityFilter.addEventListener('change', refreshTickets);
|
||||
categoryFilter.addEventListener('change', refreshTickets);
|
||||
|
||||
// Helper: Status/Priority zu "Title-Case-With-Dash"
|
||||
function toTitleCaseWithDash(str) {
|
||||
return str
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join('-');
|
||||
// --- XSS Protection ---
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function toTitleCaseWithDash(str) {
|
||||
return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('-');
|
||||
}
|
||||
|
||||
// --- Dynamic Categories ---
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const res = await fetch('/api/categories');
|
||||
const categories = await res.json();
|
||||
if (categoryFilter) {
|
||||
categoryFilter.innerHTML = '<option value="all">All Categories</option>' +
|
||||
categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Accordion ---
|
||||
if (closedToggle) {
|
||||
closedToggle.addEventListener('click', () => {
|
||||
accordionOpen = !accordionOpen;
|
||||
closedAccordion.style.display = accordionOpen ? 'block' : 'none';
|
||||
closedToggle.classList.toggle('open', accordionOpen);
|
||||
});
|
||||
}
|
||||
|
||||
// --- PFP Modal ---
|
||||
function openPfpModal() {
|
||||
if (pfpModal) {
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
pfpModal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||
}
|
||||
}
|
||||
function closePfpModal() {
|
||||
if (pfpModal) {
|
||||
pfpModal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.paddingRight = '';
|
||||
}
|
||||
}
|
||||
if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal);
|
||||
if (pfpModal) {
|
||||
window.addEventListener('click', (e) => { if (e.target === pfpModal) closePfpModal(); });
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal(); });
|
||||
}
|
||||
|
||||
// --- Drag & Drop ---
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.borderColor = '#10b981'; });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#4b5563'; });
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.borderColor = '#4b5563';
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
selectedPfpFile = file;
|
||||
dropZone.querySelector('p').textContent = file.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
selectedPfpFile = file;
|
||||
dropZone.querySelector('p').textContent = file.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (uploadPfpBtn) {
|
||||
uploadPfpBtn.addEventListener('click', async () => {
|
||||
if (!selectedPfpFile) return alert('Please select an image first.');
|
||||
const formData = new FormData();
|
||||
formData.append('pfp', selectedPfpFile, 'admin.jpg');
|
||||
try {
|
||||
const res = await fetch('/api/upload-admin-pfp', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
alert('Admin profile picture updated!');
|
||||
closePfpModal();
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Error uploading profile picture');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
async function fetchTickets() {
|
||||
try {
|
||||
const res = await fetch('/api/tickets');
|
||||
@@ -39,7 +148,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const res = await fetch(`/api/tickets/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actor: 'Admin' })
|
||||
body: JSON.stringify({ actor: 'Admin' }),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
@@ -59,33 +168,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const category = categoryFilter.value;
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
|
||||
const filtered = tickets.filter(t =>
|
||||
(status === 'all' || t.status === status) &&
|
||||
(priority === 'all' || t.priority === priority) &&
|
||||
(category === 'all' || t.category === category) &&
|
||||
(t.title.toLowerCase().includes(searchTerm) ||
|
||||
t.description.toLowerCase().includes(searchTerm) ||
|
||||
t.requester.toLowerCase().includes(searchTerm))
|
||||
).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
const filtered = tickets
|
||||
.filter(t =>
|
||||
(status === 'all' || t.status === status) &&
|
||||
(priority === 'all' || t.priority === priority) &&
|
||||
(category === 'all' || t.category === category) &&
|
||||
(t.title.toLowerCase().includes(searchTerm) ||
|
||||
t.description.toLowerCase().includes(searchTerm) ||
|
||||
t.requester.toLowerCase().includes(searchTerm))
|
||||
)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
const openRows = [];
|
||||
const closedRows = [];
|
||||
|
||||
filtered.forEach(ticket => {
|
||||
const shortTitle = ticket.title.length > 50 ? ticket.title.slice(0, 50) + '...' : ticket.title;
|
||||
|
||||
// Wandle Status und Priority korrekt in Klassen um
|
||||
const statusClass = toTitleCaseWithDash(ticket.status || 'Unknown');
|
||||
const priorityClass = toTitleCaseWithDash(ticket.priority || 'None');
|
||||
|
||||
const rowHTML = `
|
||||
<tr>
|
||||
<td>${ticket.id}</td>
|
||||
<td class="ticket-title">${shortTitle}</td>
|
||||
<td>${ticket.requester}</td>
|
||||
<td>${ticket.category || '-'}</td>
|
||||
<td><span class="status-badge status-${statusClass}">${ticket.status || 'Unknown'}</span></td>
|
||||
<td><span class="priority-badge priority-${priorityClass}">${ticket.priority || 'None'}</span></td>
|
||||
<td class="ticket-title">${escapeHtml(shortTitle)}</td>
|
||||
<td>${escapeHtml(ticket.requester)}</td>
|
||||
<td>${escapeHtml(ticket.category) || '-'}</td>
|
||||
<td><span class="status-badge status-${statusClass}">${escapeHtml(ticket.status) || 'Unknown'}</span></td>
|
||||
<td><span class="priority-badge priority-${priorityClass}">${escapeHtml(ticket.priority) || 'None'}</span></td>
|
||||
<td>${formatDate(ticket.created_at)}</td>
|
||||
<td>${ticket.total_time} min</td>
|
||||
<td class="table-actions">
|
||||
@@ -94,31 +203,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
if (ticket.status === 'Closed') {
|
||||
closedRows.push(rowHTML);
|
||||
} else {
|
||||
openRows.push(rowHTML);
|
||||
}
|
||||
if (ticket.status === 'Closed') closedRows.push(rowHTML);
|
||||
else openRows.push(rowHTML);
|
||||
});
|
||||
|
||||
ticketsBody.innerHTML = openRows.length
|
||||
? openRows.join('')
|
||||
: `<tr><td colspan="8" class="empty-table">No open tickets found</td></tr>`;
|
||||
: `<tr><td colspan="9" class="empty-table">No open tickets found</td></tr>`;
|
||||
|
||||
if (closedTicketsBody) {
|
||||
closedTicketsBody.innerHTML = closedRows.length
|
||||
? closedRows.join('')
|
||||
: `<tr><td colspan="8" class="empty-table">No closed tickets</td></tr>`;
|
||||
: `<tr><td colspan="9" class="empty-table">No closed tickets</td></tr>`;
|
||||
}
|
||||
|
||||
// Event listeners an Buttons hängen
|
||||
[...document.querySelectorAll('.view-btn')].forEach(btn =>
|
||||
if (closedCount) closedCount.textContent = closedRows.length;
|
||||
|
||||
document.querySelectorAll('.view-btn').forEach(btn =>
|
||||
btn.addEventListener('click', () => {
|
||||
window.location.href = `/ticketview/ticket-view-admin.html?id=${btn.dataset.id}`;
|
||||
})
|
||||
);
|
||||
|
||||
[...document.querySelectorAll('.delete-btn')].forEach(btn =>
|
||||
document.querySelectorAll('.delete-btn').forEach(btn =>
|
||||
btn.addEventListener('click', () => {
|
||||
if (confirm('Are you sure you want to delete this ticket?')) {
|
||||
deleteTicket(btn.dataset.id).then(refreshTickets);
|
||||
@@ -132,19 +238,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
statOpen.textContent = tickets.filter(t => t.status === 'Open').length;
|
||||
statInProgress.textContent = tickets.filter(t => t.status === 'In Progress').length;
|
||||
statClosed.textContent = tickets.filter(t => t.status === 'Closed').length;
|
||||
|
||||
// Gesamtzeit aller Tickets summieren (total_time in Minuten)
|
||||
const totalTime = tickets.reduce((sum, ticket) => sum + (ticket.total_time || 0), 0);
|
||||
const totalTime = tickets.reduce((sum, t) => sum + (t.total_time || 0), 0);
|
||||
statTotalTime.textContent = `${totalTime} min`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
refreshTickets();
|
||||
loadCategories().then(() => refreshTickets());
|
||||
});
|
||||
+50
-28
@@ -13,12 +13,30 @@
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<h1><i class="fas fa-ticket-alt"></i> Admin Dashboard</h1>
|
||||
<div>
|
||||
<div class="user-actions">
|
||||
<span>Welcome, Admin!</span>
|
||||
<img class="avatar" src="/assets/admin.jpg" alt="Admin Avatar">
|
||||
<button id="update-pfp-btn" class="btn-update-pfp small-btn">
|
||||
<i class="fas fa-camera"></i> Update Picture
|
||||
</button>
|
||||
<a href="/">User View</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Admin Profile Picture Modal -->
|
||||
<div id="update-pfp-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content small-modal">
|
||||
<h2><i class="fas fa-camera"></i> Update Admin Picture</h2>
|
||||
<div id="drop-zone">
|
||||
<p>Drag & Drop your picture here<br>or click to select</p>
|
||||
<input type="file" id="pfp-file-input" accept="image/*" hidden>
|
||||
</div>
|
||||
<button id="upload-pfp-btn" class="btn-update-pfp" style="margin-top: 12px;">
|
||||
<i class="fas fa-upload"></i> Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="main-content">
|
||||
<section class="admin-stats">
|
||||
<div class="stat-card">
|
||||
@@ -64,10 +82,6 @@
|
||||
</select>
|
||||
<select id="category-filter">
|
||||
<option value="all">All Categories</option>
|
||||
<option value="Bug">Bug</option>
|
||||
<option value="Feature">Feature</option>
|
||||
<option value="Support">Support</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,36 +106,44 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Closed Tickets Accordion -->
|
||||
<section class="closed-tickets-section">
|
||||
<div class="section-header">
|
||||
<h2><i class="fas fa-archive"></i> Closed Tickets</h2>
|
||||
<div class="accordion-section">
|
||||
<button class="accordion-toggle" id="closed-toggle">
|
||||
<i class="fas fa-archive"></i>
|
||||
Closed Tickets
|
||||
<span class="accordion-count" id="closed-count">0</span>
|
||||
<i class="fas fa-chevron-down accordion-arrow"></i>
|
||||
</button>
|
||||
<div class="accordion-body" id="closed-accordion" style="display: none;">
|
||||
<div class="admin-tickets-list">
|
||||
<table id="closed-tickets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Requester</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Created</th>
|
||||
<th>Total Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="closed-tickets-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-tickets-list">
|
||||
<table id="closed-tickets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Requester</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Created</th>
|
||||
<th>Total Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="closed-tickets-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Ticketsystem by Honey v1.1 © 2025</p>
|
||||
<p class="app-footer-text"></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="admin-script.js"></script>
|
||||
<script src="version.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+21
-39
@@ -6,10 +6,7 @@
|
||||
<title>Laila Dashboard - Ticket System!</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="user.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -30,9 +27,7 @@
|
||||
<!-- Modal Window for Creating Ticket -->
|
||||
<div id="create-ticket-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<button id="close-modal-btn" class="modal-close-btn" aria-label="Close modal">
|
||||
×
|
||||
</button>
|
||||
<button id="close-modal-btn" class="modal-close-btn" aria-label="Close modal">×</button>
|
||||
<section class="ticket-form">
|
||||
<h2><i class="fas fa-plus-circle"></i> Create New Ticket</h2>
|
||||
<form id="create-form">
|
||||
@@ -40,12 +35,7 @@
|
||||
<input type="text" id="title" placeholder="Ticket Title" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<textarea
|
||||
id="description"
|
||||
rows="3"
|
||||
placeholder="Describe your issue..."
|
||||
required
|
||||
></textarea>
|
||||
<textarea id="description" rows="3" placeholder="Describe your issue..." required></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@@ -57,20 +47,13 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<select id="category">
|
||||
<option value="HELP">HELP</option>
|
||||
<option value="HELP MEE NOOOW">HELP MEE NOOOW</option>
|
||||
<option value="IT-Support">IT-Support</option>
|
||||
<option value="Suggestion...">Suggestion...</option>
|
||||
<option value="Add Book pls">Add Book pls</option>
|
||||
<option value="Honeyycloud!">Honeyycloud!</option>
|
||||
</select>
|
||||
<select id="category"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group button-wrapper">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-paper-plane"></i> Create Ticket
|
||||
</button>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-paper-plane"></i> Create Ticket
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -81,12 +64,10 @@
|
||||
<div id="update-pfp-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content small-modal">
|
||||
<h2><i class="fas fa-camera"></i> Update Profile Picture</h2>
|
||||
|
||||
<div id="drop-zone">
|
||||
<p>Drag & Drop your picture here<br>or click to select</p>
|
||||
<input type="file" id="pfp-file-input" accept="image/*" hidden>
|
||||
</div>
|
||||
|
||||
<button id="upload-pfp-btn" class="btn-update-pfp" style="margin-top: 12px;">
|
||||
<i class="fas fa-upload"></i> Upload
|
||||
</button>
|
||||
@@ -107,35 +88,36 @@
|
||||
</select>
|
||||
<select id="category-filter">
|
||||
<option value="all">All Categories</option>
|
||||
<option value="HELP">HELP</option>
|
||||
<option value="HELP MEE NOOOW">HELP MEE NOOOW</option>
|
||||
<option value="IT-Support">IT-Support</option>
|
||||
<option value="Suggestion...">Suggestion...</option>
|
||||
<option value="Add Book pls">Add Book pls</option>
|
||||
<option value="Honeyycloud!">Honeyycloud!</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🟢 Open/Active Tickets -->
|
||||
<div>
|
||||
<h3>Active Tickets</h3>
|
||||
<div id="tickets-container" class="tickets-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- 🔴 Closed Tickets -->
|
||||
<div style="margin-top: 2rem;">
|
||||
<h3>Closed Tickets</h3>
|
||||
<div id="closed-tickets-container" class="tickets-grid"></div>
|
||||
<!-- Closed Tickets Accordion -->
|
||||
<div class="accordion-section" style="margin-top: 2rem;">
|
||||
<button class="accordion-toggle" id="closed-toggle">
|
||||
<i class="fas fa-archive"></i>
|
||||
Closed Tickets
|
||||
<span class="accordion-count" id="closed-count">0</span>
|
||||
<i class="fas fa-chevron-down accordion-arrow"></i>
|
||||
</button>
|
||||
<div class="accordion-body" id="closed-accordion" style="display: none;">
|
||||
<div id="closed-tickets-container" class="tickets-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Ticket System by Honey v1.1 © 2025</p>
|
||||
<p class="app-footer-text"></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="user-script.js"></script>
|
||||
<script src="version.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
fetchTicket, fetchComments, fetchHistory, createComment, logTime,
|
||||
formatDateTime, formatMinutes, getHistoryIcon
|
||||
formatDateTime, formatMinutes, getHistoryIcon, escapeHtml, linkify
|
||||
} from './ticket-shared.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ticketId = new URLSearchParams(window.location.search).get('id');
|
||||
if (!ticketId) return window.location.href = '../admin.html';
|
||||
if (!ticketId) return (window.location.href = '../admin.html');
|
||||
|
||||
const statusSelect = document.getElementById('status-select');
|
||||
const priorityInput = document.getElementById('priority-text');
|
||||
@@ -19,55 +19,26 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const totalTimeDisplay = document.getElementById('total-time');
|
||||
const timeForm = document.getElementById('time-booking-form');
|
||||
|
||||
let webhookConfig = null;
|
||||
|
||||
// Fetch Webhook Configuration
|
||||
async function loadWebhookConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/webhooks');
|
||||
if (response.ok) {
|
||||
webhookConfig = await response.json();
|
||||
console.log('Webhook configuration loaded:', webhookConfig.configured);
|
||||
} else {
|
||||
console.warn('Failed to load webhook configuration');
|
||||
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading webhook configuration:', error);
|
||||
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
|
||||
}
|
||||
}
|
||||
|
||||
// Send Webhook Notification
|
||||
async function sendWebhook(message) {
|
||||
if (!webhookConfig?.user) {
|
||||
console.log('User webhook not configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookConfig.user, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: message })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('User webhook failed:', res.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error sending user webhook:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let originalStatus = '', originalPriority = '', originalTitle = '', originalDesc = '', originalCategory = '';
|
||||
|
||||
// Load categories dynamically
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const res = await fetch('/api/categories');
|
||||
const categories = await res.json();
|
||||
if (categorySelect) {
|
||||
categorySelect.innerHTML = categories.map(c =>
|
||||
`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
|
||||
).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loadWebhookConfig().then(() => {
|
||||
loadTicketDetails();
|
||||
loadComments();
|
||||
loadHistory();
|
||||
});
|
||||
await loadCategories();
|
||||
await Promise.all([loadTicketDetails(), loadComments(), loadHistory()]);
|
||||
}
|
||||
|
||||
async function loadTicketDetails() {
|
||||
@@ -84,9 +55,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('ticket-requester').textContent = ticket.requester;
|
||||
document.getElementById('ticket-created').textContent = formatDateTime(ticket.created_at);
|
||||
document.getElementById('ticket-updated').textContent = formatDateTime(ticket.updated_at);
|
||||
const totalMins = typeof ticket.total_time === 'number' ? ticket.total_time : 0;
|
||||
totalTimeDisplay.textContent = formatMinutes(totalMins);
|
||||
|
||||
totalTimeDisplay.textContent = formatMinutes(typeof ticket.total_time === 'number' ? ticket.total_time : 0);
|
||||
|
||||
statusSelect.className = 'form-control status-' + (ticket.status || '').toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
@@ -98,54 +67,28 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
|
||||
async function updateTicket({ status, priority, title, description, category, actor = 'Admin' }) {
|
||||
try {
|
||||
const ticket = await fetchTicket(ticketId); // Fetch ticket to get the requester
|
||||
const requester = ticket.requester || ''; // Ensure requester is included in the update request
|
||||
try {
|
||||
const ticket = await fetchTicket(ticketId);
|
||||
const requester = ticket.requester || '';
|
||||
|
||||
const res = await fetch(`/api/tickets/${ticketId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, priority, title, description, category, actor, requester }) // Add requester
|
||||
});
|
||||
const res = await fetch(`/api/tickets/${ticketId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, priority, title, description, category, author: actor, requester }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update');
|
||||
|
||||
if (!res.ok) throw new Error('Failed to update');
|
||||
alert('Ticket updated successfully');
|
||||
// Webhooks are handled server-side now — no need to send from here
|
||||
alert('Ticket updated successfully');
|
||||
|
||||
if (status !== originalStatus) {
|
||||
await sendWebhook(`Ticket #${ticketId} status changed from "${originalStatus}" to "${status}"`);
|
||||
originalStatus = status;
|
||||
titleText.textContent = title;
|
||||
descText.textContent = description;
|
||||
await loadPage();
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
alert('Error updating ticket');
|
||||
}
|
||||
|
||||
if (priority !== originalPriority) {
|
||||
await sendWebhook(`Ticket #${ticketId} priority changed from "${originalPriority}" to "${priority}"`);
|
||||
originalPriority = priority;
|
||||
}
|
||||
|
||||
if (title !== originalTitle) {
|
||||
await sendWebhook(`Ticket #${ticketId} title changed by ${actor}`);
|
||||
originalTitle = title;
|
||||
}
|
||||
|
||||
if (description !== originalDesc) {
|
||||
await sendWebhook(`Ticket #${ticketId} description changed by ${actor}`);
|
||||
originalDesc = description;
|
||||
}
|
||||
|
||||
if (category !== originalCategory) {
|
||||
await sendWebhook(`Ticket #${ticketId} category changed from "${originalCategory}" to "${category}"`);
|
||||
originalCategory = category;
|
||||
}
|
||||
|
||||
titleText.textContent = title;
|
||||
descText.textContent = description;
|
||||
|
||||
await loadPage();
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
alert('Error updating ticket');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('update-ticket-btn').addEventListener('click', async () => {
|
||||
const isEditingTitle = titleInput.style.display === 'block';
|
||||
@@ -153,14 +96,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
const newTitle = isEditingTitle ? titleInput.value.trim() : titleText.textContent;
|
||||
const newDesc = isEditingDesc ? descInput.value.trim() : descText.textContent;
|
||||
const newStatus = statusSelect.value;
|
||||
const newPriority = priorityInput.value;
|
||||
const newCategory = categorySelect.value;
|
||||
|
||||
const confirmed = confirm('Möchten Sie die Änderungen wirklich speichern?');
|
||||
if (!confirmed) return;
|
||||
if (!confirm('Save changes?')) return;
|
||||
|
||||
await updateTicket({ status: newStatus, priority: newPriority, title: newTitle, description: newDesc, category: newCategory });
|
||||
await updateTicket({
|
||||
status: statusSelect.value,
|
||||
priority: priorityInput.value,
|
||||
title: newTitle,
|
||||
description: newDesc,
|
||||
category: categorySelect.value,
|
||||
});
|
||||
|
||||
titleInput.style.display = 'none';
|
||||
titleText.style.display = 'block';
|
||||
@@ -169,16 +114,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
document.getElementById('close-ticket-btn').addEventListener('click', async () => {
|
||||
const confirmed = confirm('Möchtest du das Ticket wirklich schließen?');
|
||||
if (!confirmed) return;
|
||||
|
||||
const newStatus = 'Closed';
|
||||
const newPriority = priorityInput.value;
|
||||
const newTitle = titleText.textContent;
|
||||
const newDesc = descText.textContent;
|
||||
const newCategory = categorySelect.value;
|
||||
|
||||
await updateTicket({ status: newStatus, priority: newPriority, title: newTitle, description: newDesc, category: newCategory });
|
||||
if (!confirm('Close this ticket?')) return;
|
||||
await updateTicket({
|
||||
status: 'Closed',
|
||||
priority: priorityInput.value,
|
||||
title: titleText.textContent,
|
||||
description: descText.textContent,
|
||||
category: categorySelect.value,
|
||||
});
|
||||
});
|
||||
|
||||
titleText.addEventListener('dblclick', () => {
|
||||
@@ -195,6 +138,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
descInput.focus();
|
||||
});
|
||||
|
||||
// Restore saved author name
|
||||
const savedAuthor = localStorage.getItem('commentAuthor');
|
||||
if (savedAuthor) commentAuthorInput.value = savedAuthor;
|
||||
|
||||
@@ -205,17 +149,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const role = comment.role || 'user';
|
||||
const isAdmin = role === 'admin';
|
||||
const avatarUrl = isAdmin ? '/assets/admin.jpg' : '/assets/user.jpg';
|
||||
|
||||
return `
|
||||
<div class="comment ${isAdmin ? 'comment-admin' : ''}">
|
||||
<img class="comment-avatar" src="${avatarUrl}" alt="${isAdmin ? 'Admin' : 'User'} Avatar">
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">${comment.author}</span>
|
||||
<span class="comment-author">${escapeHtml(comment.author)}</span>
|
||||
${isAdmin ? '<span class="comment-role-badge">Admin</span>' : ''}
|
||||
<span class="comment-date">${formatDateTime(comment.date)}</span>
|
||||
</div>
|
||||
<div class="comment-text">${comment.content}</div>
|
||||
<div class="comment-text">${linkify(comment.content)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -228,10 +171,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
<div class="history-item">
|
||||
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
|
||||
<div class="history-content">
|
||||
<div class="history-action">${h.action}</div>
|
||||
<div class="history-details">${h.message || ''}</div>
|
||||
<div class="history-action">${escapeHtml(h.action)}</div>
|
||||
<div class="history-details">${escapeHtml(h.message || '')}</div>
|
||||
<div class="history-meta">
|
||||
<span>By ${h.author || h.actor || 'unknown'}</span>
|
||||
<span>By ${escapeHtml(h.author || h.actor || 'unknown')}</span>
|
||||
<span>${formatDateTime(h.date || h.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,20 +183,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
commentForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const author = commentAuthorInput.value.trim();
|
||||
if (!author) return alert('Bitte gib deinen Namen ein');
|
||||
if (!author) return alert('Please enter your name');
|
||||
const content = document.getElementById('comment-content').value.trim();
|
||||
if (!content) return alert('Comment required');
|
||||
|
||||
const role = 'admin';
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await createComment(ticketId, { author, role, content, timestamp });
|
||||
await sendWebhook(`New comment added by ${author} (${role}): "${content}" on ticket #${ticketId}`);
|
||||
await createComment(ticketId, { author, role: 'admin', content, timestamp: new Date().toISOString() });
|
||||
localStorage.setItem('commentAuthor', author);
|
||||
|
||||
document.getElementById('comment-content').value = '';
|
||||
await loadComments();
|
||||
await loadHistory();
|
||||
@@ -269,41 +206,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const comment = document.getElementById('time-comment').value.trim();
|
||||
const actor = commentAuthorInput.value || 'Admin';
|
||||
|
||||
if (isNaN(minutes) || minutes <= 0) return alert('Gültige Zeit in Minuten eingeben');
|
||||
if (isNaN(minutes) || minutes <= 0) return alert('Enter valid minutes');
|
||||
|
||||
try {
|
||||
// Save the time log
|
||||
await logTime(ticketId, { minutes, comment, author: actor });
|
||||
|
||||
// Also add it as a comment
|
||||
const timeComment = `⏱ Zeit gebucht: ${formatMinutes(minutes)}${comment ? ` – ${comment}` : ''}`;
|
||||
await createComment(ticketId, {
|
||||
author: actor,
|
||||
role: 'admin',
|
||||
content: timeComment,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
const timeComment = `⏱ Time booked: ${formatMinutes(minutes)}${comment ? ` – ${comment}` : ''}`;
|
||||
await createComment(ticketId, { author: actor, role: 'admin', content: timeComment, timestamp: new Date().toISOString() });
|
||||
|
||||
// Webhook notification
|
||||
await sendWebhook(`Time booked: ${formatMinutes(minutes)} by ${actor} on ticket #${ticketId}${comment ? ` – "${comment}"` : ''}`);
|
||||
|
||||
// Clear inputs and reload
|
||||
document.getElementById('time-minutes').value = '';
|
||||
document.getElementById('time-comment').value = '';
|
||||
await loadPage();
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Speichern der Zeit:', err);
|
||||
alert('Fehler beim Speichern');
|
||||
console.error('Error saving time:', err);
|
||||
alert('Error saving time');
|
||||
}
|
||||
});
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.tab;
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById(`${tab}-tab`).classList.add('active');
|
||||
document.getElementById(`${btn.dataset.tab}-tab`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ export async function fetchHistory(id) {
|
||||
}
|
||||
|
||||
export async function logTime(id, timeLog) {
|
||||
// timeLog = { minutes: Number, comment: String, author: String }
|
||||
const res = await fetch(`/api/tickets/${id}/time`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -26,7 +25,6 @@ export async function logTime(id, timeLog) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Flexible Update-Funktion für beliebige Felder, z.B. {status, priority, category, actor, ...}
|
||||
export async function updateTicket(id, data) {
|
||||
const res = await fetch(`/api/tickets/${id}`, {
|
||||
method: 'PUT',
|
||||
@@ -45,15 +43,26 @@ export async function createComment(id, comment) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// XSS protection — escape HTML entities before inserting into innerHTML
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Convert URLs in text to clickable links (applied AFTER escaping)
|
||||
export function linkify(text) {
|
||||
const escaped = escapeHtml(text);
|
||||
const urlPattern = /(\bhttps?:\/\/[^\s<]+)/g;
|
||||
return escaped.replace(urlPattern, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
}
|
||||
|
||||
export function formatDateTime(date) {
|
||||
if (!date) return 'n/a';
|
||||
|
||||
// Convert SQLite DATETIME if needed
|
||||
const safeDate = date.includes('T') ? date : date.replace(' ', 'T');
|
||||
const parsed = new Date(safeDate + 'Z'); // Assume UTC
|
||||
|
||||
const parsed = new Date(safeDate + 'Z');
|
||||
if (isNaN(parsed.getTime())) return 'n/a';
|
||||
|
||||
return parsed.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
fetchTicket, fetchComments, fetchHistory,
|
||||
createComment,
|
||||
formatDateTime, formatMinutes, getHistoryIcon
|
||||
fetchTicket, fetchComments, fetchHistory, createComment,
|
||||
formatDateTime, formatMinutes, getHistoryIcon, escapeHtml, linkify
|
||||
} from './ticket-shared.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.classList.add('user-view');
|
||||
|
||||
const ticketId = new URLSearchParams(window.location.search).get('id');
|
||||
if (!ticketId) return window.location.href = '/';
|
||||
if (!ticketId) return (window.location.href = '/');
|
||||
|
||||
// DOM elements
|
||||
const ticketTitle = document.getElementById('ticket-title-text');
|
||||
const ticketIdElem = document.getElementById('ticket-id-val');
|
||||
const ticketStatus = document.getElementById('ticket-status-badge');
|
||||
@@ -24,54 +22,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const historyContainer = document.getElementById('history-container');
|
||||
const commentForm = document.getElementById('comment-form');
|
||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||
const totalTimeDisplay = document.getElementById('total-time');
|
||||
|
||||
let webhookConfig = null;
|
||||
|
||||
// Fetch Webhook Configuration
|
||||
async function loadWebhookConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/webhooks');
|
||||
if (response.ok) {
|
||||
webhookConfig = await response.json();
|
||||
console.log('Webhook configuration loaded:', webhookConfig.configured);
|
||||
} else {
|
||||
console.warn('Failed to load webhook configuration');
|
||||
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading webhook configuration:', error);
|
||||
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
|
||||
}
|
||||
}
|
||||
|
||||
// Send Webhook Notification
|
||||
async function sendAdminWebhook(message) {
|
||||
if (!webhookConfig?.admin) {
|
||||
console.log('Admin webhook not configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookConfig.admin, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: message })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Admin webhook failed:', res.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error sending admin webhook:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Links automatically convert to clickable
|
||||
function linkify(text) {
|
||||
const urlPattern = /(\bhttps?:\/\/[^\s<]+)/g;
|
||||
return text.replace(urlPattern, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
}
|
||||
|
||||
let originalStatus = '';
|
||||
let originalPriority = '';
|
||||
@@ -89,16 +39,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
ticketUpdated.textContent = formatDateTime(ticket.updated_at);
|
||||
ticketPriority.textContent = ticket.priority;
|
||||
|
||||
if (ticketCategory) {
|
||||
ticketCategory.textContent = ticket.category || '—';
|
||||
}
|
||||
if (ticketCategory) ticketCategory.textContent = ticket.category || '—';
|
||||
|
||||
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
|
||||
|
||||
const totalTimeDisplay = document.getElementById('total-time');
|
||||
if (totalTimeDisplay) {
|
||||
totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0);
|
||||
}
|
||||
if (totalTimeDisplay) totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0);
|
||||
|
||||
originalStatus = ticket.status;
|
||||
originalPriority = ticket.priority;
|
||||
@@ -110,20 +56,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const role = comment.role ? comment.role.toLowerCase() : 'user';
|
||||
let avatarUrl = '/assets/user.jpg';
|
||||
if (role === 'admin') avatarUrl = '/assets/admin.jpg';
|
||||
else if (role === 'moderator') avatarUrl = '/assets/moderator.jpg';
|
||||
|
||||
// Comment content is linkified
|
||||
const linkedContent = linkify(comment.content || '');
|
||||
|
||||
return `
|
||||
<div class="comment">
|
||||
<img class="comment-avatar" src="${avatarUrl}" alt="${role.charAt(0).toUpperCase() + role.slice(1)} Avatar">
|
||||
<div class="comment-content">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">${comment.author}</span>
|
||||
<span class="comment-author">${escapeHtml(comment.author)}</span>
|
||||
<span class="comment-date">${formatDateTime(comment.date)}</span>
|
||||
</div>
|
||||
<div class="comment-text">${linkedContent}</div>
|
||||
<div class="comment-text">${linkify(comment.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -136,10 +78,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="history-item">
|
||||
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
|
||||
<div class="history-content">
|
||||
<div class="history-action">${h.action}</div>
|
||||
<div class="history-details">${h.message || h.details || ''}</div>
|
||||
<div class="history-action">${escapeHtml(h.action)}</div>
|
||||
<div class="history-details">${escapeHtml(h.message || h.details || '')}</div>
|
||||
<div class="history-meta">
|
||||
<span class="history-actor">By ${h.author || h.actor || 'unknown'}</span>
|
||||
<span class="history-actor">By ${escapeHtml(h.author || h.actor || 'unknown')}</span>
|
||||
<span class="history-date">${formatDateTime(h.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,14 +96,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const content = document.getElementById('comment-content')?.value;
|
||||
if (!content?.trim()) return alert('Please enter a comment');
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await createComment(ticketId, { author, content, timestamp });
|
||||
await createComment(ticketId, { author, content, timestamp: new Date().toISOString() });
|
||||
// Webhook notification is handled server-side
|
||||
document.getElementById('comment-content').value = '';
|
||||
await loadComments();
|
||||
await loadHistory();
|
||||
await sendAdminWebhook(`New comment added by ${author}: "${content}" on ticket #${ticketId}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create comment:', err);
|
||||
alert('Failed to submit comment. Please try again.');
|
||||
@@ -173,19 +113,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const ticket = await fetchTicket(ticketId);
|
||||
if (!ticket) return;
|
||||
|
||||
// Update UI if status or priority changes server-side
|
||||
if (ticket.status !== originalStatus) {
|
||||
originalStatus = ticket.status;
|
||||
ticketStatus.textContent = ticket.status;
|
||||
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
|
||||
}
|
||||
|
||||
if (ticket.priority !== originalPriority) {
|
||||
originalPriority = ticket.priority;
|
||||
ticketPriority.textContent = ticket.priority;
|
||||
}
|
||||
|
||||
// Check and update category if necessary
|
||||
if (ticketCategory && ticket.category !== ticketCategory.textContent) {
|
||||
ticketCategory.textContent = ticket.category || '—';
|
||||
}
|
||||
@@ -199,12 +135,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}));
|
||||
|
||||
// Initial load
|
||||
loadWebhookConfig().then(() => {
|
||||
loadTicketDetails();
|
||||
loadComments();
|
||||
loadHistory();
|
||||
});
|
||||
loadTicketDetails();
|
||||
loadComments();
|
||||
loadHistory();
|
||||
|
||||
// Periodic updates
|
||||
// Poll for changes every 30s
|
||||
setInterval(checkForChanges, 30000);
|
||||
});
|
||||
@@ -7,10 +7,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon_ticket.ico">
|
||||
<link rel="stylesheet" href="ticket-view.css" />
|
||||
<link rel="stylesheet" href="admin-view.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -25,7 +22,6 @@
|
||||
|
||||
<main class="main-content">
|
||||
<div class="ticket-details-layout">
|
||||
<!-- Ticket Info Section -->
|
||||
<section class="ticket-details">
|
||||
<div class="ticket-header">
|
||||
<div id="ticket-title-view">
|
||||
@@ -42,28 +38,23 @@
|
||||
<h4>Requester</h4>
|
||||
<p id="ticket-requester">-</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item description">
|
||||
<h3>Description</h3>
|
||||
<p id="ticket-description-text">Loading...</p>
|
||||
<textarea id="ticket-description-input" class="form-control" style="display: none"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="info-item created">
|
||||
<h4>Created</h4>
|
||||
<p id="ticket-created">-</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item updated">
|
||||
<h4>Last Updated</h4>
|
||||
<p id="ticket-updated">-</p>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<h4>Time</h4>
|
||||
<p><span id="total-time">0</span></p>
|
||||
</div>
|
||||
|
||||
<div class="info-item status">
|
||||
<h4><label for="status-select">Status</label></h4>
|
||||
<select id="status-select" class="form-control">
|
||||
@@ -73,7 +64,6 @@
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-item priority">
|
||||
<h4><label for="priority-text">Priority</label></h4>
|
||||
<select id="priority-text" class="form-control">
|
||||
@@ -83,16 +73,10 @@
|
||||
<option value="Urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="info-item category">
|
||||
<h4><label for="category-select">Category</label></h4>
|
||||
<select id="category-select" class="form-control">
|
||||
<option value="HELP">HELP</option>
|
||||
<option value="HELP MEE NOOOW">HELP MEE NOOOW</option>
|
||||
<option value="IT-Support">IT-Support</option>
|
||||
<option value="Suggestion...">Suggestion...</option>
|
||||
<option value="Add Book pls">Add Book pls</option>
|
||||
<option value="Honeyycloud!">Honeyycloud!</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +91,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ticket Activity Section -->
|
||||
<section class="ticket-activity">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="comments">Comments</button>
|
||||
@@ -121,18 +104,13 @@
|
||||
<textarea id="comment-content" rows="2" placeholder="Add a comment..." required></textarea>
|
||||
<button class="btn-primary" type="submit">Post Comment</button>
|
||||
</form>
|
||||
|
||||
<form id="time-booking-form" class="comment-form compact-form">
|
||||
<input type="number" id="time-minutes" name="time-minutes" min="0" placeholder="Minutes" required />
|
||||
<textarea id="time-comment" name="time-comment" rows="2" placeholder="Optional comment"></textarea>
|
||||
<button class="btn-primary" type="submit">Add Time</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Comment Output -->
|
||||
<div id="comments-container"></div>
|
||||
|
||||
<!-- Time Logs -->
|
||||
<section id="time-logs" class="time-logs"></section>
|
||||
</div>
|
||||
|
||||
@@ -144,10 +122,11 @@
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Ticket System by Honey v1.1 © 2025</p>
|
||||
<p class="app-footer-text"></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script type="module" src="ticket-admin.js"></script>
|
||||
<script src="../version.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -110,10 +110,11 @@
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Ticket System by Honey v1.1 © 2025</p>
|
||||
<p class="app-footer-text"></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script type="module" src="ticket-user.js"></script>
|
||||
<script src="../version.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+83
-189
@@ -1,121 +1,82 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isAdminView = document.body.classList.contains('admin-view');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const ticketsContainer = document.getElementById('tickets-container');
|
||||
|
||||
const statusFilter = document.getElementById('status-filter');
|
||||
const categoryFilter = document.getElementById('category-filter');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
|
||||
// Webhook configuration - will be loaded dynamically
|
||||
let webhookConfig = null;
|
||||
|
||||
// --- Ticket Modal Elements ---
|
||||
const openModalBtn = document.getElementById('open-modal-btn');
|
||||
const modal = document.getElementById('create-ticket-modal');
|
||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
|
||||
// --- PFP Modal Elements ---
|
||||
const updatePfpBtn = document.getElementById('update-pfp-btn');
|
||||
const pfpModal = document.getElementById('update-pfp-modal');
|
||||
const closePfpModalBtn = document.getElementById('close-pfp-modal-btn');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('pfp-file-input');
|
||||
const uploadPfpBtn = document.getElementById('upload-pfp-btn');
|
||||
|
||||
// Accordion elements
|
||||
const closedToggle = document.getElementById('closed-toggle');
|
||||
const closedAccordion = document.getElementById('closed-accordion');
|
||||
const closedCount = document.getElementById('closed-count');
|
||||
|
||||
let selectedPfpFile = null;
|
||||
let allTickets = [];
|
||||
let accordionOpen = false;
|
||||
|
||||
// --- XSS Protection ---
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
function formatStatusClass(status) {
|
||||
if (!status) return 'Unknown';
|
||||
return status
|
||||
.split(' ')
|
||||
.map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
|
||||
.join('-');
|
||||
return status.split(' ').map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join('-');
|
||||
}
|
||||
|
||||
// --- Webhook Functions ---
|
||||
async function loadWebhookConfig() {
|
||||
// --- Dynamic Categories ---
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch('/api/webhooks');
|
||||
if (response.ok) {
|
||||
webhookConfig = await response.json();
|
||||
console.log('Webhook configuration loaded:', webhookConfig.configured);
|
||||
} else {
|
||||
console.warn('Failed to load webhook configuration');
|
||||
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading webhook configuration:', error);
|
||||
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
|
||||
const res = await fetch('/api/categories');
|
||||
const categories = await res.json();
|
||||
populateCategorySelects(categories);
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWebhookNotification(webhookUrl, message) {
|
||||
if (!webhookUrl) {
|
||||
console.log('No webhook URL provided, skipping notification');
|
||||
return;
|
||||
function populateCategorySelects(categories) {
|
||||
// Modal create form select
|
||||
const createSelect = document.getElementById('category');
|
||||
if (createSelect) {
|
||||
createSelect.innerHTML = categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Webhook notification failed: ${response.status} ${response.statusText}`);
|
||||
} else {
|
||||
console.log('Webhook notification sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send webhook notification:', error);
|
||||
// Filter select (keep "All Categories" as first option)
|
||||
if (categoryFilter) {
|
||||
categoryFilter.innerHTML = '<option value="all">All Categories</option>' +
|
||||
categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAdminWebhook(ticket) {
|
||||
if (!webhookConfig?.admin) {
|
||||
console.log('Admin webhook not configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
embeds: [{
|
||||
title: '🎫 New Ticket Created',
|
||||
description: `**Ticket #${ticket.id}**: ${ticket.title}`,
|
||||
fields: [
|
||||
{ name: '👤 Requester', value: 'Laila' },
|
||||
{ name: '📊 Status', value: ticket.status || 'Open', inline: true },
|
||||
{ name: '⚡ Priority', value: ticket.priority || 'None', inline: true },
|
||||
{ name: '📂 Category', value: ticket.category || 'None', inline: true },
|
||||
{ name: '📝 Description', value: ticket.description.length > 100 ?
|
||||
ticket.description.substring(0, 100) + '...' :
|
||||
ticket.description, inline: false }
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0x00ff00,
|
||||
footer: {
|
||||
text: 'Ticket System',
|
||||
icon_url: 'https://cdn-icons-png.flaticon.com/512/2666/2666505.png'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
await sendWebhookNotification(webhookConfig.admin, message);
|
||||
// --- Accordion ---
|
||||
if (closedToggle) {
|
||||
closedToggle.addEventListener('click', () => {
|
||||
accordionOpen = !accordionOpen;
|
||||
closedAccordion.style.display = accordionOpen ? 'block' : 'none';
|
||||
closedToggle.classList.toggle('open', accordionOpen);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ticket Modal Open/Close ---
|
||||
// --- Ticket Modal ---
|
||||
function openModal() {
|
||||
if (modal) {
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (scrollBarWidth > 0) {
|
||||
document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||
}
|
||||
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||
}
|
||||
}
|
||||
function closeModal() {
|
||||
@@ -126,31 +87,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.paddingRight = '';
|
||||
}
|
||||
}
|
||||
function outsideClickListener(event) {
|
||||
if (event.target === modal) closeModal();
|
||||
}
|
||||
function escKeyListener(event) {
|
||||
if (event.key === 'Escape' && modal.style.display === 'flex') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
if (openModalBtn) openModalBtn.addEventListener('click', openModal);
|
||||
if (closeModalBtn) closeModalBtn.addEventListener('click', closeModal);
|
||||
if (modal) {
|
||||
window.addEventListener('click', outsideClickListener);
|
||||
window.addEventListener('keydown', escKeyListener);
|
||||
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'flex') closeModal(); });
|
||||
}
|
||||
|
||||
// --- PFP Modal Open/Close ---
|
||||
// --- PFP Modal ---
|
||||
function openPfpModal() {
|
||||
if (pfpModal) {
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
pfpModal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (scrollBarWidth > 0) {
|
||||
document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||
}
|
||||
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||
}
|
||||
}
|
||||
function closePfpModal() {
|
||||
@@ -162,26 +113,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal);
|
||||
if (closePfpModalBtn) closePfpModalBtn.addEventListener('click', closePfpModal);
|
||||
if (pfpModal) {
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === pfpModal) closePfpModal();
|
||||
});
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal();
|
||||
});
|
||||
window.addEventListener('click', (e) => { if (e.target === pfpModal) closePfpModal(); });
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal(); });
|
||||
}
|
||||
|
||||
// --- Drag & Drop Logic ---
|
||||
// --- Drag & Drop ---
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.borderColor = '#10b981';
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.style.borderColor = '#4b5563';
|
||||
});
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.borderColor = '#10b981'; });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#4b5563'; });
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.borderColor = '#4b5563';
|
||||
@@ -192,7 +133,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
@@ -202,21 +142,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Upload PFP ---
|
||||
if (uploadPfpBtn) {
|
||||
uploadPfpBtn.addEventListener('click', async () => {
|
||||
if (!selectedPfpFile) {
|
||||
alert('Please select an image first.');
|
||||
return;
|
||||
}
|
||||
if (!selectedPfpFile) return alert('Please select an image first.');
|
||||
const formData = new FormData();
|
||||
formData.append('pfp', selectedPfpFile, 'user.jpg');
|
||||
try {
|
||||
const res = await fetch('/api/upload-pfp', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const res = await fetch('/api/upload-pfp', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
alert('Profile picture updated!');
|
||||
closePfpModal();
|
||||
@@ -239,23 +171,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
async function createTicket(ticket) {
|
||||
try {
|
||||
const res = await fetch('/api/tickets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ticket)
|
||||
});
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error('Error creating ticket:', err);
|
||||
throw err;
|
||||
}
|
||||
const res = await fetch('/api/tickets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ticket),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create ticket');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// --- Date Formatting ---
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,38 +192,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const category = categoryFilter?.value || 'all';
|
||||
const search = searchInput?.value.trim().toLowerCase() || '';
|
||||
|
||||
const openTickets = allTickets.filter(ticket =>
|
||||
(ticket.status || '').toLowerCase() !== 'closed'
|
||||
);
|
||||
const closedTickets = allTickets.filter(ticket =>
|
||||
(ticket.status || '').toLowerCase() === 'closed'
|
||||
);
|
||||
|
||||
const filteredOpen = openTickets.filter(ticket => {
|
||||
const filtered = allTickets.filter(ticket => {
|
||||
const ticketStatus = (ticket.status || '').toLowerCase();
|
||||
const ticketCategory = (ticket.category || '').toLowerCase();
|
||||
const ticketTitle = (ticket.title || '').toLowerCase();
|
||||
const ticketDescription = (ticket.description || '').toLowerCase();
|
||||
const isClosed = ticketStatus === 'closed';
|
||||
const matchesStatus = status === 'all' || ticketStatus === status.toLowerCase();
|
||||
const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase();
|
||||
const matchesSearch =
|
||||
ticketTitle.includes(search) ||
|
||||
ticketDescription.includes(search);
|
||||
return matchesStatus && matchesCategory && matchesSearch;
|
||||
const matchesSearch = ticketTitle.includes(search) || ticketDescription.includes(search);
|
||||
return (isClosed || matchesStatus) && matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
const filteredClosed = closedTickets.filter(ticket => {
|
||||
const ticketCategory = (ticket.category || '').toLowerCase();
|
||||
const ticketTitle = (ticket.title || '').toLowerCase();
|
||||
const ticketDescription = (ticket.description || '').toLowerCase();
|
||||
const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase();
|
||||
const matchesSearch =
|
||||
ticketTitle.includes(search) ||
|
||||
ticketDescription.includes(search);
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
renderTickets([...filteredOpen, ...filteredClosed]);
|
||||
renderTickets(filtered);
|
||||
}
|
||||
|
||||
function renderTickets(tickets) {
|
||||
@@ -304,6 +212,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const closedContainer = document.getElementById('closed-tickets-container');
|
||||
openContainer.innerHTML = '';
|
||||
closedContainer.innerHTML = '';
|
||||
|
||||
let closedTicketCount = 0;
|
||||
|
||||
if (!tickets.length) {
|
||||
openContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
@@ -311,29 +222,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<h3>No tickets found</h3>
|
||||
<p>Try changing your filters or create a new ticket</p>
|
||||
</div>`;
|
||||
closedContainer.innerHTML = '';
|
||||
if (closedCount) closedCount.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
tickets.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
tickets.forEach(ticket => {
|
||||
const statusSafe = formatStatusClass(ticket.status);
|
||||
const prioritySafe = (ticket.priority || 'None');
|
||||
const prioritySafe = ticket.priority || 'None';
|
||||
const el = document.createElement('div');
|
||||
el.className = `ticket status-${statusSafe.replace(/\s+/g, '-')}`;
|
||||
el.innerHTML = `
|
||||
<h3>${ticket.title} <span class="ticket-id">#${ticket.id}</span> <small class="total-time"> Time Cin: (${ticket.total_time} min)</small> </h3>
|
||||
<p class="description">${ticket.description}</p>
|
||||
<h3>${escapeHtml(ticket.title)} <span class="ticket-id">#${ticket.id}</span> <small class="total-time">Time: (${ticket.total_time} min)</small></h3>
|
||||
<p class="description">${escapeHtml(ticket.description)}</p>
|
||||
<div class="meta">
|
||||
<span class="requester"><i class="fas fa-user"></i> ${ticket.requester}</span>
|
||||
<span class="requester"><i class="fas fa-user"></i> ${escapeHtml(ticket.requester)}</span>
|
||||
<span class="date"><i class="fas fa-calendar"></i> ${formatDate(ticket.created_at)}</span>
|
||||
${ticket.category ? `<span class="category"><i class="fas fa-tag"></i> ${ticket.category}</span>` : ''}
|
||||
${ticket.category ? `<span class="category"><i class="fas fa-tag"></i> ${escapeHtml(ticket.category)}</span>` : ''}
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<div class="status status-${statusSafe.replace(/\s+/g, '-')}">
|
||||
<i class="fas fa-circle"></i> ${ticket.status}
|
||||
<i class="fas fa-circle"></i> ${escapeHtml(ticket.status)}
|
||||
</div>
|
||||
<div class="priority priority-${prioritySafe.replace(/\s+/g, '-')}">
|
||||
<i class="fas fa-flag"></i> ${prioritySafe}
|
||||
<i class="fas fa-flag"></i> ${escapeHtml(prioritySafe)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-actions">
|
||||
@@ -344,14 +257,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
`;
|
||||
if ((ticket.status || '').toLowerCase() === 'closed') {
|
||||
closedContainer.appendChild(el);
|
||||
closedTicketCount++;
|
||||
} else {
|
||||
openContainer.appendChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
if (closedCount) closedCount.textContent = closedTicketCount;
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
let isSubmittingTicket = false; // prevent double submit
|
||||
let isSubmittingTicket = false;
|
||||
|
||||
async function handleCreateTicket(e) {
|
||||
e.preventDefault();
|
||||
@@ -372,28 +288,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const submitBtn = createForm.querySelector('.btn-primary');
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const ticket = {
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
category,
|
||||
requester: 'Laila',
|
||||
status: 'Open'
|
||||
};
|
||||
|
||||
try {
|
||||
const createdTicket = await createTicket(ticket);
|
||||
await createTicket({ title, description, priority, category, requester: 'Laila', status: 'Open' });
|
||||
createForm.reset();
|
||||
closeModal();
|
||||
await refreshTickets();
|
||||
await sendAdminWebhook({
|
||||
id: createdTicket.id,
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
category,
|
||||
status: 'Open'
|
||||
});
|
||||
|
||||
submitBtn.innerHTML = '<i class="fas fa-check"></i> Created!';
|
||||
submitBtn.style.backgroundColor = '#28a745';
|
||||
@@ -416,15 +315,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
async function initialize() {
|
||||
await loadWebhookConfig();
|
||||
await refreshTickets();
|
||||
}
|
||||
|
||||
if (createForm) createForm.addEventListener('submit', handleCreateTicket);
|
||||
if (statusFilter) statusFilter.addEventListener('change', renderFilteredTickets);
|
||||
if (categoryFilter) categoryFilter.addEventListener('change', renderFilteredTickets);
|
||||
if (searchInput) searchInput.addEventListener('input', renderFilteredTickets);
|
||||
|
||||
initialize();
|
||||
loadCategories().then(() => refreshTickets());
|
||||
});
|
||||
@@ -654,4 +654,54 @@ a:focus {
|
||||
.header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
/* ===== ACCORDION ===== */
|
||||
.accordion-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--neutral-medium);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-toggle:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.accordion-toggle .accordion-arrow {
|
||||
margin-left: auto;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion-toggle.open .accordion-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.accordion-count {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
padding-top: 15px;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Fetches app version and updates all footer texts
|
||||
fetch('/api/version')
|
||||
.then(res => res.json())
|
||||
.then(({ version }) => {
|
||||
document.querySelectorAll('.app-footer-text').forEach(el => {
|
||||
el.textContent = `Ticket System by Honey v${version} © 2026`;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
Reference in new issue
Block a user