From e6fae71d85e1789add383cd2410f6e3d209d72f8 Mon Sep 17 00:00:00 2001 From: Eric Thilen Date: Wed, 22 Apr 2026 16:09:16 +0200 Subject: [PATCH 1/3] set default staff status to 'OFFLINE'; add online staff JavaScript module; update UI for status management and display --- .../cyberwatch/config/DataInitializer.java | 1 + .../features/staff/model/Staff.java | 4 +- .../V11__set_default_status_for_staff.sql | 5 + src/main/resources/static/css/styles.css | 338 +++++++++++++++--- src/main/resources/static/js/common.js | 77 ++-- src/main/resources/static/js/online-staff.js | 127 +++++++ .../resources/static/pages/create-ticket.html | 1 + .../resources/static/pages/dashboard.html | 12 +- .../resources/static/pages/edit-ticket.html | 1 + .../resources/static/pages/employment.html | 1 + .../resources/static/pages/ticket-detail.html | 3 +- .../staff/service/StaffServiceTest.java | 7 + 12 files changed, 499 insertions(+), 78 deletions(-) create mode 100644 src/main/resources/db/migration/V11__set_default_status_for_staff.sql create mode 100644 src/main/resources/static/js/online-staff.js 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/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/static/css/styles.css b/src/main/resources/static/css/styles.css index 712edfb..dbc4943 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,102 @@ 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; } + +.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 f16c5df..b0afe68 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) { @@ -38,7 +38,7 @@ function safeHttpUrl(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; @@ -48,19 +48,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", { @@ -91,7 +89,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"; @@ -104,27 +104,40 @@ async function renderNavbar() { + + @@ -140,6 +153,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" @@ -152,6 +166,10 @@ async function renderNavbar() { } previousStatus = nextStatus; + + if (typeof refreshOnlineStaffWidget === "function") { + await refreshOnlineStaffWidget(); + } } catch (err) { e.target.value = previousStatus; console.error(err); @@ -159,30 +177,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..765e4fd --- /dev/null +++ b/src/main/resources/static/js/online-staff.js @@ -0,0 +1,127 @@ +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"); + + if (!listElement) return; + + const isOpen = listElement.classList.contains("show"); + + if (isOpen) { + listElement.classList.remove("show"); + if (arrowElement) { + arrowElement.textContent = "▾"; + } + return; + } + + listElement.classList.add("show"); + if (arrowElement) { + arrowElement.textContent = "▴"; + } + + await renderOnlineStaffList(); +} + +async function refreshOnlineStaffWidget() { + await loadOnlineStaffCount(); + + const listElement = document.getElementById("onlineStaffList"); + if (listElement && listElement.classList.contains("show")) { + await renderOnlineStaffList(); + } +} + +function initOnlineStaffWidget() { + const toggleButton = document.getElementById("onlineStaffToggle"); + if (!toggleButton) return; + + toggleButton.addEventListener("click", toggleOnlineStaffList); + 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

+ - - + + + + + \ No newline at end of file diff --git a/src/main/resources/static/pages/edit-ticket.html b/src/main/resources/static/pages/edit-ticket.html index aa90943..cdc811e 100644 --- a/src/main/resources/static/pages/edit-ticket.html +++ b/src/main/resources/static/pages/edit-ticket.html @@ -61,6 +61,7 @@

Redigera ärende

+ + - + +> -> +