V.1.2 - Improved Secrurity, Better Ticket Handling
This commit is contained in:
1 parent
cb5d091be2
commit
3136c81533
16 files changed
+819
-678
No files matched your search
+1
-3
@@ -2,17 +2,15 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files first for better layer caching
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy the rest of the application
|
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
|
COPY categories.json ./
|
||||||
COPY public/ ./public/
|
COPY public/ ./public/
|
||||||
COPY assets/ ./assets/
|
COPY assets/ ./assets/
|
||||||
|
|
||||||
# Create data directory for the database
|
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
"HELP",
|
||||||
|
"HELP MEE NOOOW",
|
||||||
|
"IT-Support",
|
||||||
|
"Suggestion...",
|
||||||
|
"Add Book pls",
|
||||||
|
"Honeyycloud!"
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-2
@@ -6,7 +6,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # Bind mount for the SQLite database
|
- ./data:/app/data
|
||||||
- ./assets:/app/assets # Bind mount so you can update avatars etc.
|
- ./assets:/app/assets
|
||||||
|
- ./categories.json:/app/categories.json
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
+1
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ticketsystem",
|
"name": "ticketsystem",
|
||||||
"version": "1.2",
|
"version": "1.2.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,10 +14,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"date-fns-tz": "^3.2.0",
|
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"luxon": "^3.7.1",
|
|
||||||
"moment-timezone": "^0.6.0",
|
"moment-timezone": "^0.6.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
|
|||||||
+137
-35
@@ -9,21 +9,130 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const statOpen = document.getElementById('open-tickets');
|
const statOpen = document.getElementById('open-tickets');
|
||||||
const statInProgress = document.getElementById('in-progress-tickets');
|
const statInProgress = document.getElementById('in-progress-tickets');
|
||||||
const statClosed = document.getElementById('closed-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);
|
searchInput.addEventListener('input', refreshTickets);
|
||||||
statusFilter.addEventListener('change', refreshTickets);
|
statusFilter.addEventListener('change', refreshTickets);
|
||||||
priorityFilter.addEventListener('change', refreshTickets);
|
priorityFilter.addEventListener('change', refreshTickets);
|
||||||
categoryFilter.addEventListener('change', refreshTickets);
|
categoryFilter.addEventListener('change', refreshTickets);
|
||||||
|
|
||||||
// Helper: Status/Priority zu "Title-Case-With-Dash"
|
// --- XSS Protection ---
|
||||||
function toTitleCaseWithDash(str) {
|
function escapeHtml(str) {
|
||||||
return str
|
if (!str) return '';
|
||||||
.split(' ')
|
const div = document.createElement('div');
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
div.textContent = str;
|
||||||
.join('-');
|
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() {
|
async function fetchTickets() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tickets');
|
const res = await fetch('/api/tickets');
|
||||||
@@ -39,7 +148,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const res = await fetch(`/api/tickets/${id}`, {
|
const res = await fetch(`/api/tickets/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ actor: 'Admin' })
|
body: JSON.stringify({ actor: 'Admin' }),
|
||||||
});
|
});
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -59,33 +168,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const category = categoryFilter.value;
|
const category = categoryFilter.value;
|
||||||
const searchTerm = searchInput.value.toLowerCase();
|
const searchTerm = searchInput.value.toLowerCase();
|
||||||
|
|
||||||
const filtered = tickets.filter(t =>
|
const filtered = tickets
|
||||||
|
.filter(t =>
|
||||||
(status === 'all' || t.status === status) &&
|
(status === 'all' || t.status === status) &&
|
||||||
(priority === 'all' || t.priority === priority) &&
|
(priority === 'all' || t.priority === priority) &&
|
||||||
(category === 'all' || t.category === category) &&
|
(category === 'all' || t.category === category) &&
|
||||||
(t.title.toLowerCase().includes(searchTerm) ||
|
(t.title.toLowerCase().includes(searchTerm) ||
|
||||||
t.description.toLowerCase().includes(searchTerm) ||
|
t.description.toLowerCase().includes(searchTerm) ||
|
||||||
t.requester.toLowerCase().includes(searchTerm))
|
t.requester.toLowerCase().includes(searchTerm))
|
||||||
).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
)
|
||||||
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
const openRows = [];
|
const openRows = [];
|
||||||
const closedRows = [];
|
const closedRows = [];
|
||||||
|
|
||||||
filtered.forEach(ticket => {
|
filtered.forEach(ticket => {
|
||||||
const shortTitle = ticket.title.length > 50 ? ticket.title.slice(0, 50) + '...' : ticket.title;
|
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 statusClass = toTitleCaseWithDash(ticket.status || 'Unknown');
|
||||||
const priorityClass = toTitleCaseWithDash(ticket.priority || 'None');
|
const priorityClass = toTitleCaseWithDash(ticket.priority || 'None');
|
||||||
|
|
||||||
const rowHTML = `
|
const rowHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${ticket.id}</td>
|
<td>${ticket.id}</td>
|
||||||
<td class="ticket-title">${shortTitle}</td>
|
<td class="ticket-title">${escapeHtml(shortTitle)}</td>
|
||||||
<td>${ticket.requester}</td>
|
<td>${escapeHtml(ticket.requester)}</td>
|
||||||
<td>${ticket.category || '-'}</td>
|
<td>${escapeHtml(ticket.category) || '-'}</td>
|
||||||
<td><span class="status-badge status-${statusClass}">${ticket.status || 'Unknown'}</span></td>
|
<td><span class="status-badge status-${statusClass}">${escapeHtml(ticket.status) || 'Unknown'}</span></td>
|
||||||
<td><span class="priority-badge priority-${priorityClass}">${ticket.priority || 'None'}</span></td>
|
<td><span class="priority-badge priority-${priorityClass}">${escapeHtml(ticket.priority) || 'None'}</span></td>
|
||||||
<td>${formatDate(ticket.created_at)}</td>
|
<td>${formatDate(ticket.created_at)}</td>
|
||||||
<td>${ticket.total_time} min</td>
|
<td>${ticket.total_time} min</td>
|
||||||
<td class="table-actions">
|
<td class="table-actions">
|
||||||
@@ -94,31 +203,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
if (ticket.status === 'Closed') {
|
if (ticket.status === 'Closed') closedRows.push(rowHTML);
|
||||||
closedRows.push(rowHTML);
|
else openRows.push(rowHTML);
|
||||||
} else {
|
|
||||||
openRows.push(rowHTML);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ticketsBody.innerHTML = openRows.length
|
ticketsBody.innerHTML = openRows.length
|
||||||
? openRows.join('')
|
? 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) {
|
if (closedTicketsBody) {
|
||||||
closedTicketsBody.innerHTML = closedRows.length
|
closedTicketsBody.innerHTML = closedRows.length
|
||||||
? closedRows.join('')
|
? 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
|
if (closedCount) closedCount.textContent = closedRows.length;
|
||||||
[...document.querySelectorAll('.view-btn')].forEach(btn =>
|
|
||||||
|
document.querySelectorAll('.view-btn').forEach(btn =>
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
window.location.href = `/ticketview/ticket-view-admin.html?id=${btn.dataset.id}`;
|
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', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (confirm('Are you sure you want to delete this ticket?')) {
|
if (confirm('Are you sure you want to delete this ticket?')) {
|
||||||
deleteTicket(btn.dataset.id).then(refreshTickets);
|
deleteTicket(btn.dataset.id).then(refreshTickets);
|
||||||
@@ -132,19 +238,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
statOpen.textContent = tickets.filter(t => t.status === 'Open').length;
|
statOpen.textContent = tickets.filter(t => t.status === 'Open').length;
|
||||||
statInProgress.textContent = tickets.filter(t => t.status === 'In Progress').length;
|
statInProgress.textContent = tickets.filter(t => t.status === 'In Progress').length;
|
||||||
statClosed.textContent = tickets.filter(t => t.status === 'Closed').length;
|
statClosed.textContent = tickets.filter(t => t.status === 'Closed').length;
|
||||||
|
const totalTime = tickets.reduce((sum, t) => sum + (t.total_time || 0), 0);
|
||||||
// Gesamtzeit aller Tickets summieren (total_time in Minuten)
|
|
||||||
const totalTime = tickets.reduce((sum, ticket) => sum + (ticket.total_time || 0), 0);
|
|
||||||
statTotalTime.textContent = `${totalTime} min`;
|
statTotalTime.textContent = `${totalTime} min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return new Date(date).toLocaleDateString(undefined, {
|
return new Date(date).toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTickets();
|
loadCategories().then(() => refreshTickets());
|
||||||
});
|
});
|
||||||
+32
-10
@@ -13,12 +13,30 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1><i class="fas fa-ticket-alt"></i> Admin Dashboard</h1>
|
<h1><i class="fas fa-ticket-alt"></i> Admin Dashboard</h1>
|
||||||
<div>
|
<div class="user-actions">
|
||||||
<span>Welcome, Admin!</span>
|
<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>
|
<a href="/">User View</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<main class="main-content">
|
||||||
<section class="admin-stats">
|
<section class="admin-stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -64,10 +82,6 @@
|
|||||||
</select>
|
</select>
|
||||||
<select id="category-filter">
|
<select id="category-filter">
|
||||||
<option value="all">All Categories</option>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,10 +106,16 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Closed Tickets Accordion -->
|
||||||
<section class="closed-tickets-section">
|
<section class="closed-tickets-section">
|
||||||
<div class="section-header">
|
<div class="accordion-section">
|
||||||
<h2><i class="fas fa-archive"></i> Closed Tickets</h2>
|
<button class="accordion-toggle" id="closed-toggle">
|
||||||
</div>
|
<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">
|
<div class="admin-tickets-list">
|
||||||
<table id="closed-tickets-table">
|
<table id="closed-tickets-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -114,14 +134,16 @@
|
|||||||
<tbody id="closed-tickets-body"></tbody>
|
<tbody id="closed-tickets-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>Ticketsystem by Honey v1.1 © 2025</p>
|
<p class="app-footer-text"></p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin-script.js"></script>
|
<script src="admin-script.js"></script>
|
||||||
|
<script src="version.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+16
-34
@@ -6,10 +6,7 @@
|
|||||||
<title>Laila Dashboard - Ticket System!</title>
|
<title>Laila Dashboard - Ticket System!</title>
|
||||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="user.css" />
|
<link rel="stylesheet" href="user.css" />
|
||||||
<link
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -30,9 +27,7 @@
|
|||||||
<!-- Modal Window for Creating Ticket -->
|
<!-- Modal Window for Creating Ticket -->
|
||||||
<div id="create-ticket-modal" class="modal" style="display:none;">
|
<div id="create-ticket-modal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button id="close-modal-btn" class="modal-close-btn" aria-label="Close modal">
|
<button id="close-modal-btn" class="modal-close-btn" aria-label="Close modal">×</button>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<section class="ticket-form">
|
<section class="ticket-form">
|
||||||
<h2><i class="fas fa-plus-circle"></i> Create New Ticket</h2>
|
<h2><i class="fas fa-plus-circle"></i> Create New Ticket</h2>
|
||||||
<form id="create-form">
|
<form id="create-form">
|
||||||
@@ -40,12 +35,7 @@
|
|||||||
<input type="text" id="title" placeholder="Ticket Title" required />
|
<input type="text" id="title" placeholder="Ticket Title" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<textarea
|
<textarea id="description" rows="3" placeholder="Describe your issue..." required></textarea>
|
||||||
id="description"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Describe your issue..."
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -57,14 +47,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select id="category">
|
<select id="category"></select>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group button-wrapper">
|
<div class="form-group button-wrapper">
|
||||||
@@ -81,12 +64,10 @@
|
|||||||
<div id="update-pfp-modal" class="modal" style="display:none;">
|
<div id="update-pfp-modal" class="modal" style="display:none;">
|
||||||
<div class="modal-content small-modal">
|
<div class="modal-content small-modal">
|
||||||
<h2><i class="fas fa-camera"></i> Update Profile Picture</h2>
|
<h2><i class="fas fa-camera"></i> Update Profile Picture</h2>
|
||||||
|
|
||||||
<div id="drop-zone">
|
<div id="drop-zone">
|
||||||
<p>Drag & Drop your picture here<br>or click to select</p>
|
<p>Drag & Drop your picture here<br>or click to select</p>
|
||||||
<input type="file" id="pfp-file-input" accept="image/*" hidden>
|
<input type="file" id="pfp-file-input" accept="image/*" hidden>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="upload-pfp-btn" class="btn-update-pfp" style="margin-top: 12px;">
|
<button id="upload-pfp-btn" class="btn-update-pfp" style="margin-top: 12px;">
|
||||||
<i class="fas fa-upload"></i> Upload
|
<i class="fas fa-upload"></i> Upload
|
||||||
</button>
|
</button>
|
||||||
@@ -107,35 +88,36 @@
|
|||||||
</select>
|
</select>
|
||||||
<select id="category-filter">
|
<select id="category-filter">
|
||||||
<option value="all">All Categories</option>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🟢 Open/Active Tickets -->
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Tickets</h3>
|
<h3>Active Tickets</h3>
|
||||||
<div id="tickets-container" class="tickets-grid"></div>
|
<div id="tickets-container" class="tickets-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🔴 Closed Tickets -->
|
<!-- Closed Tickets Accordion -->
|
||||||
<div style="margin-top: 2rem;">
|
<div class="accordion-section" style="margin-top: 2rem;">
|
||||||
<h3>Closed Tickets</h3>
|
<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 id="closed-tickets-container" class="tickets-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>Ticket System by Honey v1.1 © 2025</p>
|
<p class="app-footer-text"></p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="user-script.js"></script>
|
<script src="user-script.js"></script>
|
||||||
|
<script src="version.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
fetchTicket, fetchComments, fetchHistory, createComment, logTime,
|
fetchTicket, fetchComments, fetchHistory, createComment, logTime,
|
||||||
formatDateTime, formatMinutes, getHistoryIcon
|
formatDateTime, formatMinutes, getHistoryIcon, escapeHtml, linkify
|
||||||
} from './ticket-shared.js';
|
} from './ticket-shared.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const ticketId = new URLSearchParams(window.location.search).get('id');
|
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 statusSelect = document.getElementById('status-select');
|
||||||
const priorityInput = document.getElementById('priority-text');
|
const priorityInput = document.getElementById('priority-text');
|
||||||
@@ -19,55 +19,26 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const totalTimeDisplay = document.getElementById('total-time');
|
const totalTimeDisplay = document.getElementById('total-time');
|
||||||
const timeForm = document.getElementById('time-booking-form');
|
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 = '';
|
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() {
|
async function loadPage() {
|
||||||
loadWebhookConfig().then(() => {
|
await loadCategories();
|
||||||
loadTicketDetails();
|
await Promise.all([loadTicketDetails(), loadComments(), loadHistory()]);
|
||||||
loadComments();
|
|
||||||
loadHistory();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTicketDetails() {
|
async function loadTicketDetails() {
|
||||||
@@ -84,9 +55,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
document.getElementById('ticket-requester').textContent = ticket.requester;
|
document.getElementById('ticket-requester').textContent = ticket.requester;
|
||||||
document.getElementById('ticket-created').textContent = formatDateTime(ticket.created_at);
|
document.getElementById('ticket-created').textContent = formatDateTime(ticket.created_at);
|
||||||
document.getElementById('ticket-updated').textContent = formatDateTime(ticket.updated_at);
|
document.getElementById('ticket-updated').textContent = formatDateTime(ticket.updated_at);
|
||||||
const totalMins = typeof ticket.total_time === 'number' ? ticket.total_time : 0;
|
totalTimeDisplay.textContent = formatMinutes(typeof ticket.total_time === 'number' ? ticket.total_time : 0);
|
||||||
totalTimeDisplay.textContent = formatMinutes(totalMins);
|
|
||||||
|
|
||||||
|
|
||||||
statusSelect.className = 'form-control status-' + (ticket.status || '').toLowerCase().replace(/\s+/g, '-');
|
statusSelect.className = 'form-control status-' + (ticket.status || '').toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
@@ -99,46 +68,21 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
async function updateTicket({ status, priority, title, description, category, actor = 'Admin' }) {
|
async function updateTicket({ status, priority, title, description, category, actor = 'Admin' }) {
|
||||||
try {
|
try {
|
||||||
const ticket = await fetchTicket(ticketId); // Fetch ticket to get the requester
|
const ticket = await fetchTicket(ticketId);
|
||||||
const requester = ticket.requester || ''; // Ensure requester is included in the update request
|
const requester = ticket.requester || '';
|
||||||
|
|
||||||
const res = await fetch(`/api/tickets/${ticketId}`, {
|
const res = await fetch(`/api/tickets/${ticketId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status, priority, title, description, category, actor, requester }) // Add requester
|
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');
|
||||||
|
|
||||||
|
// Webhooks are handled server-side now — no need to send from here
|
||||||
alert('Ticket updated successfully');
|
alert('Ticket updated successfully');
|
||||||
|
|
||||||
if (status !== originalStatus) {
|
|
||||||
await sendWebhook(`Ticket #${ticketId} status changed from "${originalStatus}" to "${status}"`);
|
|
||||||
originalStatus = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
titleText.textContent = title;
|
||||||
descText.textContent = description;
|
descText.textContent = description;
|
||||||
|
|
||||||
await loadPage();
|
await loadPage();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update failed:', err);
|
console.error('Update failed:', err);
|
||||||
@@ -146,21 +90,22 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
document.getElementById('update-ticket-btn').addEventListener('click', async () => {
|
document.getElementById('update-ticket-btn').addEventListener('click', async () => {
|
||||||
const isEditingTitle = titleInput.style.display === 'block';
|
const isEditingTitle = titleInput.style.display === 'block';
|
||||||
const isEditingDesc = descInput.style.display === 'block';
|
const isEditingDesc = descInput.style.display === 'block';
|
||||||
|
|
||||||
const newTitle = isEditingTitle ? titleInput.value.trim() : titleText.textContent;
|
const newTitle = isEditingTitle ? titleInput.value.trim() : titleText.textContent;
|
||||||
const newDesc = isEditingDesc ? descInput.value.trim() : descText.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 (!confirm('Save changes?')) return;
|
||||||
if (!confirmed) 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';
|
titleInput.style.display = 'none';
|
||||||
titleText.style.display = 'block';
|
titleText.style.display = 'block';
|
||||||
@@ -169,16 +114,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('close-ticket-btn').addEventListener('click', async () => {
|
document.getElementById('close-ticket-btn').addEventListener('click', async () => {
|
||||||
const confirmed = confirm('Möchtest du das Ticket wirklich schließen?');
|
if (!confirm('Close this ticket?')) return;
|
||||||
if (!confirmed) return;
|
await updateTicket({
|
||||||
|
status: 'Closed',
|
||||||
const newStatus = 'Closed';
|
priority: priorityInput.value,
|
||||||
const newPriority = priorityInput.value;
|
title: titleText.textContent,
|
||||||
const newTitle = titleText.textContent;
|
description: descText.textContent,
|
||||||
const newDesc = descText.textContent;
|
category: categorySelect.value,
|
||||||
const newCategory = categorySelect.value;
|
});
|
||||||
|
|
||||||
await updateTicket({ status: newStatus, priority: newPriority, title: newTitle, description: newDesc, category: newCategory });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
titleText.addEventListener('dblclick', () => {
|
titleText.addEventListener('dblclick', () => {
|
||||||
@@ -195,6 +138,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
descInput.focus();
|
descInput.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore saved author name
|
||||||
const savedAuthor = localStorage.getItem('commentAuthor');
|
const savedAuthor = localStorage.getItem('commentAuthor');
|
||||||
if (savedAuthor) commentAuthorInput.value = savedAuthor;
|
if (savedAuthor) commentAuthorInput.value = savedAuthor;
|
||||||
|
|
||||||
@@ -205,17 +149,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const role = comment.role || 'user';
|
const role = comment.role || 'user';
|
||||||
const isAdmin = role === 'admin';
|
const isAdmin = role === 'admin';
|
||||||
const avatarUrl = isAdmin ? '/assets/admin.jpg' : '/assets/user.jpg';
|
const avatarUrl = isAdmin ? '/assets/admin.jpg' : '/assets/user.jpg';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="comment ${isAdmin ? 'comment-admin' : ''}">
|
<div class="comment ${isAdmin ? 'comment-admin' : ''}">
|
||||||
<img class="comment-avatar" src="${avatarUrl}" alt="${isAdmin ? 'Admin' : 'User'} Avatar">
|
<img class="comment-avatar" src="${avatarUrl}" alt="${isAdmin ? 'Admin' : 'User'} Avatar">
|
||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<div class="comment-header">
|
<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>' : ''}
|
${isAdmin ? '<span class="comment-role-badge">Admin</span>' : ''}
|
||||||
<span class="comment-date">${formatDateTime(comment.date)}</span>
|
<span class="comment-date">${formatDateTime(comment.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">${comment.content}</div>
|
<div class="comment-text">${linkify(comment.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -228,10 +171,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
<div class="history-item">
|
<div class="history-item">
|
||||||
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
|
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
|
||||||
<div class="history-content">
|
<div class="history-content">
|
||||||
<div class="history-action">${h.action}</div>
|
<div class="history-action">${escapeHtml(h.action)}</div>
|
||||||
<div class="history-details">${h.message || ''}</div>
|
<div class="history-details">${escapeHtml(h.message || '')}</div>
|
||||||
<div class="history-meta">
|
<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>
|
<span>${formatDateTime(h.date || h.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,20 +183,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
commentForm.addEventListener('submit', async (e) => {
|
commentForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const author = commentAuthorInput.value.trim();
|
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();
|
const content = document.getElementById('comment-content').value.trim();
|
||||||
if (!content) return alert('Comment required');
|
if (!content) return alert('Comment required');
|
||||||
|
|
||||||
const role = 'admin';
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createComment(ticketId, { author, role, content, timestamp });
|
await createComment(ticketId, { author, role: 'admin', content, timestamp: new Date().toISOString() });
|
||||||
await sendWebhook(`New comment added by ${author} (${role}): "${content}" on ticket #${ticketId}`);
|
|
||||||
localStorage.setItem('commentAuthor', author);
|
localStorage.setItem('commentAuthor', author);
|
||||||
|
|
||||||
document.getElementById('comment-content').value = '';
|
document.getElementById('comment-content').value = '';
|
||||||
await loadComments();
|
await loadComments();
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
@@ -269,41 +206,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const comment = document.getElementById('time-comment').value.trim();
|
const comment = document.getElementById('time-comment').value.trim();
|
||||||
const actor = commentAuthorInput.value || 'Admin';
|
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 {
|
try {
|
||||||
// Save the time log
|
|
||||||
await logTime(ticketId, { minutes, comment, author: actor });
|
await logTime(ticketId, { minutes, comment, author: actor });
|
||||||
|
|
||||||
// Also add it as a comment
|
const timeComment = `⏱ Time booked: ${formatMinutes(minutes)}${comment ? ` – ${comment}` : ''}`;
|
||||||
const timeComment = `⏱ Zeit gebucht: ${formatMinutes(minutes)}${comment ? ` – ${comment}` : ''}`;
|
await createComment(ticketId, { author: actor, role: 'admin', content: timeComment, timestamp: new Date().toISOString() });
|
||||||
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-minutes').value = '';
|
||||||
document.getElementById('time-comment').value = '';
|
document.getElementById('time-comment').value = '';
|
||||||
await loadPage();
|
await loadPage();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Speichern der Zeit:', err);
|
console.error('Error saving time:', err);
|
||||||
alert('Fehler beim Speichern');
|
alert('Error saving time');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tabs
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const tab = btn.dataset.tab;
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('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) {
|
export async function logTime(id, timeLog) {
|
||||||
// timeLog = { minutes: Number, comment: String, author: String }
|
|
||||||
const res = await fetch(`/api/tickets/${id}/time`, {
|
const res = await fetch(`/api/tickets/${id}/time`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -26,7 +25,6 @@ export async function logTime(id, timeLog) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flexible Update-Funktion für beliebige Felder, z.B. {status, priority, category, actor, ...}
|
|
||||||
export async function updateTicket(id, data) {
|
export async function updateTicket(id, data) {
|
||||||
const res = await fetch(`/api/tickets/${id}`, {
|
const res = await fetch(`/api/tickets/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -45,15 +43,26 @@ export async function createComment(id, comment) {
|
|||||||
return res.json();
|
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) {
|
export function formatDateTime(date) {
|
||||||
if (!date) return 'n/a';
|
if (!date) return 'n/a';
|
||||||
|
|
||||||
// Convert SQLite DATETIME if needed
|
|
||||||
const safeDate = date.includes('T') ? date : date.replace(' ', 'T');
|
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';
|
if (isNaN(parsed.getTime())) return 'n/a';
|
||||||
|
|
||||||
return parsed.toLocaleString(undefined, {
|
return parsed.toLocaleString(undefined, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
fetchTicket, fetchComments, fetchHistory,
|
fetchTicket, fetchComments, fetchHistory, createComment,
|
||||||
createComment,
|
formatDateTime, formatMinutes, getHistoryIcon, escapeHtml, linkify
|
||||||
formatDateTime, formatMinutes, getHistoryIcon
|
|
||||||
} from './ticket-shared.js';
|
} from './ticket-shared.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.body.classList.add('user-view');
|
document.body.classList.add('user-view');
|
||||||
|
|
||||||
const ticketId = new URLSearchParams(window.location.search).get('id');
|
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 ticketTitle = document.getElementById('ticket-title-text');
|
||||||
const ticketIdElem = document.getElementById('ticket-id-val');
|
const ticketIdElem = document.getElementById('ticket-id-val');
|
||||||
const ticketStatus = document.getElementById('ticket-status-badge');
|
const ticketStatus = document.getElementById('ticket-status-badge');
|
||||||
@@ -24,54 +22,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const historyContainer = document.getElementById('history-container');
|
const historyContainer = document.getElementById('history-container');
|
||||||
const commentForm = document.getElementById('comment-form');
|
const commentForm = document.getElementById('comment-form');
|
||||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
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 originalStatus = '';
|
||||||
let originalPriority = '';
|
let originalPriority = '';
|
||||||
@@ -89,16 +39,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
ticketUpdated.textContent = formatDateTime(ticket.updated_at);
|
ticketUpdated.textContent = formatDateTime(ticket.updated_at);
|
||||||
ticketPriority.textContent = ticket.priority;
|
ticketPriority.textContent = ticket.priority;
|
||||||
|
|
||||||
if (ticketCategory) {
|
if (ticketCategory) ticketCategory.textContent = ticket.category || '—';
|
||||||
ticketCategory.textContent = ticket.category || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
|
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
|
||||||
|
|
||||||
const totalTimeDisplay = document.getElementById('total-time');
|
const totalTimeDisplay = document.getElementById('total-time');
|
||||||
if (totalTimeDisplay) {
|
if (totalTimeDisplay) totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0);
|
||||||
totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
originalStatus = ticket.status;
|
originalStatus = ticket.status;
|
||||||
originalPriority = ticket.priority;
|
originalPriority = ticket.priority;
|
||||||
@@ -110,20 +56,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const role = comment.role ? comment.role.toLowerCase() : 'user';
|
const role = comment.role ? comment.role.toLowerCase() : 'user';
|
||||||
let avatarUrl = '/assets/user.jpg';
|
let avatarUrl = '/assets/user.jpg';
|
||||||
if (role === 'admin') avatarUrl = '/assets/admin.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 `
|
return `
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<img class="comment-avatar" src="${avatarUrl}" alt="${role.charAt(0).toUpperCase() + role.slice(1)} Avatar">
|
<img class="comment-avatar" src="${avatarUrl}" alt="${role.charAt(0).toUpperCase() + role.slice(1)} Avatar">
|
||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<div class="comment-header">
|
<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>
|
<span class="comment-date">${formatDateTime(comment.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">${linkedContent}</div>
|
<div class="comment-text">${linkify(comment.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -136,10 +78,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="history-item">
|
<div class="history-item">
|
||||||
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
|
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
|
||||||
<div class="history-content">
|
<div class="history-content">
|
||||||
<div class="history-action">${h.action}</div>
|
<div class="history-action">${escapeHtml(h.action)}</div>
|
||||||
<div class="history-details">${h.message || h.details || ''}</div>
|
<div class="history-details">${escapeHtml(h.message || h.details || '')}</div>
|
||||||
<div class="history-meta">
|
<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>
|
<span class="history-date">${formatDateTime(h.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,14 +96,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const content = document.getElementById('comment-content')?.value;
|
const content = document.getElementById('comment-content')?.value;
|
||||||
if (!content?.trim()) return alert('Please enter a comment');
|
if (!content?.trim()) return alert('Please enter a comment');
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
try {
|
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 = '';
|
document.getElementById('comment-content').value = '';
|
||||||
await loadComments();
|
await loadComments();
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
await sendAdminWebhook(`New comment added by ${author}: "${content}" on ticket #${ticketId}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create comment:', err);
|
console.error('Failed to create comment:', err);
|
||||||
alert('Failed to submit comment. Please try again.');
|
alert('Failed to submit comment. Please try again.');
|
||||||
@@ -173,19 +113,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const ticket = await fetchTicket(ticketId);
|
const ticket = await fetchTicket(ticketId);
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
// Update UI if status or priority changes server-side
|
|
||||||
if (ticket.status !== originalStatus) {
|
if (ticket.status !== originalStatus) {
|
||||||
originalStatus = ticket.status;
|
originalStatus = ticket.status;
|
||||||
ticketStatus.textContent = ticket.status;
|
ticketStatus.textContent = ticket.status;
|
||||||
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
|
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.priority !== originalPriority) {
|
if (ticket.priority !== originalPriority) {
|
||||||
originalPriority = ticket.priority;
|
originalPriority = ticket.priority;
|
||||||
ticketPriority.textContent = ticket.priority;
|
ticketPriority.textContent = ticket.priority;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check and update category if necessary
|
|
||||||
if (ticketCategory && ticket.category !== ticketCategory.textContent) {
|
if (ticketCategory && ticket.category !== ticketCategory.textContent) {
|
||||||
ticketCategory.textContent = ticket.category || '—';
|
ticketCategory.textContent = ticket.category || '—';
|
||||||
}
|
}
|
||||||
@@ -199,12 +135,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadWebhookConfig().then(() => {
|
|
||||||
loadTicketDetails();
|
loadTicketDetails();
|
||||||
loadComments();
|
loadComments();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
});
|
|
||||||
|
|
||||||
// Periodic updates
|
// Poll for changes every 30s
|
||||||
setInterval(checkForChanges, 30000);
|
setInterval(checkForChanges, 30000);
|
||||||
});
|
});
|
||||||
@@ -7,10 +7,7 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="/assets/favicon_ticket.ico">
|
<link rel="icon" type="image/x-icon" href="/assets/favicon_ticket.ico">
|
||||||
<link rel="stylesheet" href="ticket-view.css" />
|
<link rel="stylesheet" href="ticket-view.css" />
|
||||||
<link rel="stylesheet" href="admin-view.css" />
|
<link rel="stylesheet" href="admin-view.css" />
|
||||||
<link
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -25,7 +22,6 @@
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div class="ticket-details-layout">
|
<div class="ticket-details-layout">
|
||||||
<!-- Ticket Info Section -->
|
|
||||||
<section class="ticket-details">
|
<section class="ticket-details">
|
||||||
<div class="ticket-header">
|
<div class="ticket-header">
|
||||||
<div id="ticket-title-view">
|
<div id="ticket-title-view">
|
||||||
@@ -42,28 +38,23 @@
|
|||||||
<h4>Requester</h4>
|
<h4>Requester</h4>
|
||||||
<p id="ticket-requester">-</p>
|
<p id="ticket-requester">-</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item description">
|
<div class="info-item description">
|
||||||
<h3>Description</h3>
|
<h3>Description</h3>
|
||||||
<p id="ticket-description-text">Loading...</p>
|
<p id="ticket-description-text">Loading...</p>
|
||||||
<textarea id="ticket-description-input" class="form-control" style="display: none"></textarea>
|
<textarea id="ticket-description-input" class="form-control" style="display: none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item created">
|
<div class="info-item created">
|
||||||
<h4>Created</h4>
|
<h4>Created</h4>
|
||||||
<p id="ticket-created">-</p>
|
<p id="ticket-created">-</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item updated">
|
<div class="info-item updated">
|
||||||
<h4>Last Updated</h4>
|
<h4>Last Updated</h4>
|
||||||
<p id="ticket-updated">-</p>
|
<p id="ticket-updated">-</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<h4>Time</h4>
|
<h4>Time</h4>
|
||||||
<p><span id="total-time">0</span></p>
|
<p><span id="total-time">0</span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item status">
|
<div class="info-item status">
|
||||||
<h4><label for="status-select">Status</label></h4>
|
<h4><label for="status-select">Status</label></h4>
|
||||||
<select id="status-select" class="form-control">
|
<select id="status-select" class="form-control">
|
||||||
@@ -73,7 +64,6 @@
|
|||||||
<option value="Closed">Closed</option>
|
<option value="Closed">Closed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item priority">
|
<div class="info-item priority">
|
||||||
<h4><label for="priority-text">Priority</label></h4>
|
<h4><label for="priority-text">Priority</label></h4>
|
||||||
<select id="priority-text" class="form-control">
|
<select id="priority-text" class="form-control">
|
||||||
@@ -83,16 +73,10 @@
|
|||||||
<option value="Urgent">Urgent</option>
|
<option value="Urgent">Urgent</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item category">
|
<div class="info-item category">
|
||||||
<h4><label for="category-select">Category</label></h4>
|
<h4><label for="category-select">Category</label></h4>
|
||||||
<select id="category-select" class="form-control">
|
<select id="category-select" class="form-control">
|
||||||
<option value="HELP">HELP</option>
|
<!-- Populated dynamically -->
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +91,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Ticket Activity Section -->
|
|
||||||
<section class="ticket-activity">
|
<section class="ticket-activity">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" data-tab="comments">Comments</button>
|
<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>
|
<textarea id="comment-content" rows="2" placeholder="Add a comment..." required></textarea>
|
||||||
<button class="btn-primary" type="submit">Post Comment</button>
|
<button class="btn-primary" type="submit">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="time-booking-form" class="comment-form compact-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 />
|
<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>
|
<textarea id="time-comment" name="time-comment" rows="2" placeholder="Optional comment"></textarea>
|
||||||
<button class="btn-primary" type="submit">Add Time</button>
|
<button class="btn-primary" type="submit">Add Time</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comment Output -->
|
|
||||||
<div id="comments-container"></div>
|
<div id="comments-container"></div>
|
||||||
|
|
||||||
<!-- Time Logs -->
|
|
||||||
<section id="time-logs" class="time-logs"></section>
|
<section id="time-logs" class="time-logs"></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,10 +122,11 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>Ticket System by Honey v1.1 © 2025</p>
|
<p class="app-footer-text"></p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="ticket-admin.js"></script>
|
<script type="module" src="ticket-admin.js"></script>
|
||||||
|
<script src="../version.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -110,10 +110,11 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>Ticket System by Honey v1.1 © 2025</p>
|
<p class="app-footer-text"></p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="ticket-user.js"></script>
|
<script type="module" src="ticket-user.js"></script>
|
||||||
|
<script src="../version.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+79
-185
@@ -1,121 +1,82 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const isAdminView = document.body.classList.contains('admin-view');
|
|
||||||
const createForm = document.getElementById('create-form');
|
const createForm = document.getElementById('create-form');
|
||||||
const ticketsContainer = document.getElementById('tickets-container');
|
|
||||||
|
|
||||||
const statusFilter = document.getElementById('status-filter');
|
const statusFilter = document.getElementById('status-filter');
|
||||||
const categoryFilter = document.getElementById('category-filter');
|
const categoryFilter = document.getElementById('category-filter');
|
||||||
const searchInput = document.getElementById('search-input');
|
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 openModalBtn = document.getElementById('open-modal-btn');
|
||||||
const modal = document.getElementById('create-ticket-modal');
|
const modal = document.getElementById('create-ticket-modal');
|
||||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||||
|
|
||||||
// --- PFP Modal Elements ---
|
|
||||||
const updatePfpBtn = document.getElementById('update-pfp-btn');
|
const updatePfpBtn = document.getElementById('update-pfp-btn');
|
||||||
const pfpModal = document.getElementById('update-pfp-modal');
|
const pfpModal = document.getElementById('update-pfp-modal');
|
||||||
const closePfpModalBtn = document.getElementById('close-pfp-modal-btn');
|
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
const fileInput = document.getElementById('pfp-file-input');
|
const fileInput = document.getElementById('pfp-file-input');
|
||||||
const uploadPfpBtn = document.getElementById('upload-pfp-btn');
|
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 selectedPfpFile = null;
|
||||||
let allTickets = [];
|
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) {
|
function formatStatusClass(status) {
|
||||||
if (!status) return 'Unknown';
|
if (!status) return 'Unknown';
|
||||||
return status
|
return status.split(' ').map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join('-');
|
||||||
.split(' ')
|
|
||||||
.map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
|
|
||||||
.join('-');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Webhook Functions ---
|
// --- Dynamic Categories ---
|
||||||
async function loadWebhookConfig() {
|
async function loadCategories() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/webhooks');
|
const res = await fetch('/api/categories');
|
||||||
if (response.ok) {
|
const categories = await res.json();
|
||||||
webhookConfig = await response.json();
|
populateCategorySelects(categories);
|
||||||
console.log('Webhook configuration loaded:', webhookConfig.configured);
|
} catch (err) {
|
||||||
} else {
|
console.error('Failed to load categories:', err);
|
||||||
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 } };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWebhookNotification(webhookUrl, message) {
|
function populateCategorySelects(categories) {
|
||||||
if (!webhookUrl) {
|
// Modal create form select
|
||||||
console.log('No webhook URL provided, skipping notification');
|
const createSelect = document.getElementById('category');
|
||||||
return;
|
if (createSelect) {
|
||||||
|
createSelect.innerHTML = categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
|
||||||
|
}
|
||||||
|
// 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('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// --- Accordion ---
|
||||||
const response = await fetch(webhookUrl, {
|
if (closedToggle) {
|
||||||
method: 'POST',
|
closedToggle.addEventListener('click', () => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
accordionOpen = !accordionOpen;
|
||||||
body: JSON.stringify(message)
|
closedAccordion.style.display = accordionOpen ? 'block' : 'none';
|
||||||
|
closedToggle.classList.toggle('open', accordionOpen);
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendAdminWebhook(ticket) {
|
// --- Ticket Modal ---
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Ticket Modal Open/Close ---
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
document.documentElement.style.overflow = 'hidden';
|
document.documentElement.style.overflow = 'hidden';
|
||||||
if (scrollBarWidth > 0) {
|
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||||
document.body.style.paddingRight = scrollBarWidth + 'px';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
@@ -126,31 +87,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.body.style.paddingRight = '';
|
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 (openModalBtn) openModalBtn.addEventListener('click', openModal);
|
||||||
if (closeModalBtn) closeModalBtn.addEventListener('click', closeModal);
|
if (closeModalBtn) closeModalBtn.addEventListener('click', closeModal);
|
||||||
if (modal) {
|
if (modal) {
|
||||||
window.addEventListener('click', outsideClickListener);
|
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||||
window.addEventListener('keydown', escKeyListener);
|
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'flex') closeModal(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PFP Modal Open/Close ---
|
// --- PFP Modal ---
|
||||||
function openPfpModal() {
|
function openPfpModal() {
|
||||||
if (pfpModal) {
|
if (pfpModal) {
|
||||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
pfpModal.style.display = 'flex';
|
pfpModal.style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
document.documentElement.style.overflow = 'hidden';
|
document.documentElement.style.overflow = 'hidden';
|
||||||
if (scrollBarWidth > 0) {
|
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
|
||||||
document.body.style.paddingRight = scrollBarWidth + 'px';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function closePfpModal() {
|
function closePfpModal() {
|
||||||
@@ -162,26 +113,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal);
|
if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal);
|
||||||
if (closePfpModalBtn) closePfpModalBtn.addEventListener('click', closePfpModal);
|
|
||||||
if (pfpModal) {
|
if (pfpModal) {
|
||||||
window.addEventListener('click', (e) => {
|
window.addEventListener('click', (e) => { if (e.target === pfpModal) closePfpModal(); });
|
||||||
if (e.target === pfpModal) closePfpModal();
|
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal(); });
|
||||||
});
|
|
||||||
window.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drag & Drop Logic ---
|
// --- Drag & Drop ---
|
||||||
if (dropZone) {
|
if (dropZone) {
|
||||||
dropZone.addEventListener('click', () => fileInput.click());
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
dropZone.addEventListener('dragover', (e) => {
|
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.borderColor = '#10b981'; });
|
||||||
e.preventDefault();
|
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#4b5563'; });
|
||||||
dropZone.style.borderColor = '#10b981';
|
|
||||||
});
|
|
||||||
dropZone.addEventListener('dragleave', () => {
|
|
||||||
dropZone.style.borderColor = '#4b5563';
|
|
||||||
});
|
|
||||||
dropZone.addEventListener('drop', (e) => {
|
dropZone.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.style.borderColor = '#4b5563';
|
dropZone.style.borderColor = '#4b5563';
|
||||||
@@ -192,7 +133,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener('change', (e) => {
|
fileInput.addEventListener('change', (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
@@ -202,21 +142,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload PFP ---
|
|
||||||
if (uploadPfpBtn) {
|
if (uploadPfpBtn) {
|
||||||
uploadPfpBtn.addEventListener('click', async () => {
|
uploadPfpBtn.addEventListener('click', async () => {
|
||||||
if (!selectedPfpFile) {
|
if (!selectedPfpFile) return alert('Please select an image first.');
|
||||||
alert('Please select an image first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('pfp', selectedPfpFile, 'user.jpg');
|
formData.append('pfp', selectedPfpFile, 'user.jpg');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload-pfp', {
|
const res = await fetch('/api/upload-pfp', { method: 'POST', body: formData });
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Upload failed');
|
if (!res.ok) throw new Error('Upload failed');
|
||||||
alert('Profile picture updated!');
|
alert('Profile picture updated!');
|
||||||
closePfpModal();
|
closePfpModal();
|
||||||
@@ -239,23 +171,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createTicket(ticket) {
|
async function createTicket(ticket) {
|
||||||
try {
|
|
||||||
const res = await fetch('/api/tickets', {
|
const res = await fetch('/api/tickets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(ticket)
|
body: JSON.stringify(ticket),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create ticket');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating ticket:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Date Formatting ---
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
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 category = categoryFilter?.value || 'all';
|
||||||
const search = searchInput?.value.trim().toLowerCase() || '';
|
const search = searchInput?.value.trim().toLowerCase() || '';
|
||||||
|
|
||||||
const openTickets = allTickets.filter(ticket =>
|
const filtered = allTickets.filter(ticket => {
|
||||||
(ticket.status || '').toLowerCase() !== 'closed'
|
|
||||||
);
|
|
||||||
const closedTickets = allTickets.filter(ticket =>
|
|
||||||
(ticket.status || '').toLowerCase() === 'closed'
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredOpen = openTickets.filter(ticket => {
|
|
||||||
const ticketStatus = (ticket.status || '').toLowerCase();
|
const ticketStatus = (ticket.status || '').toLowerCase();
|
||||||
const ticketCategory = (ticket.category || '').toLowerCase();
|
const ticketCategory = (ticket.category || '').toLowerCase();
|
||||||
const ticketTitle = (ticket.title || '').toLowerCase();
|
const ticketTitle = (ticket.title || '').toLowerCase();
|
||||||
const ticketDescription = (ticket.description || '').toLowerCase();
|
const ticketDescription = (ticket.description || '').toLowerCase();
|
||||||
|
const isClosed = ticketStatus === 'closed';
|
||||||
const matchesStatus = status === 'all' || ticketStatus === status.toLowerCase();
|
const matchesStatus = status === 'all' || ticketStatus === status.toLowerCase();
|
||||||
const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase();
|
const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase();
|
||||||
const matchesSearch =
|
const matchesSearch = ticketTitle.includes(search) || ticketDescription.includes(search);
|
||||||
ticketTitle.includes(search) ||
|
return (isClosed || matchesStatus) && matchesCategory && matchesSearch;
|
||||||
ticketDescription.includes(search);
|
|
||||||
return matchesStatus && matchesCategory && matchesSearch;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredClosed = closedTickets.filter(ticket => {
|
renderTickets(filtered);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTickets(tickets) {
|
function renderTickets(tickets) {
|
||||||
@@ -304,6 +212,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const closedContainer = document.getElementById('closed-tickets-container');
|
const closedContainer = document.getElementById('closed-tickets-container');
|
||||||
openContainer.innerHTML = '';
|
openContainer.innerHTML = '';
|
||||||
closedContainer.innerHTML = '';
|
closedContainer.innerHTML = '';
|
||||||
|
|
||||||
|
let closedTicketCount = 0;
|
||||||
|
|
||||||
if (!tickets.length) {
|
if (!tickets.length) {
|
||||||
openContainer.innerHTML = `
|
openContainer.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -311,29 +222,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<h3>No tickets found</h3>
|
<h3>No tickets found</h3>
|
||||||
<p>Try changing your filters or create a new ticket</p>
|
<p>Try changing your filters or create a new ticket</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
closedContainer.innerHTML = '';
|
if (closedCount) closedCount.textContent = '0';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tickets.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
tickets.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
tickets.forEach(ticket => {
|
tickets.forEach(ticket => {
|
||||||
const statusSafe = formatStatusClass(ticket.status);
|
const statusSafe = formatStatusClass(ticket.status);
|
||||||
const prioritySafe = (ticket.priority || 'None');
|
const prioritySafe = ticket.priority || 'None';
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `ticket status-${statusSafe.replace(/\s+/g, '-')}`;
|
el.className = `ticket status-${statusSafe.replace(/\s+/g, '-')}`;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<h3>${ticket.title} <span class="ticket-id">#${ticket.id}</span> <small class="total-time"> Time Cin: (${ticket.total_time} min)</small> </h3>
|
<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">${ticket.description}</p>
|
<p class="description">${escapeHtml(ticket.description)}</p>
|
||||||
<div class="meta">
|
<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>
|
<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>
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<div class="status status-${statusSafe.replace(/\s+/g, '-')}">
|
<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>
|
||||||
<div class="priority priority-${prioritySafe.replace(/\s+/g, '-')}">
|
<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>
|
</div>
|
||||||
<div class="ticket-actions">
|
<div class="ticket-actions">
|
||||||
@@ -344,14 +257,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
`;
|
`;
|
||||||
if ((ticket.status || '').toLowerCase() === 'closed') {
|
if ((ticket.status || '').toLowerCase() === 'closed') {
|
||||||
closedContainer.appendChild(el);
|
closedContainer.appendChild(el);
|
||||||
|
closedTicketCount++;
|
||||||
} else {
|
} else {
|
||||||
openContainer.appendChild(el);
|
openContainer.appendChild(el);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (closedCount) closedCount.textContent = closedTicketCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
let isSubmittingTicket = false; // prevent double submit
|
let isSubmittingTicket = false;
|
||||||
|
|
||||||
async function handleCreateTicket(e) {
|
async function handleCreateTicket(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -372,28 +288,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const submitBtn = createForm.querySelector('.btn-primary');
|
const submitBtn = createForm.querySelector('.btn-primary');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
const ticket = {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
priority,
|
|
||||||
category,
|
|
||||||
requester: 'Laila',
|
|
||||||
status: 'Open'
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdTicket = await createTicket(ticket);
|
await createTicket({ title, description, priority, category, requester: 'Laila', status: 'Open' });
|
||||||
createForm.reset();
|
createForm.reset();
|
||||||
closeModal();
|
closeModal();
|
||||||
await refreshTickets();
|
await refreshTickets();
|
||||||
await sendAdminWebhook({
|
|
||||||
id: createdTicket.id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
priority,
|
|
||||||
category,
|
|
||||||
status: 'Open'
|
|
||||||
});
|
|
||||||
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-check"></i> Created!';
|
submitBtn.innerHTML = '<i class="fas fa-check"></i> Created!';
|
||||||
submitBtn.style.backgroundColor = '#28a745';
|
submitBtn.style.backgroundColor = '#28a745';
|
||||||
@@ -416,15 +315,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
async function initialize() {
|
|
||||||
await loadWebhookConfig();
|
|
||||||
await refreshTickets();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createForm) createForm.addEventListener('submit', handleCreateTicket);
|
if (createForm) createForm.addEventListener('submit', handleCreateTicket);
|
||||||
if (statusFilter) statusFilter.addEventListener('change', renderFilteredTickets);
|
if (statusFilter) statusFilter.addEventListener('change', renderFilteredTickets);
|
||||||
if (categoryFilter) categoryFilter.addEventListener('change', renderFilteredTickets);
|
if (categoryFilter) categoryFilter.addEventListener('change', renderFilteredTickets);
|
||||||
if (searchInput) searchInput.addEventListener('input', renderFilteredTickets);
|
if (searchInput) searchInput.addEventListener('input', renderFilteredTickets);
|
||||||
|
|
||||||
initialize();
|
loadCategories().then(() => refreshTickets());
|
||||||
});
|
});
|
||||||
@@ -655,3 +655,53 @@ a:focus {
|
|||||||
font-size: 1.2rem;
|
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(() => {});
|
||||||
@@ -5,6 +5,8 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import multer from 'multer';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -15,11 +17,13 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
|
||||||
// Load webhook URLs from environment
|
// Load webhook URLs from environment (kept server-side only!)
|
||||||
const ADMIN_WEBHOOK_URL = process.env.ADMIN_WEBHOOK_URL;
|
const ADMIN_WEBHOOK_URL = process.env.ADMIN_WEBHOOK_URL;
|
||||||
const USER_WEBHOOK_URL = process.env.USER_WEBHOOK_URL;
|
const USER_WEBHOOK_URL = process.env.USER_WEBHOOK_URL;
|
||||||
|
|
||||||
const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss');
|
// Read version from package.json
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||||
|
const APP_VERSION = pkg.version || '0.0.0';
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -27,27 +31,75 @@ app.use(express.urlencoded({ extended: false }));
|
|||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
app.use('/assets', express.static(path.join(__dirname, 'assets')));
|
app.use('/assets', express.static(path.join(__dirname, 'assets')));
|
||||||
|
|
||||||
const dbPath = path.resolve(__dirname, 'tickets.db');
|
// --- Profile Picture Upload (supports user + admin) ---
|
||||||
const db = new sqlite3.Database(dbPath, err => {
|
function createPfpUpload(filename) {
|
||||||
if (err) {
|
const storage = multer.diskStorage({
|
||||||
console.error('Failed to connect to DB:', err.message);
|
destination: path.join(__dirname, 'assets'),
|
||||||
} else {
|
filename: (_req, _file, cb) => cb(null, filename),
|
||||||
console.log('Connected to SQLite DB.');
|
});
|
||||||
|
return multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||||
|
else cb(new Error('Only image files are allowed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Categories Config ---
|
||||||
|
const categoriesPath = path.resolve(__dirname, 'categories.json');
|
||||||
|
function loadCategories() {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(categoriesPath, 'utf-8');
|
||||||
|
return JSON.parse(data).categories || [];
|
||||||
|
} catch {
|
||||||
|
return ['HELP', 'IT-Support', 'Suggestion...', 'Other'];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Database ---
|
||||||
|
const dbPath = path.resolve(__dirname, 'data', 'tickets.db');
|
||||||
|
const db = new sqlite3.Database(dbPath, err => {
|
||||||
|
if (err) console.error('Failed to connect to DB:', err.message);
|
||||||
|
else console.log('Connected to SQLite DB.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrent reads
|
||||||
|
db.run('PRAGMA journal_mode=WAL');
|
||||||
|
|
||||||
|
// Promisified DB helpers
|
||||||
|
function dbAll(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function dbGet(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => (err ? reject(err) : resolve(row)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function dbRun(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve({ lastID: this.lastID, changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create tables
|
// Create tables
|
||||||
db.serialize(() => {
|
db.serialize(() => {
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS tickets (
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
title TEXT,
|
title TEXT NOT NULL,
|
||||||
requester TEXT,
|
requester TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
status TEXT,
|
status TEXT DEFAULT 'Open',
|
||||||
priority TEXT,
|
priority TEXT DEFAULT 'Medium',
|
||||||
category TEXT,
|
category TEXT,
|
||||||
total_time INTEGER DEFAULT 0
|
total_time INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
@@ -61,7 +113,7 @@ db.serialize(() => {
|
|||||||
comment TEXT,
|
comment TEXT,
|
||||||
author TEXT NOT NULL,
|
author TEXT NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(ticket_id) REFERENCES tickets(id)
|
FOREIGN KEY(ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -74,230 +126,404 @@ db.serialize(() => {
|
|||||||
author TEXT,
|
author TEXT,
|
||||||
role TEXT,
|
role TEXT,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(ticket_id) REFERENCES tickets(id)
|
FOREIGN KEY(ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
db.run(`CREATE INDEX IF NOT EXISTS idx_time_logs_ticket ON time_logs(ticket_id)`);
|
db.run('CREATE INDEX IF NOT EXISTS idx_time_logs_ticket ON time_logs(ticket_id)');
|
||||||
db.run(`CREATE INDEX IF NOT EXISTS idx_history_ticket ON history(ticket_id)`);
|
db.run('CREATE INDEX IF NOT EXISTS idx_history_ticket ON history(ticket_id)');
|
||||||
|
db.run('PRAGMA foreign_keys = ON');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log history
|
// --- Helpers ---
|
||||||
|
function utcNow() {
|
||||||
|
return moment().utc().format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
function logHistory(ticketId, action, message, author = 'system', role = 'user') {
|
function logHistory(ticketId, action, message, author = 'system', role = 'user') {
|
||||||
const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
console.log(`Logging history for ticket: ${ticketId}, timestamp: ${timestamp}`); // Debugging line
|
|
||||||
db.run(
|
db.run(
|
||||||
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[ticketId, action, message, author, role, timestamp]
|
[ticketId, action, message, author, role, utcNow()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET webhook configuration for frontend
|
// --- Server-side webhook sending (URLs never exposed to frontend) ---
|
||||||
app.get('/api/webhooks', (req, res) => {
|
async function sendWebhook(url, payload) {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Webhook send failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord embed colors
|
||||||
|
const COLORS = {
|
||||||
|
green: 0x00ff00,
|
||||||
|
orange: 0xffa500,
|
||||||
|
blue: 0x4a90e2,
|
||||||
|
red: 0xd0021b,
|
||||||
|
purple: 0x9b59b6,
|
||||||
|
yellow: 0xf5a623,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Admin Notifications ---
|
||||||
|
function notifyAdmin(ticket, event = 'created') {
|
||||||
|
if (!ADMIN_WEBHOOK_URL) return;
|
||||||
|
|
||||||
|
const embeds = {
|
||||||
|
created: {
|
||||||
|
title: '🎫 New Ticket Created',
|
||||||
|
color: COLORS.green,
|
||||||
|
description: `**Ticket #${ticket.id}**: ${ticket.title}`,
|
||||||
|
fields: [
|
||||||
|
{ name: '👤 Requester', value: ticket.requester || 'Unknown' },
|
||||||
|
{ name: '📊 Status', value: ticket.status || 'Open', inline: true },
|
||||||
|
{ name: '⚡ Priority', value: ticket.priority || 'None', inline: true },
|
||||||
|
{ name: '📂 Category', value: ticket.category || 'None', inline: true },
|
||||||
|
...(ticket.description ? [{
|
||||||
|
name: '📝 Description',
|
||||||
|
value: ticket.description.length > 200 ? ticket.description.substring(0, 200) + '...' : ticket.description,
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
title: '💬 New User Comment',
|
||||||
|
color: COLORS.blue,
|
||||||
|
description: `**Ticket #${ticket.id}**: ${ticket.title}`,
|
||||||
|
fields: [
|
||||||
|
{ name: '👤 Author', value: ticket.commentAuthor || 'Unknown', inline: true },
|
||||||
|
{ name: '📊 Status', value: ticket.status || '-', inline: true },
|
||||||
|
{ name: '💬 Comment', value: ticket.commentContent?.length > 200 ? ticket.commentContent.substring(0, 200) + '...' : (ticket.commentContent || '-') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const embed = embeds[event] || embeds.created;
|
||||||
|
embed.timestamp = new Date().toISOString();
|
||||||
|
embed.footer = { text: 'Ticket System' };
|
||||||
|
|
||||||
|
sendWebhook(ADMIN_WEBHOOK_URL, { embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Notifications ---
|
||||||
|
function notifyUser(type, data = {}) {
|
||||||
|
if (!USER_WEBHOOK_URL) return;
|
||||||
|
|
||||||
|
const embeds = {
|
||||||
|
statusChanged: {
|
||||||
|
title: '📊 Status Changed',
|
||||||
|
color: data.newStatus === 'Closed' ? COLORS.red : COLORS.orange,
|
||||||
|
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Previous', value: data.oldStatus || '-', inline: true },
|
||||||
|
{ name: 'New', value: data.newStatus || '-', inline: true },
|
||||||
|
{ name: '👤 Changed by', value: data.actor || 'Admin', inline: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
priorityChanged: {
|
||||||
|
title: '⚡ Priority Changed',
|
||||||
|
color: COLORS.yellow,
|
||||||
|
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Previous', value: data.oldPriority || '-', inline: true },
|
||||||
|
{ name: 'New', value: data.newPriority || '-', inline: true },
|
||||||
|
{ name: '👤 Changed by', value: data.actor || 'Admin', inline: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
title: '💬 New Admin Comment',
|
||||||
|
color: COLORS.purple,
|
||||||
|
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
|
||||||
|
fields: [
|
||||||
|
{ name: '👤 Author', value: data.author || 'Admin', inline: true },
|
||||||
|
{ name: '💬 Comment', value: data.content?.length > 200 ? data.content.substring(0, 200) + '...' : (data.content || '-') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timeLogged: {
|
||||||
|
title: '⏱ Time Booked',
|
||||||
|
color: COLORS.blue,
|
||||||
|
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
|
||||||
|
fields: [
|
||||||
|
{ name: '⏱ Duration', value: `${data.minutes || 0} min`, inline: true },
|
||||||
|
{ name: '👤 By', value: data.author || 'Admin', inline: true },
|
||||||
|
...(data.comment ? [{ name: '📝 Note', value: data.comment }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const embed = embeds[type];
|
||||||
|
if (!embed) return;
|
||||||
|
|
||||||
|
embed.timestamp = new Date().toISOString();
|
||||||
|
embed.footer = { text: 'Ticket System' };
|
||||||
|
|
||||||
|
sendWebhook(USER_WEBHOOK_URL, { embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input validation middleware ---
|
||||||
|
function validateTicketBody(req, res, next) {
|
||||||
|
const { title, requester } = req.body;
|
||||||
|
if (!title?.trim()) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
if (!requester?.trim()) return res.status(400).json({ error: 'Requester is required' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API ROUTES ====================
|
||||||
|
|
||||||
|
// GET webhook status (only tells frontend if configured, NOT the URLs)
|
||||||
|
app.get('/api/webhooks', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
admin: ADMIN_WEBHOOK_URL || null,
|
|
||||||
user: USER_WEBHOOK_URL || null,
|
|
||||||
configured: {
|
configured: {
|
||||||
admin: !!ADMIN_WEBHOOK_URL,
|
admin: !!ADMIN_WEBHOOK_URL,
|
||||||
user: !!USER_WEBHOOK_URL
|
user: !!USER_WEBHOOK_URL,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload profile picture (user)
|
||||||
|
app.post('/api/upload-pfp', createPfpUpload('user.jpg').single('pfp'), (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
res.json({ message: 'Profile picture updated', filename: req.file.filename });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload profile picture (admin)
|
||||||
|
app.post('/api/upload-admin-pfp', createPfpUpload('admin.jpg').single('pfp'), (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
res.json({ message: 'Admin profile picture updated', filename: req.file.filename });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET categories
|
||||||
|
app.get('/api/categories', (_req, res) => {
|
||||||
|
res.json(loadCategories());
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET app version
|
||||||
|
app.get('/api/version', (_req, res) => {
|
||||||
|
res.json({ version: APP_VERSION });
|
||||||
|
});
|
||||||
|
|
||||||
// GET all tickets
|
// GET all tickets
|
||||||
app.get('/api/tickets', (req, res) => {
|
app.get('/api/tickets', async (_req, res) => {
|
||||||
db.all(
|
try {
|
||||||
`SELECT id, title, requester, description, status, priority, category, total_time,
|
const rows = await dbAll(`
|
||||||
|
SELECT id, title, requester, description, status, priority, category, total_time,
|
||||||
strftime('%Y-%m-%dT%H:%M:%S', created_at) AS created_at,
|
strftime('%Y-%m-%dT%H:%M:%S', created_at) AS created_at,
|
||||||
strftime('%Y-%m-%dT%H:%M:%S', updated_at) AS updated_at
|
strftime('%Y-%m-%dT%H:%M:%S', updated_at) AS updated_at
|
||||||
FROM tickets
|
FROM tickets ORDER BY created_at DESC
|
||||||
ORDER BY created_at DESC`,
|
`);
|
||||||
(err, rows) => {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET single ticket
|
// GET single ticket
|
||||||
app.get('/api/tickets/:id', (req, res) => {
|
app.get('/api/tickets/:id', async (req, res) => {
|
||||||
db.get('SELECT * FROM tickets WHERE id = ?', [req.params.id], (err, row) => {
|
try {
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
const row = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
if (!row) return res.status(404).json({ error: 'Ticket not found' });
|
if (!row) return res.status(404).json({ error: 'Ticket not found' });
|
||||||
res.json(row);
|
res.json(row);
|
||||||
});
|
} catch (err) {
|
||||||
});
|
res.status(500).json({ error: err.message });
|
||||||
|
|
||||||
// GET ticket history (ISO formatted date)
|
|
||||||
app.get('/api/tickets/:id/history', (req, res) => {
|
|
||||||
db.all(
|
|
||||||
`SELECT id, ticket_id, action, message, author, role,
|
|
||||||
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
|
|
||||||
FROM history
|
|
||||||
WHERE ticket_id = ?
|
|
||||||
ORDER BY timestamp DESC`,
|
|
||||||
[req.params.id],
|
|
||||||
(err, rows) => {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
res.json(rows);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST new ticket
|
// POST new ticket
|
||||||
app.post('/api/tickets', (req, res) => {
|
app.post('/api/tickets', validateTicketBody, async (req, res) => {
|
||||||
const { title, requester, description, status, priority, category } = req.body;
|
const { title, requester, description, status, priority, category } = req.body;
|
||||||
const statusValue = status && status.trim() ? status : 'open';
|
const ts = utcNow();
|
||||||
|
try {
|
||||||
// Get current Berlin time and convert it to UTC
|
const result = await dbRun(
|
||||||
const berlinTime = moment.tz('Europe/Berlin');
|
`INSERT INTO tickets (title, requester, description, status, priority, category, created_at, updated_at)
|
||||||
const utcTime = berlinTime.clone().utc().format('YYYY-MM-DD HH:mm:ss'); // Convert Berlin time to UTC
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[title.trim(), requester.trim(), description?.trim() || '', status || 'Open', priority || 'Medium', category || '', ts, ts]
|
||||||
db.run(
|
|
||||||
'INSERT INTO tickets (title, requester, description, status, priority, category, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[title, requester, description, statusValue, priority, category, utcTime, utcTime],
|
|
||||||
function (err) {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
logHistory(this.lastID, 'Ticket Created', `Ticket "${title}" created`, requester);
|
|
||||||
res.status(201).json({ id: this.lastID });
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
const ticket = { id: result.lastID, title, requester, description, status: status || 'Open', priority: priority || 'Medium', category };
|
||||||
|
logHistory(result.lastID, 'Ticket Created', `Ticket "${title}" created`, requester);
|
||||||
|
notifyAdmin(ticket, 'created');
|
||||||
|
res.status(201).json({ id: result.lastID });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT update ticket
|
// PUT update ticket
|
||||||
app.put('/api/tickets/:id', (req, res) => {
|
app.put('/api/tickets/:id', async (req, res) => {
|
||||||
const { title, requester, description, status, priority, category, author } = req.body;
|
const { title, requester, description, status, priority, category, author } = req.body;
|
||||||
|
const ts = utcNow();
|
||||||
|
try {
|
||||||
|
// Fetch old ticket for change detection
|
||||||
|
const old = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!old) return res.status(404).json({ error: 'Ticket not found' });
|
||||||
|
|
||||||
// Get current Berlin time and convert it to UTC
|
await dbRun(
|
||||||
const berlinTime = moment.tz('Europe/Berlin');
|
`UPDATE tickets SET title=?, requester=?, description=?, status=?, priority=?, category=?, updated_at=? WHERE id=?`,
|
||||||
const utcTime = berlinTime.clone().utc().format('YYYY-MM-DD HH:mm:ss'); // Convert Berlin time to UTC
|
[title, requester || old.requester, description, status, priority, category, ts, req.params.id]
|
||||||
|
|
||||||
db.run(
|
|
||||||
`UPDATE tickets SET
|
|
||||||
title = ?,
|
|
||||||
requester = ?,
|
|
||||||
description = ?,
|
|
||||||
status = ?,
|
|
||||||
priority = ?,
|
|
||||||
category = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
[title, requester, description, status, priority, category, utcTime, req.params.id],
|
|
||||||
function (err) {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
if (this.changes === 0) return res.status(404).json({ error: 'Ticket not found' });
|
|
||||||
logHistory(req.params.id, 'Ticket Updated', 'Ticket details updated', author || 'system');
|
|
||||||
res.json({ message: 'Ticket updated' });
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const actor = author || 'system';
|
||||||
|
|
||||||
|
// Log specific changes to history
|
||||||
|
if (status !== old.status) {
|
||||||
|
logHistory(req.params.id, 'Status Changed', `Status changed from "${old.status}" to "${status}"`, actor);
|
||||||
|
notifyUser('statusChanged', { ticketId: req.params.id, title: old.title, oldStatus: old.status, newStatus: status, actor });
|
||||||
|
}
|
||||||
|
if (priority !== old.priority) {
|
||||||
|
logHistory(req.params.id, 'Priority Changed', `Priority changed from "${old.priority}" to "${priority}"`, actor);
|
||||||
|
notifyUser('priorityChanged', { ticketId: req.params.id, title: old.title, oldPriority: old.priority, newPriority: priority, actor });
|
||||||
|
}
|
||||||
|
if (category !== (old.category || '')) {
|
||||||
|
logHistory(req.params.id, 'Category Changed', `Category changed from "${old.category || 'none'}" to "${category}"`, actor);
|
||||||
|
}
|
||||||
|
if (title !== old.title || description !== old.description) {
|
||||||
|
logHistory(req.params.id, 'Ticket Updated', 'Ticket details updated', actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Ticket updated' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE ticket
|
// DELETE ticket
|
||||||
app.delete('/api/tickets/:id', (req, res) => {
|
app.delete('/api/tickets/:id', async (req, res) => {
|
||||||
db.run('DELETE FROM tickets WHERE id = ?', [req.params.id], function (err) {
|
try {
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
const result = await dbRun('DELETE FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
if (this.changes === 0) return res.status(404).json({ error: 'Ticket not found' });
|
if (result.changes === 0) return res.status(404).json({ error: 'Ticket not found' });
|
||||||
logHistory(req.params.id, 'Ticket Deleted', 'Ticket deleted');
|
|
||||||
res.json({ message: 'Ticket deleted' });
|
res.json({ message: 'Ticket deleted' });
|
||||||
});
|
} catch (err) {
|
||||||
});
|
res.status(500).json({ error: err.message });
|
||||||
|
|
||||||
// POST comment with role
|
|
||||||
app.post('/api/tickets/:id/comments', (req, res) => {
|
|
||||||
const { author, role, content } = req.body;
|
|
||||||
if (!author || !content) return res.status(400).json({ error: 'Author and content required' });
|
|
||||||
|
|
||||||
const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss'); // Explicit UTC timestamp
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, "Comment", ?, ?, ?, ?)',
|
|
||||||
[req.params.id, content, author, role || 'user', timestamp],
|
|
||||||
function (err) {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
res.status(201).json({ id: this.lastID });
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET comments (ISO formatted date)
|
// POST comment
|
||||||
app.get('/api/tickets/:id/comments', (req, res) => {
|
app.post('/api/tickets/:id/comments', async (req, res) => {
|
||||||
db.all(
|
const { author, role, content } = req.body;
|
||||||
|
if (!author?.trim() || !content?.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Author and content required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await dbRun(
|
||||||
|
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, "Comment", ?, ?, ?, ?)',
|
||||||
|
[req.params.id, content.trim(), author.trim(), role || 'user', utcNow()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server-side webhook for comments
|
||||||
|
const ticket = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
|
const ticketTitle = ticket?.title || '';
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
notifyUser('comment', { ticketId: req.params.id, title: ticketTitle, author, content: content.trim() });
|
||||||
|
} else {
|
||||||
|
notifyAdmin({
|
||||||
|
id: req.params.id,
|
||||||
|
title: ticketTitle,
|
||||||
|
requester: ticket?.requester || author,
|
||||||
|
status: ticket?.status || '-',
|
||||||
|
commentAuthor: author,
|
||||||
|
commentContent: content.trim(),
|
||||||
|
}, 'comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ id: result.lastID });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET comments
|
||||||
|
app.get('/api/tickets/:id/comments', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(
|
||||||
`SELECT id, ticket_id, message AS content, author, role,
|
`SELECT id, ticket_id, message AS content, author, role,
|
||||||
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
|
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
|
||||||
FROM history
|
FROM history WHERE ticket_id = ? AND action = "Comment"
|
||||||
WHERE ticket_id = ? AND action = "Comment"
|
|
||||||
ORDER BY timestamp DESC`,
|
ORDER BY timestamp DESC`,
|
||||||
[req.params.id],
|
[req.params.id]
|
||||||
(err, rows) => {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
res.json(rows);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET ticket history
|
||||||
|
app.get('/api/tickets/:id/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(
|
||||||
|
`SELECT id, ticket_id, action, message, author, role,
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
|
||||||
|
FROM history WHERE ticket_id = ?
|
||||||
|
ORDER BY timestamp DESC`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST time log
|
// POST time log
|
||||||
app.post('/api/tickets/:id/time', (req, res) => {
|
app.post('/api/tickets/:id/time', async (req, res) => {
|
||||||
const { minutes, comment, author } = req.body;
|
const { minutes, comment, author } = req.body;
|
||||||
if (typeof minutes !== 'number' || minutes <= 0)
|
if (typeof minutes !== 'number' || minutes <= 0) {
|
||||||
return res.status(400).json({ error: 'Minutes must be a positive number.' });
|
return res.status(400).json({ error: 'Minutes must be a positive number.' });
|
||||||
if (!author)
|
}
|
||||||
|
if (!author?.trim()) {
|
||||||
return res.status(400).json({ error: 'Author is required.' });
|
return res.status(400).json({ error: 'Author is required.' });
|
||||||
|
}
|
||||||
db.get('SELECT * FROM tickets WHERE id = ?', [req.params.id], (err, ticket) => {
|
try {
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
const ticket = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
|
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
|
||||||
|
|
||||||
db.run(
|
await dbRun(
|
||||||
'INSERT INTO time_logs (ticket_id, minutes, comment, author) VALUES (?, ?, ?, ?)',
|
'INSERT INTO time_logs (ticket_id, minutes, comment, author) VALUES (?, ?, ?, ?)',
|
||||||
[req.params.id, minutes, comment || '', author],
|
[req.params.id, minutes, comment || '', author]
|
||||||
function (err) {
|
);
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
|
|
||||||
db.get(
|
const { total } = await dbGet(
|
||||||
'SELECT SUM(minutes) as total FROM time_logs WHERE ticket_id = ?',
|
'SELECT SUM(minutes) as total FROM time_logs WHERE ticket_id = ?',
|
||||||
[req.params.id],
|
[req.params.id]
|
||||||
(err, row) => {
|
);
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
const totalTime = total || 0;
|
||||||
|
|
||||||
const totalTime = row.total || 0;
|
await dbRun(
|
||||||
db.run(
|
'UPDATE tickets SET total_time = ?, updated_at = ? WHERE id = ?',
|
||||||
'UPDATE tickets SET total_time = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
[totalTime, utcNow(), req.params.id]
|
||||||
[totalTime, req.params.id],
|
|
||||||
err => {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
logHistory(
|
|
||||||
req.params.id,
|
|
||||||
'Time Logged',
|
|
||||||
`Added ${minutes} minutes by ${author}. Comment: ${comment || 'none'}`,
|
|
||||||
author
|
|
||||||
);
|
);
|
||||||
// Send back the updated total_time to the frontend
|
|
||||||
|
logHistory(req.params.id, 'Time Logged', `Added ${minutes} min by ${author}. Comment: ${comment || 'none'}`, author);
|
||||||
|
notifyUser('timeLogged', { ticketId: req.params.id, title: ticket.title, minutes, author, comment });
|
||||||
|
|
||||||
res.status(201).json({ message: 'Time logged', total_time: totalTime });
|
res.status(201).json({ message: 'Time logged', total_time: totalTime });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET time logs
|
// GET time logs
|
||||||
app.get('/api/tickets/:id/time', (req, res) => {
|
app.get('/api/tickets/:id/time', async (req, res) => {
|
||||||
db.all(
|
try {
|
||||||
|
const rows = await dbAll(
|
||||||
'SELECT * FROM time_logs WHERE ticket_id = ? ORDER BY created_at DESC',
|
'SELECT * FROM time_logs WHERE ticket_id = ? ORDER BY created_at DESC',
|
||||||
[req.params.id],
|
[req.params.id]
|
||||||
(err, rows) => {
|
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
|
||||||
res.json(rows);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Start ---
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server läuft auf http://localhost:${port}`);
|
console.log(`Server running on http://localhost:${port}`);
|
||||||
console.log('Webhook URLs loaded:', {
|
console.log('Webhooks:', {
|
||||||
admin: ADMIN_WEBHOOK_URL ? 'Configured' : 'Not configured',
|
admin: ADMIN_WEBHOOK_URL ? 'Configured' : 'Not configured',
|
||||||
user: USER_WEBHOOK_URL ? 'Configured' : 'Not configured'
|
user: USER_WEBHOOK_URL ? 'Configured' : 'Not configured',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user