diff --git a/.gitignore b/.gitignore
index 54613fa..e26c325 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,8 +4,6 @@ target/
!**/src/main/**/target/
!**/src/test/**/target/
-logs/
-
### STS ###
.apt_generated
.classpath
@@ -34,4 +32,7 @@ build/
### VS Code ###
.vscode/
-.env
\ No newline at end of file
+.env
+
+# Logs
+logs/cyberwatch.log
\ No newline at end of file
diff --git a/src/main/java/org/example/cyberwatch/config/DataInitializer.java b/src/main/java/org/example/cyberwatch/config/DataInitializer.java
index aaf00ff..2b9a0c7 100644
--- a/src/main/java/org/example/cyberwatch/config/DataInitializer.java
+++ b/src/main/java/org/example/cyberwatch/config/DataInitializer.java
@@ -43,6 +43,7 @@ private Staff createStaff(String ssn, String firstName, String lastName, String
staff.setPhoneNumber(phone);
staff.setRole(role);
staff.setDepartment(department);
+ staff.setStatus("OFFLINE");
staff.setPassword(passwordEncoder.encode(rawPassword));
return staff;
}
diff --git a/src/main/java/org/example/cyberwatch/features/activitylog/controller/ActivityLogController.java b/src/main/java/org/example/cyberwatch/features/activitylog/controller/ActivityLogController.java
index db458e9..c36e8a2 100644
--- a/src/main/java/org/example/cyberwatch/features/activitylog/controller/ActivityLogController.java
+++ b/src/main/java/org/example/cyberwatch/features/activitylog/controller/ActivityLogController.java
@@ -4,7 +4,10 @@
import org.example.cyberwatch.features.activitylog.service.ActivityLogService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
import java.util.List;
diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java
index 7f66854..0eef5db 100644
--- a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java
+++ b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java
@@ -61,6 +61,6 @@ public class Staff {
@Column(name = "profile_picture_url")
private String profilePictureUrl;
- @Column(name = "status")
- private String status;
+ @Column(name = "status", nullable = false)
+ private String status = "OFFLINE";
}
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V11__set_default_status_for_staff.sql b/src/main/resources/db/migration/V11__set_default_status_for_staff.sql
new file mode 100644
index 0000000..0b590e0
--- /dev/null
+++ b/src/main/resources/db/migration/V11__set_default_status_for_staff.sql
@@ -0,0 +1,5 @@
+-- Set default value for status and update existing NULL values
+UPDATE staff SET status = 'OFFLINE' WHERE status IS NULL;
+
+ALTER TABLE staff ALTER COLUMN status SET DEFAULT 'OFFLINE';
+ALTER TABLE staff ALTER COLUMN status SET NOT NULL;
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index 3d61ca4..20b501e 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -32,7 +32,7 @@
-
+
diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css
index 712edfb..fc50434 100644
--- a/src/main/resources/static/css/styles.css
+++ b/src/main/resources/static/css/styles.css
@@ -6,41 +6,153 @@
--text: #1e293b;
--border: #e2e8f0;
}
+
* { box-sizing: border-box; margin: 0; padding: 0; }
-body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); }
+
+body {
+ font-family: Inter, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+}
+
.app-wrapper { display: flex; min-height: 100vh; }
/* Sidebar */
-.sidebar { width: var(--sidebar-width); background: var(--sidebar-bg); color: white; position: fixed; height: 100vh; display: flex; flex-direction: column; padding: 1.5rem; z-index: 100; }
-.sidebar-logo { font-size: 1.25rem; font-weight: 800; margin-bottom: 2rem; color: white; text-decoration: none; display: block; }
-.sidebar-nav { flex: 1; display: flex; flex-direction: column; gap: 0.25rem; }
-.sidebar-link { color: rgba(255,255,255,0.7); text-decoration: none; padding: 0.75rem 1rem; border-radius: 0.5rem; font-weight: 600; font-size: 0.9rem; transition: 0.2s; }
+.sidebar {
+ width: var(--sidebar-width);
+ background: var(--sidebar-bg);
+ color: white;
+ position: fixed;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ padding: 1.5rem;
+ z-index: 100;
+}
+
+.sidebar-logo {
+ font-size: 1.25rem;
+ font-weight: 800;
+ margin-bottom: 2rem;
+ color: white;
+ text-decoration: none;
+ display: block;
+}
+
+.sidebar-nav {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.sidebar-link {
+ color: rgba(255,255,255,0.7);
+ text-decoration: none;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ font-weight: 600;
+ font-size: 0.9rem;
+ transition: 0.2s;
+}
+
.sidebar-link:hover { background: rgba(255,255,255,0.1); color: white; }
.sidebar-link.active { background: var(--primary); color: white; }
.sidebar-footer { padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); }
-/* Main Content */
-.main-content { flex: 1; margin-left: var(--sidebar-width); padding: 2rem; }
-.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
-h1 { font-size: 1.5rem; font-weight: 800; color: #0f172a; }
-.card { background: white; border-radius: 0.75rem; padding: 1.5rem; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1.5rem; }
+/* Main */
+.main-content {
+ flex: 1;
+ margin-left: var(--sidebar-width);
+ padding: 2rem;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+}
+
+h1 {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: #0f172a;
+}
+
+.card {
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1.5rem;
+ border: 1px solid var(--border);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ margin-bottom: 1.5rem;
+}
/* Dashboard */
-.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
-.stat-card h3 { font-size: 0.7rem; color: #64748b; text-transform: uppercase; margin-bottom: 0.5rem; }
-.stat-card p { font-size: 1.5rem; font-weight: 800; color: var(--primary); }
-.filter-row { display: grid; grid-template-columns: 1fr 150px 150px auto; gap: 0.75rem; align-items: center; }
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card h3 {
+ font-size: 0.7rem;
+ color: #64748b;
+ text-transform: uppercase;
+ margin-bottom: 0.5rem;
+}
+
+.stat-card p {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: var(--primary);
+}
+
+.filter-row {
+ display: grid;
+ grid-template-columns: 1fr 150px 150px auto;
+ gap: 0.75rem;
+ align-items: center;
+}
/* Tickets */
-.ticket-list { border: 1px solid var(--border); border-radius: 0.5rem; overflow: hidden; }
-.ticket-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); background: white; cursor: pointer; }
+.ticket-list {
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.ticket-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+ background: white;
+ cursor: pointer;
+}
+
.ticket-item:last-child { border-bottom: none; }
.ticket-item:hover { background: #f8fafc; }
-.ticket-info h3 { font-size: 0.95rem; margin-bottom: 0.25rem; }
+
+.ticket-info h3 {
+ font-size: 0.95rem;
+ margin-bottom: 0.25rem;
+}
+
.muted { color: #64748b; font-size: 0.85rem; }
/* Badges */
-.badge { padding: 0.2rem 0.6rem; border-radius: 99px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; }
+.badge {
+ padding: 0.2rem 0.6rem;
+ border-radius: 99px;
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
.badge-secondary { background: #64748b; color: white; }
.badge-SUBMITTED { background: #dcfce7; color: #166534; }
.badge-IN_PROGRESS { background: #fef3c7; color: #92400e; }
@@ -48,26 +160,78 @@ h1 { font-size: 1.5rem; font-weight: 800; color: #0f172a; }
.badge-assigned { background: #dcfce7 !important; color: #166534 !important; font-weight: bold; }
/* Buttons */
-.btn { padding: 0.5rem 1rem; border-radius: 0.4rem; font-weight: 600; cursor: pointer; text-decoration: none; font-size: 0.85rem; border: 1px solid transparent; transition: 0.2s; }
+.btn {
+ padding: 0.5rem 1rem;
+ border-radius: 0.4rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ font-size: 0.85rem;
+ border: 1px solid transparent;
+ transition: 0.2s;
+}
+
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: white; border-color: var(--border); color: var(--text); }
-.logout-btn { background: transparent; color: white; border: 1px solid rgba(255,255,255,0.2); width: 100%; text-align: left; }
+
+.logout-btn {
+ background: transparent;
+ color: white;
+ border: 1px solid rgba(255,255,255,0.2);
+ width: 100%;
+ text-align: left;
+}
/* Forms */
.form-group { margin-bottom: 1rem; }
+
.dashboard-assignment-select {
width: auto;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
margin-left: 0.5rem;
}
-label { display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.4rem; }
-input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--border); border-radius: 0.4rem; font-size: 0.9rem; }
-.comment-item { padding: 0.75rem; background: #f8fafc; border-radius: 0.4rem; margin-bottom: 0.5rem; border: 1px solid var(--border); }
-.login-body { background: #064e3b; height: 100vh; display: flex; align-items: center; justify-content: center; }
-.login-card { background: white; padding: 3rem; border-radius: 1rem; width: 400px; text-align: center; }
+label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 600;
+ margin-bottom: 0.4rem;
+}
+
+input, select, textarea {
+ width: 100%;
+ padding: 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: 0.4rem;
+ font-size: 0.9rem;
+}
+
+.comment-item {
+ padding: 0.75rem;
+ background: #f8fafc;
+ border-radius: 0.4rem;
+ margin-bottom: 0.5rem;
+ border: 1px solid var(--border);
+}
+
+.login-body {
+ background: #064e3b;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.login-card {
+ background: white;
+ padding: 3rem;
+ border-radius: 1rem;
+ width: 400px;
+ text-align: center;
+}
+/* Profile */
.sidebar-profile {
display: flex;
align-items: center;
@@ -86,10 +250,7 @@ input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--
flex-shrink: 0;
}
-.sidebar-profile-info {
- flex: 1;
- min-width: 0;
-}
+.sidebar-profile-info { flex: 1; min-width: 0; }
.sidebar-profile-name {
font-size: 0.9rem;
@@ -103,7 +264,7 @@ input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--
.sidebar-profile-role {
font-size: 0.75rem;
- color: rgba(255, 255, 255, 0.6);
+ color: rgba(255,255,255,0.6);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.025em;
@@ -120,10 +281,9 @@ input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--
width: 100%;
}
-.sidebar-status-select option {
- color: black;
-}
+.sidebar-status-select option { color: black; }
+/* Employment */
.employment-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -134,7 +294,7 @@ input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
- background: var(--surface, #fff);
+ background: #fff;
}
.employment-card-header {
@@ -145,14 +305,8 @@ input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--
margin-bottom: 1rem;
}
-.employment-card-header h3 {
- margin: 0;
- font-size: 1.05rem;
-}
-
-.employment-card-body p {
- margin: 0.45rem 0;
-}
+.employment-card-header h3 { margin: 0; font-size: 1.05rem; }
+.employment-card-body p { margin: 0.45rem 0; }
.employment-form-grid {
display: grid;
@@ -169,6 +323,104 @@ input, select, textarea { width: 100%; padding: 0.6rem; border: 1px solid var(--
margin-top: 0.35rem;
}
-.employment-top-card {
- margin-bottom: 1.5rem;
+.employment-top-card { margin-bottom: 1.5rem; }
+
+/* Online widget */
+.sidebar-online-wrapper { margin-bottom: 10px; }
+
+.sidebar-online-box {
+ background: rgba(255,255,255,0.08);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 10px;
+ padding: 8px 10px;
+ margin-bottom: 0;
+}
+
+.sidebar-online-button {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ text-align: left;
+ color: white;
+ cursor: pointer;
+}
+
+.sidebar-online-button:hover { background: rgba(255,255,255,0.12); }
+
+.sidebar-online-title {
+ font-size: 11px;
+ color: rgba(255,255,255,0.75);
+ margin-bottom: 2px;
+}
+
+.sidebar-online-count {
+ font-size: 18px;
+ font-weight: 700;
+ color: white;
+ line-height: 1;
+ margin-bottom: 1px;
+}
+
+.sidebar-online-text {
+ font-size: 10px;
+ color: rgba(255,255,255,0.65);
+}
+
+.sidebar-online-arrow {
+ font-size: 13px;
+ color: rgba(255,255,255,0.8);
+ margin-left: 10px;
+ flex-shrink: 0;
+}
+
+.online-staff-list {
+ display: none;
+ margin-top: -4px;
+ margin-bottom: 10px;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.08);
+ border-radius: 10px;
+ padding: 6px;
+ max-height: min(40vh, 320px);
+ overflow-y: auto;
}
+
+.online-staff-list.show { display: block; }
+
+.online-staff-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ padding: 6px;
+ border-bottom: 1px solid rgba(255,255,255,0.08);
+}
+
+.online-staff-item:last-child { border-bottom: none; }
+
+.online-staff-name {
+ font-size: 13px;
+ color: white;
+ font-weight: 500;
+}
+
+.online-staff-status {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 7px;
+ border-radius: 999px;
+ white-space: nowrap;
+}
+
+.status-online { background: rgba(34,197,94,0.18); color: #86efac; }
+.status-busy { background: rgba(239,68,68,0.18); color: #fca5a5; }
+.status-away { background: rgba(245,158,11,0.18); color: #fcd34d; }
+.status-offline { background: rgba(107,114,128,0.18); color: #d1d5db; }
+
+.online-staff-loading,
+.online-staff-empty {
+ color: rgba(255,255,255,0.75);
+ font-size: 12px;
+ padding: 8px 6px;
+}
\ No newline at end of file
diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js
index 9974239..67a5e8a 100644
--- a/src/main/resources/static/js/common.js
+++ b/src/main/resources/static/js/common.js
@@ -8,11 +8,11 @@ function getCsrfToken() {
function escapeHtml(text) {
if (!text) return "";
return String(text)
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
}
function safeImageUrl(url) {
@@ -28,7 +28,7 @@ function safeImageUrl(url) {
}
function getHeaders() {
- const headers = {"Content-Type": "application/json"};
+ const headers = { "Content-Type": "application/json" };
const csrf = getCsrfToken();
if (csrf) headers["X-XSRF-TOKEN"] = csrf;
return headers;
@@ -38,19 +38,17 @@ async function apiFetch(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
credentials: "same-origin",
- headers: {...getHeaders(), ...options.headers}
+ headers: { ...getHeaders(), ...options.headers }
});
+
if (response.status === 401) {
logout();
- // Return a never-resolving promise so callers don't try to parse the 401 body
- // while the browser is navigating away.
return new Promise(() => {});
}
+
return response;
}
-// FIX 3 (Major): Use POST so Spring Security actually invalidates the session.
-// A plain GET to /logout only shows a confirmation page when CSRF is enabled.
async function logout() {
try {
await fetch("/logout", {
@@ -81,7 +79,9 @@ async function renderNavbar() {
const fullName =
currentUser?.fullName ||
- (currentUser?.firstName || currentUser?.lastName ? `${currentUser.firstName || ""} ${currentUser.lastName || ""}`.trim() : "Användare");
+ (currentUser?.firstName || currentUser?.lastName
+ ? `${currentUser.firstName || ""} ${currentUser.lastName || ""}`.trim()
+ : "Användare");
const profileImage = safeImageUrl(currentUser?.profilePictureUrl);
const status = currentUser?.status || "ONLINE";
@@ -94,13 +94,13 @@ async function renderNavbar() {
+
+
@@ -134,6 +147,7 @@ async function renderNavbar() {
statusSelect.addEventListener("change", async (e) => {
const nextStatus = e.target.value;
+
try {
const res = await apiFetch(`/staff/me/status?status=${encodeURIComponent(nextStatus)}`, {
method: "PATCH"
@@ -146,6 +160,10 @@ async function renderNavbar() {
}
previousStatus = nextStatus;
+
+ if (typeof refreshOnlineStaffWidget === "function") {
+ await refreshOnlineStaffWidget();
+ }
} catch (err) {
e.target.value = previousStatus;
console.error(err);
@@ -153,30 +171,37 @@ async function renderNavbar() {
}
});
}
+
+ if (typeof initOnlineStaffWidget === "function") {
+ initOnlineStaffWidget();
+ }
}
async function loadStaffList(selectId, selectedIds = []) {
const select = document.getElementById(selectId);
if (!select) return;
+
try {
const res = await apiFetch("/staff");
if (!res.ok) return;
+
const staff = await res.json();
const firstOption = select.querySelector('option[value=""]');
- const existingDefault = firstOption ? firstOption.outerHTML : '';
+ const existingDefault = firstOption
+ ? firstOption.outerHTML
+ : '';
- // FIX 1 (Major): Normalize all IDs to strings so includes() matches reliably
- // regardless of whether the API returns numbers and the caller passes strings (or vice versa).
const selectedIdSet = new Set(selectedIds.map(String));
- let staffOptions = staff.map(s => {
+ const staffOptions = staff.map(s => {
const staffId = String(s.id);
const isAssigned = selectedIdSet.has(staffId);
- const className = isAssigned ? 'class="badge-assigned"' : '';
- const selectedAttr = isAssigned ? 'selected' : '';
+ const className = isAssigned ? 'class="badge-assigned"' : "";
+ const selectedAttr = isAssigned ? "selected" : "";
+
return ``;
- }).join('');
+ }).join("");
select.innerHTML = existingDefault + staffOptions;
} catch (e) {
diff --git a/src/main/resources/static/js/online-staff.js b/src/main/resources/static/js/online-staff.js
new file mode 100644
index 0000000..9dbb24c
--- /dev/null
+++ b/src/main/resources/static/js/online-staff.js
@@ -0,0 +1,144 @@
+function getStatusLabel(status) {
+ switch (status) {
+ case "ONLINE":
+ return "Online";
+ case "BUSY":
+ return "Upptagen";
+ case "AWAY":
+ return "Borta";
+ case "OFFLINE":
+ return "Offline";
+ default:
+ return "Okänd";
+ }
+}
+
+function getStatusClass(status) {
+ switch (status) {
+ case "ONLINE":
+ return "status-online";
+ case "BUSY":
+ return "status-busy";
+ case "AWAY":
+ return "status-away";
+ default:
+ return "status-offline";
+ }
+}
+
+async function fetchActiveStaff() {
+ const res = await apiFetch("/staff");
+ if (!res.ok) {
+ throw new Error("Kunde inte hämta staff");
+ }
+
+ const staff = await res.json();
+ const validStatuses = ["ONLINE", "BUSY", "AWAY"];
+ return staff.filter(person => validStatuses.includes(person.status));
+}
+
+async function loadOnlineStaffCount() {
+ const onlineCountElement = document.getElementById("onlineStaffCount");
+ if (!onlineCountElement) return;
+
+ try {
+ const activeStaff = await fetchActiveStaff();
+ onlineCountElement.textContent = activeStaff.length;
+ } catch (e) {
+ console.error("Error loading online staff count", e);
+ onlineCountElement.textContent = "-";
+ }
+}
+
+async function renderOnlineStaffList() {
+ const listElement = document.getElementById("onlineStaffList");
+ if (!listElement) return;
+
+ listElement.innerHTML = `Laddar...
`;
+
+ try {
+ const activeStaff = await fetchActiveStaff();
+
+ if (activeStaff.length === 0) {
+ listElement.innerHTML = `Ingen är online just nu.
`;
+ return;
+ }
+
+ listElement.innerHTML = activeStaff.map(person => {
+ const fullName =
+ person.fullName ||
+ `${person.firstName || ""} ${person.lastName || ""}`.trim() ||
+ "Användare";
+
+ return `
+
+
${escapeHtml(fullName)}
+
+ ${escapeHtml(getStatusLabel(person.status))}
+
+
+ `;
+ }).join("");
+ } catch (e) {
+ console.error("Error loading online staff list", e);
+ listElement.innerHTML = `Något gick fel.
`;
+ }
+}
+
+async function toggleOnlineStaffList() {
+ const listElement = document.getElementById("onlineStaffList");
+ const arrowElement = document.getElementById("onlineStaffArrow");
+ const toggleButton = document.getElementById("onlineStaffToggle");
+
+ if (!listElement) return;
+
+ const isOpen = listElement.classList.contains("show");
+
+ if (isOpen) {
+ listElement.classList.remove("show");
+ if (toggleButton) {
+ toggleButton.setAttribute("aria-expanded", "false");
+ }
+ if (arrowElement) {
+ arrowElement.textContent = "▾";
+ }
+ return;
+ }
+
+ listElement.classList.add("show");
+ if (toggleButton) {
+ toggleButton.setAttribute("aria-expanded", "true");
+ }
+ if (arrowElement) {
+ arrowElement.textContent = "▴";
+ }
+
+ await renderOnlineStaffList();
+}
+
+async function refreshOnlineStaffWidget() {
+ await loadOnlineStaffCount();
+
+ const listElement = document.getElementById("onlineStaffList");
+ if (listElement && listElement.classList.contains("show")) {
+ await renderOnlineStaffList();
+ }
+}
+let onlineStaffRefreshTimer = null;
+function initOnlineStaffWidget() {
+ const toggleButton = document.getElementById("onlineStaffToggle");
+ if (!toggleButton) return;
+
+ toggleButton.addEventListener("click", toggleOnlineStaffList);
+ refreshOnlineStaffWidget();
+
+ if (!onlineStaffRefreshTimer) {
+ onlineStaffRefreshTimer = window.setInterval(refreshOnlineStaffWidget, 30000);
+ }
+
+ document.addEventListener("visibilitychange", () => {
+ if (!document.hidden) {
+ refreshOnlineStaffWidget();
+ }
+ });
+}
\ No newline at end of file
diff --git a/src/main/resources/static/pages/create-ticket.html b/src/main/resources/static/pages/create-ticket.html
index a253521..bd434ac 100644
--- a/src/main/resources/static/pages/create-ticket.html
+++ b/src/main/resources/static/pages/create-ticket.html
@@ -73,6 +73,7 @@ Skapa ny ticket
+
-
-
+
+
+
+
+