From e161c72fc4400f187677b4b104308fc280d10b3e Mon Sep 17 00:00:00 2001 From: Niek Rabelink Date: Thu, 10 Sep 2026 15:44:37 +0200 Subject: [PATCH] Enhance email rendering and Jira widget functionality - Implemented a secure rendering method for email content using a sandboxed iframe to prevent XSS attacks. - Added thorough HTML sanitization for incoming emails to mitigate potential exploits. - Refactored email body rendering logic to utilize a dedicated function for better readability and maintainability. - Updated Jira widget to fetch and display dashboard data, including assigned issues and team issues, with improved filtering options. - Enhanced the user interface of the Jira widget with a standardized control toolbar for resizing and collapsing widgets. - Introduced inline search functionality within the Jira widget for quick ticket filtering. - Improved error handling and logging for Jira data fetching. --- api/webhooks/jira.php | 14 +- config/config.php | 3 + includes/integrations/jira.php | 59 +++- jira.php | 186 ++++++++--- mail.php | 117 ++++++- planner.php | 588 +++++++++++++++++++++++++++++++-- 6 files changed, 867 insertions(+), 100 deletions(-) diff --git a/api/webhooks/jira.php b/api/webhooks/jira.php index ad3a800..bbc9701 100644 --- a/api/webhooks/jira.php +++ b/api/webhooks/jira.php @@ -4,16 +4,28 @@ * Ontvangt webhook events van Atlassian Jira Cloud (issue_created, issue_updated, etc.) */ +require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/database.php'; header('Content-Type: application/json'); +// Beveiligingscontrole: Verifieer geheime webhook token indien geconfigureerd in .env +$configuredSecret = fociz_env('JIRA_WEBHOOK_SECRET', ''); +if (!empty($configuredSecret)) { + $incomingSecret = $_GET['secret'] ?? ($_SERVER['HTTP_X_ATLASSIAN_WEBHOOK_SECRET'] ?? ''); + if (!hash_equals($configuredSecret, $incomingSecret)) { + http_response_code(401); + echo json_encode(['error' => 'Onbevoegde webhook aanroep']); + exit; + } +} + $rawPayload = file_get_contents('php://input'); $data = json_decode($rawPayload, true); if (!$data) { http_response_code(400); - echo json_encode(['error' => 'Invalid JSON payload']); + echo json_encode(['error' => 'Ongeldige JSON payload']); exit; } diff --git a/config/config.php b/config/config.php index fd30565..04a051c 100644 --- a/config/config.php +++ b/config/config.php @@ -17,6 +17,9 @@ if (!headers_sent()) { header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); + if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } header_remove('X-Powered-By'); } @ini_set('expose_php', 'off'); diff --git a/includes/integrations/jira.php b/includes/integrations/jira.php index f88d5a7..4b93189 100644 --- a/includes/integrations/jira.php +++ b/includes/integrations/jira.php @@ -221,6 +221,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri $authHeader = jira_get_auth_header($email, $apiToken); $fieldsList = [ + '*navigable', 'summary', 'status', 'priority', @@ -233,6 +234,8 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri 'updated', 'created', 'description', + 'labels', + 'components', ]; // 1. Moderne Atlassian Jira Cloud endpoint (/rest/api/3/search/jql) conform CHANGE-2046 via POST @@ -325,29 +328,67 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri $descriptionText = jira_adf_to_text($fields['description'] ?? ''); - // Detectie van custom field "Onderwerp logging" + // Detectie van custom field "Onderwerp logging" of routeringsveld $onderwerpLogging = ''; + $knownTopics = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie', 'incident', 'vraag', 'algemeen']; + foreach ($fields as $fKey => $fVal) { if (str_starts_with($fKey, 'customfield_')) { - if (is_array($fVal) && isset($fVal['value']) && is_string($fVal['value'])) { - $valStr = trim($fVal['value']); - $known = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie', 'incident', 'vraag', 'algemeen']; - if (in_array(strtolower($valStr), $known)) { - $onderwerpLogging = $valStr; + if (is_array($fVal)) { + if (isset($fVal['value']) && is_string($fVal['value'])) { + $valStr = trim($fVal['value']); + if (!empty($valStr)) { + foreach ($knownTopics as $kt) { + if (str_contains(strtolower($valStr), $kt)) { + $onderwerpLogging = $valStr; + break 2; + } + } + if (empty($onderwerpLogging) && (stripos($fKey, 'onderwerp') !== false || stripos($fKey, 'logging') !== false)) { + $onderwerpLogging = $valStr; + } + } } } elseif (is_string($fVal) && !empty($fVal)) { $valStr = trim($fVal); - $known = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie']; - if (in_array(strtolower($valStr), $known)) { - $onderwerpLogging = $valStr; + foreach ($knownTopics as $kt) { + if (str_contains(strtolower($valStr), $kt)) { + $onderwerpLogging = $valStr; + break 2; + } } } } } + if (empty($onderwerpLogging) && !empty($fields['onderwerp_logging'])) { $onderwerpLogging = is_string($fields['onderwerp_logging']) ? $fields['onderwerp_logging'] : ($fields['onderwerp_logging']['value'] ?? ''); } + // Fallback: kijk naar labels & componenten + if (empty($onderwerpLogging) && !empty($fields['labels']) && is_array($fields['labels'])) { + foreach ($fields['labels'] as $lbl) { + $lblLower = strtolower(trim($lbl)); + foreach ($knownTopics as $kt) { + if (str_contains($lblLower, $kt)) { + $onderwerpLogging = ucfirst($kt); + break 2; + } + } + } + } + + // Fallback: detecteer onderwerp op basis van steekwoorden in summary + if (empty($onderwerpLogging)) { + $sumLower = strtolower($summary); + if (str_contains($sumLower, 'sso')) $onderwerpLogging = 'SSO-koppeling'; + elseif (str_contains($sumLower, 'endpoint') || str_contains($sumLower, 'rest') || str_contains($sumLower, 'api')) $onderwerpLogging = 'Rest-endpoints'; + elseif (str_contains($sumLower, 'uwv')) $onderwerpLogging = 'UWV-koppeling'; + elseif (str_contains($sumLower, 'datadump') || str_contains($sumLower, 'dump')) $onderwerpLogging = 'Datadump'; + elseif (str_contains($sumLower, 'migratie')) $onderwerpLogging = 'Migratie'; + elseif (str_contains($sumLower, 'koppeling')) $onderwerpLogging = 'Koppelingen'; + } + $issues[] = [ 'key' => $key, 'summary' => $summary, diff --git a/jira.php b/jira.php index e9cdbf6..76b0e88 100644 --- a/jira.php +++ b/jira.php @@ -320,6 +320,17 @@ $boardsJson = json_encode($boards, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT 0 + + + + @@ -677,6 +688,7 @@ let currentProject = ''; let currentQueue = jiraData.default_queue_filter || ''; let activeUrgentOnly = false; let expandedKeys = new Set(); +const ticketStore = new Map(); // Bereken statussen en KPI statistieken function calculateKpis() { @@ -758,9 +770,10 @@ function onProjectSelectChange(proj) { function updateProjectPills() { const pills = document.querySelectorAll('.project-pill'); + const curNorm = (currentProject || '').trim().toLowerCase(); pills.forEach(p => { - const pKey = p.getAttribute('data-project') || ''; - if (pKey === currentProject) { + const pKey = (p.getAttribute('data-project') || '').trim().toLowerCase(); + if (pKey === curNorm) { p.className = "project-pill flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 flex-shrink-0"; } else { p.className = "project-pill flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-bold transition cursor-pointer bg-slate-100 dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700/80 border border-slate-200/80 dark:border-slate-700/80 flex-shrink-0"; @@ -768,6 +781,40 @@ function updateProjectPills() { }); } +// Hulpfunctie: Bepaal of een ticket tot een specifieke wachtrij behoort (DX, SD of aangepast) +function isIssueInQueue(iss, qCode) { + if (!qCode) return true; + const code = qCode.toUpperCase(); + const ol = (iss.onderwerp_logging || '').toLowerCase().trim(); + const sum = (iss.summary || '').toLowerCase(); + const pKey = (iss.project_key || '').toUpperCase().trim(); + + const dxTerms = ['koppeling', 'sso', 'endpoint', 'rest', 'datadump', 'uwv', 'migratie', 'api', 'webhook', 'integrat']; + const matchesDx = dxTerms.some(t => ol.includes(t) || sum.includes(t)); + + if (code === 'DX') { + return matchesDx; + } + if (code === 'SD') { + // SD is regulier Servicedesk (alle tickets die niet tot DX routering behoren) + return !matchesDx; + } + + // Aangepaste wachtrijfilter uit de database + const fObj = (jiraData.queue_filters || []).find(f => f.filter_code.toUpperCase() === code); + if (fObj) { + if (fObj.project_key && pKey && fObj.project_key.toUpperCase() !== pKey) { + return false; + } + const clause = (fObj.jql_clause || '').toLowerCase(); + if (ol && (clause.includes(ol) || ol.includes(code.toLowerCase()))) { + return true; + } + if (clause.includes('koppeling') && matchesDx) return true; + } + return true; +} + // Wachtrijfilter selectie function selectQueueFilter(code) { currentQueue = code; @@ -796,18 +843,34 @@ function updateQueueCounts() { let currentList = all; if (currentTab === 'assigned') currentList = assigned; else if (currentTab === 'team') currentList = team; - - if (currentProject) { - currentList = currentList.filter(iss => iss.project === currentProject); + else if (currentTab === 'in_progress') { + currentList = all.filter(iss => { + const s = (iss.status || '').toLowerCase(); + const sc = (iss.status_category || '').toLowerCase(); + return s.includes('progress') || s.includes('bezig') || s.includes('review') || sc === 'indeterminate'; + }); + } else if (currentTab === 'done') { + currentList = all.filter(iss => { + const s = (iss.status || '').toLowerCase(); + const sc = (iss.status_category || '').toLowerCase(); + return s.includes('done') || s.includes('afgerond') || s.includes('gesloten') || sc === 'done'; + }); + } + + if (currentProject) { + const pNorm = currentProject.trim().toLowerCase(); + currentList = currentList.filter(iss => { + const pName = (iss.project || '').trim().toLowerCase(); + const pKey = (iss.project_key || '').trim().toLowerCase(); + return pName === pNorm || pKey === pNorm; + }); } - const dxValues = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie']; let dxCount = 0; let sdCount = 0; currentList.forEach(iss => { - const ol = (iss.onderwerp_logging || '').toLowerCase().trim(); - if (dxValues.includes(ol)) { + if (isIssueInQueue(iss, 'DX')) { dxCount++; } else { sdCount++; @@ -821,6 +884,14 @@ function updateQueueCounts() { if (elAll) elAll.innerText = currentList.length; if (elDx) elDx.innerText = dxCount; if (elSd) elSd.innerText = sdCount; + + (jiraData.queue_filters || []).forEach(f => { + const el = document.getElementById(`queueCount_${f.filter_code}`); + if (el && f.filter_code !== 'DX' && f.filter_code !== 'SD') { + const cnt = currentList.filter(iss => isIssueInQueue(iss, f.filter_code)).length; + el.innerText = cnt; + } + }); } // Snelkoppeling KPI-kaart Urgent @@ -869,9 +940,9 @@ function resetAllFilters() { function applyFilters() { const searchVal = (document.getElementById('jiraSearchInput')?.value || '').trim().toLowerCase(); const projectVal = currentProject || (document.getElementById('filterProject')?.value || ''); - const typeVal = document.getElementById('filterType')?.value || ''; - const priorityVal = document.getElementById('filterPriority')?.value || ''; - const statusVal = document.getElementById('filterStatus')?.value || ''; + const typeVal = (document.getElementById('filterType')?.value || '').trim().toLowerCase(); + const priorityVal = (document.getElementById('filterPriority')?.value || '').trim(); + const statusVal = (document.getElementById('filterStatus')?.value || '').trim().toLowerCase(); const clearBtn = document.getElementById('clearSearchBtn'); if (clearBtn) { @@ -902,55 +973,54 @@ function applyFilters() { }); } - const dxValues = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie']; - // Pas zoek-, project-, wachtrij-, type-, prioriteit- en statusfilters toe const filtered = sourceList.filter(iss => { if (searchVal) { const keyMatch = (iss.key || '').toLowerCase().includes(searchVal); const sumMatch = (iss.summary || '').toLowerCase().includes(searchVal); const prjMatch = (iss.project || '').toLowerCase().includes(searchVal); + const prjKeyMatch = (iss.project_key || '').toLowerCase().includes(searchVal); const descMatch = (iss.description || '').toLowerCase().includes(searchVal); const repMatch = (iss.reporter_name || '').toLowerCase().includes(searchVal); + const assMatch = (iss.assignee_name || '').toLowerCase().includes(searchVal); const olMatch = (iss.onderwerp_logging || '').toLowerCase().includes(searchVal); - if (!keyMatch && !sumMatch && !prjMatch && !descMatch && !repMatch && !olMatch) return false; + if (!keyMatch && !sumMatch && !prjMatch && !prjKeyMatch && !descMatch && !repMatch && !assMatch && !olMatch) return false; } - if (projectVal && iss.project !== projectVal) { - return false; - } - - if (currentQueue) { - const qCode = currentQueue.toUpperCase(); - const ol = (iss.onderwerp_logging || '').toLowerCase().trim(); - - if (qCode === 'DX') { - if (!dxValues.includes(ol)) return false; - } else if (qCode === 'SD') { - if (ol !== '' && ol !== 'empty' && dxValues.includes(ol)) return false; - } else { - const fObj = (jiraData.queue_filters || []).find(f => f.filter_code.toUpperCase() === qCode); - if (fObj) { - if (ol && !ol.includes(qCode.toLowerCase()) && !fObj.jql_clause.toLowerCase().includes(ol)) { - return false; - } - } - } - } - - if (typeVal && iss.issue_type !== typeVal) { - return false; - } - - if (priorityVal) { - if (priorityVal === 'High') { - if (iss.priority !== 'High' && iss.priority !== 'Highest') return false; - } else if (iss.priority !== priorityVal) { + if (projectVal) { + const pNorm = projectVal.trim().toLowerCase(); + const pName = (iss.project || '').trim().toLowerCase(); + const pKey = (iss.project_key || '').trim().toLowerCase(); + if (pName !== pNorm && pKey !== pNorm) { return false; } } - if (statusVal && iss.status !== statusVal) { + if (currentQueue) { + if (!isIssueInQueue(iss, currentQueue)) { + return false; + } + } + + if (typeVal && (iss.issue_type || '').toLowerCase() !== typeVal) { + return false; + } + + if (priorityVal) { + const pVal = priorityVal.toLowerCase(); + const ip = (iss.priority || '').toLowerCase(); + if (pVal === 'high' || pVal === 'highest' || pVal === 'p1') { + if (!['highest', 'high', 'hoogste', 'hoog', 'urgent', 'p1'].includes(ip)) return false; + } else if (pVal === 'medium' || pVal === 'p2') { + if (!['medium', 'gemiddeld', 'normaal', 'p2'].includes(ip)) return false; + } else if (pVal === 'low' || pVal === 'lowest' || pVal === 'p3') { + if (!['low', 'lowest', 'laag', 'laagste', 'p3'].includes(ip)) return false; + } else if (ip !== pVal) { + return false; + } + } + + if (statusVal && (iss.status || '').toLowerCase() !== statusVal) { return false; } @@ -1010,6 +1080,7 @@ function renderTickets(tickets) { let html = ''; tickets.forEach(ticket => { + ticketStore.set(ticket.key, ticket); // Issue type styling & icon let typeBadgeClass = 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-400 border-sky-200 dark:border-sky-800/60'; let typeIcon = 'check-square'; @@ -1118,9 +1189,6 @@ function renderTickets(tickets) { const detailsClass = isExpanded ? '' : 'hidden'; const chevronRotateClass = isExpanded ? 'rotate-180' : ''; - // JSON stringified ticket data voor modalaanroep - const ticketSafeJson = JSON.stringify(ticket).replace(/"/g, '"'); - html += `
- - @@ -1289,7 +1357,12 @@ function renderTickets(tickets) { } // Modal 1: Plan in Dagplanner -function openPlanModal(ticket) { +function openPlanModal(item) { + const ticket = (typeof item === 'string') + ? (ticketStore.get(item) || (jiraData.assigned_issues || []).concat(jiraData.team_issues || []).find(t => t.key === item)) + : item; + if (!ticket) return; + document.getElementById('planIssueKey').value = ticket.key; document.getElementById('planIssueSummary').value = ticket.summary; document.getElementById('planIssueUrl').value = ticket.url; @@ -1378,7 +1451,12 @@ async function submitPlanTask(e) { } // Modal 2: Plaats op Kanban Bord -function openBoardModal(ticket) { +function openBoardModal(item) { + const ticket = (typeof item === 'string') + ? (ticketStore.get(item) || (jiraData.assigned_issues || []).concat(jiraData.team_issues || []).find(t => t.key === item)) + : item; + if (!ticket) return; + document.getElementById('boardIssueKey').value = ticket.key; document.getElementById('boardIssueSummary').value = ticket.summary; document.getElementById('boardIssueUrl').value = ticket.url; diff --git a/mail.php b/mail.php index 9577c8d..6202cfc 100644 --- a/mail.php +++ b/mail.php @@ -535,7 +535,7 @@ async function selectMail(id) { const res = await fetch(`/api/mail.php?action=get_message&id=${encodeURIComponent(id)}`); const data = await res.json(); if (data.success && data.message) { - bodyEl.innerHTML = data.message.body_html || `
${escapeHtml(data.message.body_text || found.body_preview)}
`; + renderMailBodyContent(bodyEl, data.message.body_html, data.message.body_text || found.body_preview); found.is_read = true; updateUnreadCount(); renderMailList(allMessages); @@ -543,10 +543,123 @@ async function selectMail(id) { bodyEl.innerHTML = `
${data.error || 'Kon bericht niet inladen.'}
`; } } catch(err) { - bodyEl.innerHTML = `

${escapeHtml(found.body_preview)}

`; + renderMailBodyContent(bodyEl, null, found.body_preview); } } +// Grondige HTML sanitization voor inkomende e-mails (XSS & exploit preventie) +function sanitizeEmailHtml(html) { + if (!html) return ''; + + // 1. Verwijder alle script tags en inhoud + let cleaned = html.replace(/)<[^<]*)*<\/script>/gi, ''); + + // 2. Verwijder potentieel gevaarlijke tags + cleaned = cleaned.replace(/<\/?(object|embed|applet|meta|base|form|input|button|textarea|select)\b[^>]*>/gi, ''); + + // 3. Verwijder inline JavaScript event handlers (onload, onerror, onclick, onmouseover, etc.) + cleaned = cleaned.replace(/\s+on[a-z0-9_-]+\s*=\s*(?:'[^']*'|"[^"]*"|[^\s>]+)/gi, ''); + + // 4. Neutraliseer javascript: en data:text/html pseudo-protocollen in links en afbeeldingen + cleaned = cleaned.replace(/(href|src)\s*=\s*['"]\s*(?:javascript|data:text\/html):[^'"]*['"]/gi, '$1="#"'); + + return cleaned; +} + +// Veilig renderen van e-mail HTML via een geïsoleerde sandboxed iframe +function renderMailBodyContent(container, htmlContent, plainContent) { + container.innerHTML = ''; + + if (!htmlContent) { + const pre = document.createElement('pre'); + pre.className = 'font-sans whitespace-pre-wrap text-sm text-slate-800 dark:text-slate-200 leading-relaxed p-2'; + pre.textContent = plainContent || '(Geen inhoud)'; + container.appendChild(pre); + return; + } + + const safeHtml = sanitizeEmailHtml(htmlContent); + const isDark = document.documentElement.classList.contains('dark'); + + // Sandboxed iframe: strikte isolatie (geen allow-scripts) + const iframe = document.createElement('iframe'); + iframe.id = 'mailBodyIframe'; + iframe.setAttribute('sandbox', 'allow-same-origin allow-popups'); + iframe.className = 'w-full border-0 rounded-xl transition-all'; + iframe.style.width = '100%'; + iframe.style.minHeight = '350px'; + iframe.style.border = 'none'; + iframe.style.background = isDark ? '#0c0f17' : '#ffffff'; + iframe.style.display = 'block'; + + container.appendChild(iframe); + + const docHtml = ` + + + + + + + + + ${safeHtml} + +`; + + iframe.srcdoc = docHtml; + + iframe.onload = () => { + try { + const innerDoc = iframe.contentDocument || iframe.contentWindow.document; + if (innerDoc) { + const updateHeight = () => { + const scrollH = innerDoc.documentElement.scrollHeight || innerDoc.body.scrollHeight; + iframe.style.height = Math.max(scrollH + 35, 280) + 'px'; + }; + updateHeight(); + if (window.ResizeObserver) { + const observer = new ResizeObserver(updateHeight); + observer.observe(innerDoc.body); + } + } + } catch (e) { + iframe.style.height = '600px'; + } + }; +} + function updateUnreadCount() { const unread = allMessages.filter(m => !m.is_read).length; const el = document.getElementById('countUnread'); diff --git a/planner.php b/planner.php index af1a02a..7cdf42a 100644 --- a/planner.php +++ b/planner.php @@ -230,16 +230,30 @@ for ($i = 0; $i < 7; $i++) { ]; } -// 5. Haal Jira issues op voor de snelle import widget -$jiraIssues = []; +// 5. Haal Jira dashboard data op voor de snelle import widget en wachtrijfiltering +$jiraAssignedIssues = []; +$jiraTeamIssues = []; +$jiraAllIssues = []; +$jiraQueueFilters = []; $isJiraLive = false; try { - $jiraData = jira_get_user_issues($userId, $forceRefresh); - $jiraIssues = $jiraData['issues'] ?? []; - $isJiraLive = !empty($jiraData['success']) && empty($jiraData['is_demo']); + $jiraDashboardData = jira_get_dashboard_data($userId, $forceRefresh); + $jiraAssignedIssues = $jiraDashboardData['assigned_issues'] ?? []; + $jiraTeamIssues = $jiraDashboardData['team_issues'] ?? []; + $jiraQueueFilters = $jiraDashboardData['queue_filters'] ?? []; + $isJiraLive = !empty($jiraDashboardData['success']) && empty($jiraDashboardData['is_demo']); + + $seenJiraKeys = []; + foreach (array_merge($jiraAssignedIssues, $jiraTeamIssues) as $iss) { + if (!empty($iss['key']) && !isset($seenJiraKeys[$iss['key']])) { + $seenJiraKeys[$iss['key']] = true; + $jiraAllIssues[] = $iss; + } + } } catch (Throwable $e) { - error_log("Fout bij ophalen Jira issues: " . $e->getMessage()); + error_log("Fout bij ophalen Jira issues in planner: " . $e->getMessage()); } +$jiraIssues = $jiraAllIssues; // 5b. Haal recente e-mails op voor de Outlook E-mail Widget $recentEmails = []; @@ -565,7 +579,39 @@ for ($h = $startHour; $h <= $endHour; $h++) { default => 'lg:col-span-6' }; ?> -
+
+ +
+
+
+ + +
+
+ +
+ +
+ + + + +
+ + + + + + +
+
+ + +
@@ -1017,52 +1063,84 @@ for ($h = $startHour; $h <= $endHour; $h++) {
- -
-
-
-
+ +
+
+
+
-

- Jira Wachtrij & Tickets -

- - +
+

+ Jira Wachtrijen & Tickets +

+
+ +
- - Alle Tickets - + + Jira Hub +
- -
- -

Geen openstaande tickets gevonden in Jira.

+ +
+
+ + + +
- -
- + +
+ + +
+
+ + +
+ +
+ +

Geen openstaande tickets gevonden in Jira.

+
+ +
- + - +
- • + •
-
- + +
+ + +
@@ -1424,7 +1508,8 @@ for ($h = $startHour; $h <= $endHour; $h++) {
-
+
+
/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// --- Floating Toast Notificatie --- +function showWidgetToast(htmlMessage, type = 'success', duration = 3500) { + let toast = document.getElementById('widgetGlobalToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'widgetGlobalToast'; + toast.className = 'fixed bottom-6 right-6 z-50 transform translate-y-20 opacity-0 transition-all duration-300 pointer-events-auto flex items-center space-x-2.5 px-4 py-2.5 rounded-2xl bg-slate-900/95 dark:bg-white text-white dark:text-slate-900 text-xs font-semibold shadow-2xl border border-slate-700/60 dark:border-slate-200/80'; + document.body.appendChild(toast); + } + + const icon = type === 'success' ? 'check-circle-2' : 'info'; + toast.innerHTML = ` +
+ +
+
${htmlMessage}
+ `; + + if (typeof lucide !== 'undefined' && lucide.createIcons) { + lucide.createIcons(); + } + + toast.classList.remove('translate-y-20', 'opacity-0'); + toast.classList.add('translate-y-0', 'opacity-100'); + + if (window._widgetToastTimeout) clearTimeout(window._widgetToastTimeout); + window._widgetToastTimeout = setTimeout(() => { + toast.classList.add('translate-y-20', 'opacity-0'); + toast.classList.remove('translate-y-0', 'opacity-100'); + }, duration); +} + +// --- Achtergrond Auto-Save voor Widget Layout --- +async function persistWidgetLayoutAuto() { + try { + const payload = focizWidgets.map((w, idx) => ({ + id: w.id, + enabled: !!w.enabled, + col_span: parseInt(w.col_span, 10) || 6, + order: idx + 1 + })); + + const res = await fetch('/api/planner.php?action=save_widget_layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ layout: payload }) + }); + const data = await res.json(); + return data.success; + } catch (e) { + console.warn('Fout bij automatisch opslaan widget layout:', e); + return false; + } +} + +// --- In-Widget Inline Breedte Aanpassen (1/3, 1/2, 2/3, 100%) --- +function setWidgetSpan(wId, span) { + span = parseInt(span, 10); + const wrapper = document.getElementById(`widgetWrapper_${wId}`) || document.querySelector(`.fociz-widget-wrapper[data-widget-id="${wId}"]`); + if (!wrapper) return; + + // Verwijder alle mogelijke col-span klassen + const spanClasses = ['lg:col-span-12', 'lg:col-span-8', 'lg:col-span-7', 'lg:col-span-6', 'lg:col-span-5', 'lg:col-span-4']; + spanClasses.forEach(c => wrapper.classList.remove(c)); + + // Voeg nieuwe klasse toe + wrapper.classList.add(`lg:col-span-${span}`); + wrapper.setAttribute('data-col-span', span); + + // Knoppen styling bijwerken binnen deze widget + const buttons = wrapper.querySelectorAll('.widget-span-btn'); + buttons.forEach(btn => { + const bSpan = parseInt(btn.getAttribute('data-span'), 10); + if (bSpan === span) { + btn.className = "widget-span-btn px-1.5 py-0.5 rounded text-[10px] font-bold transition bg-indigo-600 text-white shadow-2xs"; + } else { + btn.className = "widget-span-btn px-1.5 py-0.5 rounded text-[10px] font-bold transition text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"; + } + }); + + // Update in-memory array + const item = focizWidgets.find(x => x.id === wId); + if (item) { + item.col_span = span; + } + + // Trigger resize voor SVG en diagrammen + window.dispatchEvent(new Event('resize')); + + // Automatisch opslaan naar database + persistWidgetLayoutAuto(); + + const spanDescriptions = { + 4: '1/3 schermbreedte (33%)', + 6: '1/2 schermbreedte (50%)', + 8: '2/3 schermbreedte (66%)', + 12: 'volledige breedte (100%)' + }; + showWidgetToast(`Formaat aangepast naar ${spanDescriptions[span] || span + ' kolommen'}`, 'success'); +} + +// --- Widget Inklappen / Uitklappen --- +function toggleWidgetCollapse(wId) { + const body = document.getElementById(`widget_body_${wId}`); + const icon = document.getElementById(`collapse_icon_${wId}`); + if (!body) return; + + const isNowHidden = body.classList.toggle('hidden'); + if (icon) { + if (isNowHidden) { + icon.classList.add('rotate-180'); + } else { + icon.classList.remove('rotate-180'); + } + } + try { + localStorage.setItem(`fociz_widget_collapsed_${wId}`, isNowHidden ? '1' : '0'); + } catch (e) {} +} + +// --- Widget Verbergen van Scherm met Undo Toast --- +function hideWidgetOnScreen(wId) { + const wrapper = document.getElementById(`widgetWrapper_${wId}`) || document.querySelector(`.fociz-widget-wrapper[data-widget-id="${wId}"]`); + const item = focizWidgets.find(x => x.id === wId); + const title = item ? item.title : 'Widget'; + + if (wrapper) { + wrapper.style.transition = 'all 0.25s ease'; + wrapper.style.opacity = '0'; + wrapper.style.transform = 'scale(0.95)'; + setTimeout(() => { + wrapper.remove(); + }, 250); + } + + if (item) { + item.enabled = false; + } + + persistWidgetLayoutAuto(); + showWidgetToast(`Widget "${title}" verborgen `, 'info', 6000); +} + +function undoHideWidget(wId) { + const item = focizWidgets.find(x => x.id === wId); + if (item) { + item.enabled = true; + } + persistWidgetLayoutAuto(); + showWidgetToast('Widget herstellen...', 'success', 2000); + setTimeout(() => { + window.location.reload(); + }, 400); +} + +// --- Direct On-Screen Drag & Drop Engine (HTML5 Native) --- +let draggedWidgetEl = null; +let dropPlaceholderEl = null; + +function initPlannerWidgets() { + const grid = document.getElementById('focizWidgetGrid'); + if (!grid) return; + + // Herstel eventuele ingeklapte widgets vanuit localStorage + document.querySelectorAll('.fociz-widget-wrapper').forEach(wrapper => { + const wId = wrapper.getAttribute('data-widget-id'); + try { + if (localStorage.getItem(`fociz_widget_collapsed_${wId}`) === '1') { + const body = document.getElementById(`widget_body_${wId}`); + const icon = document.getElementById(`collapse_icon_${wId}`); + if (body) body.classList.add('hidden'); + if (icon) icon.classList.add('rotate-180'); + } + } catch(e) {} + }); + + if (!dropPlaceholderEl) { + dropPlaceholderEl = document.createElement('div'); + dropPlaceholderEl.id = 'widgetDropPlaceholder'; + dropPlaceholderEl.className = 'col-span-1 min-w-0 border-2 border-dashed border-indigo-400/80 dark:border-indigo-500/80 bg-indigo-50/50 dark:bg-indigo-950/40 rounded-2xl flex items-center justify-center p-8 text-xs font-bold text-indigo-600 dark:text-indigo-400 pointer-events-none transition-all duration-150'; + dropPlaceholderEl.innerHTML = '
Laat los om hier te plaatsen
'; + } + + // Luister naar drag handles + document.querySelectorAll('.widget-drag-handle').forEach(handle => { + handle.setAttribute('draggable', 'true'); + + handle.ondragstart = (e) => { + const wrapper = handle.closest('.fociz-widget-wrapper'); + if (!wrapper) return; + draggedWidgetEl = wrapper; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', wrapper.getAttribute('data-widget-id')); + + // Match column span van dragged widget + const curSpan = wrapper.getAttribute('data-col-span') || '6'; + dropPlaceholderEl.className = `lg:col-span-${curSpan} col-span-1 min-w-0 border-2 border-dashed border-indigo-400/80 dark:border-indigo-500/80 bg-indigo-50/60 dark:bg-indigo-950/50 rounded-2xl flex items-center justify-center p-8 text-xs font-bold text-indigo-600 dark:text-indigo-400 pointer-events-none transition-all`; + + setTimeout(() => { + if (draggedWidgetEl) { + draggedWidgetEl.classList.add('opacity-40', 'scale-[0.98]', 'ring-2', 'ring-indigo-500/50'); + } + }, 0); + }; + + handle.ondragend = (e) => { + if (draggedWidgetEl) { + draggedWidgetEl.classList.remove('opacity-40', 'scale-[0.98]', 'ring-2', 'ring-indigo-500/50'); + } + if (dropPlaceholderEl && dropPlaceholderEl.parentNode) { + dropPlaceholderEl.remove(); + } + draggedWidgetEl = null; + }; + }); + + // Dragover op grid + grid.ondragover = (e) => { + if (!draggedWidgetEl) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const target = e.target.closest('.fociz-widget-wrapper'); + if (target && target !== draggedWidgetEl && target !== dropPlaceholderEl) { + const rect = target.getBoundingClientRect(); + const isAfter = (e.clientY - rect.top) > (rect.height / 2) || (e.clientX - rect.left) > (rect.width / 2); + if (isAfter) { + if (target.nextSibling !== dropPlaceholderEl) { + target.after(dropPlaceholderEl); + } + } else { + if (target.previousSibling !== dropPlaceholderEl) { + target.before(dropPlaceholderEl); + } + } + } + }; + + grid.ondrop = (e) => { + if (!draggedWidgetEl) return; + e.preventDefault(); + + if (dropPlaceholderEl && dropPlaceholderEl.parentNode) { + dropPlaceholderEl.replaceWith(draggedWidgetEl); + } + + if (draggedWidgetEl) { + draggedWidgetEl.classList.remove('opacity-40', 'scale-[0.98]', 'ring-2', 'ring-indigo-500/50'); + } + + // Volgorde bijwerken + const currentWrappers = Array.from(grid.querySelectorAll('.fociz-widget-wrapper')); + currentWrappers.forEach((el, index) => { + const wId = el.getAttribute('data-widget-id'); + const item = focizWidgets.find(x => x.id === wId); + if (item) { + item.order = index + 1; + } + }); + + persistWidgetLayoutAuto(); + showWidgetToast('Widget verplaatst & indeling opgeslagen', 'success'); + + draggedWidgetEl = null; + }; +} + +// --- Jira Queue & Tickets Widget Engine (v2.0) --- +const plannerJiraAssigned = ; +const plannerJiraAll = ; +const plannerTicketStore = new Map(); +plannerJiraAll.forEach(iss => { + if (iss && iss.key) plannerTicketStore.set(iss.key, iss); +}); + +let activePlannerJiraQueue = ''; // '': Alle, 'my': Mijn, 'DX': DX Queue, 'SD': SD Queue + +function isIssueInQueue(iss, qCode) { + if (!qCode) return true; + const code = qCode.toUpperCase(); + const ol = (iss.onderwerp_logging || '').toLowerCase().trim(); + const sum = (iss.summary || '').toLowerCase(); + + const dxTerms = ['koppeling', 'sso', 'endpoint', 'rest', 'datadump', 'uwv', 'migratie', 'api', 'webhook', 'integrat']; + const matchesDx = dxTerms.some(t => ol.includes(t) || sum.includes(t)); + + if (code === 'DX') return matchesDx; + if (code === 'SD') return !matchesDx; + return true; +} + +function setPlannerJiraQueue(queueCode) { + activePlannerJiraQueue = queueCode; + + // Tab styling bijwerken + ['all', 'my', 'DX', 'SD'].forEach(tab => { + const btn = document.getElementById(`pjPill_${tab}`); + if (!btn) return; + const isActive = (tab === 'all' && !queueCode) || (tab === queueCode); + if (isActive) { + btn.className = "planner-jira-tab px-2.5 py-1 rounded-lg text-[11px] font-bold transition bg-indigo-600 text-white shadow-2xs flex-shrink-0 cursor-pointer"; + } else { + btn.className = "planner-jira-tab px-2.5 py-1 rounded-lg text-[11px] font-bold transition bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 flex-shrink-0 cursor-pointer"; + } + }); + + filterPlannerJira(); +} + +function filterPlannerJira() { + const searchVal = (document.getElementById('plannerJiraSearch')?.value || '').toLowerCase().trim(); + const listContainer = document.getElementById('plannerJiraListContainer'); + const emptyState = document.getElementById('plannerJiraEmptyState'); + if (!listContainer) return; + + // Tellers bijwerken + let dxCount = 0; + let sdCount = 0; + plannerJiraAll.forEach(iss => { + if (isIssueInQueue(iss, 'DX')) dxCount++; + else sdCount++; + }); + const elAll = document.getElementById('pjCount_all'); + const elMy = document.getElementById('pjCount_my'); + const elDx = document.getElementById('pjCount_DX'); + const elSd = document.getElementById('pjCount_SD'); + if (elAll) elAll.innerText = plannerJiraAll.length; + if (elMy) elMy.innerText = plannerJiraAssigned.length; + if (elDx) elDx.innerText = dxCount; + if (elSd) elSd.innerText = sdCount; + + // Filteren + let filtered = plannerJiraAll.filter(iss => { + if (activePlannerJiraQueue === 'my') { + const isAssigned = plannerJiraAssigned.some(a => a.key === iss.key); + if (!isAssigned) return false; + } else if (activePlannerJiraQueue === 'DX') { + if (!isIssueInQueue(iss, 'DX')) return false; + } else if (activePlannerJiraQueue === 'SD') { + if (!isIssueInQueue(iss, 'SD')) return false; + } + + if (searchVal) { + const k = (iss.key || '').toLowerCase(); + const s = (iss.summary || '').toLowerCase(); + const p = (iss.project || '').toLowerCase(); + const ol = (iss.onderwerp_logging || '').toLowerCase(); + if (!k.includes(searchVal) && !s.includes(searchVal) && !p.includes(searchVal) && !ol.includes(searchVal)) { + return false; + } + } + return true; + }); + + // Badge totale resultaten bijwerken + const badge = document.getElementById('plannerJiraCountBadge'); + if (badge) badge.innerText = filtered.length; + + if (filtered.length === 0) { + listContainer.innerHTML = ''; + if (emptyState) emptyState.classList.remove('hidden'); + return; + } + + if (emptyState) emptyState.classList.add('hidden'); + + listContainer.innerHTML = filtered.slice(0, 15).map(issue => { + const keyEsc = escapeHtml(issue.key || ''); + const summaryEsc = escapeHtml(issue.summary || ''); + const statusEsc = escapeHtml(issue.status || 'To Do'); + const priorityEsc = escapeHtml(issue.priority || 'Medium'); + const isDx = isIssueInQueue(issue, 'DX'); + const queuePill = isDx + ? 'DX' + : 'SD'; + + return ` +
+
+
+ + ${keyEsc} + + ${queuePill} + + ${summaryEsc} + +
+ + ${statusEsc} • ${priorityEsc} + +
+ + +
+ `; + }).join(''); + + if (typeof lucide !== 'undefined' && lucide.createIcons) { + lucide.createIcons(); + } +} + +async function quickImportJiraByKey(btn, issueKey) { + const issue = plannerTicketStore.get(issueKey); + if (!issue) return; + return quickImportJira(btn, issue.summary || issueKey, issue.key, issue.url || ''); +} + // --- Direct E-mail Inplannen als Dagtaak --- function openPlanEmailDirect(mailId, subject, sender, webLink) { document.getElementById('planEmailId').value = mailId; @@ -2045,6 +2557,10 @@ async function switchPlannerDate(newDate, event = null, pushState = true) { if (typeof lucide !== 'undefined' && lucide.createIcons) { lucide.createIcons(); } + initPlannerWidgets(); + if (document.getElementById('plannerJiraListContainer')) { + filterPlannerJira(); + } // Garandeer dat het scherm exact op dezelfde scrollhoogte blijft window.scrollTo({ @@ -2679,6 +3195,10 @@ function triggerManualSync() { // Initialisatie bij pagina-inladen document.addEventListener('DOMContentLoaded', () => { restoreTimerStateIfAny(); + initPlannerWidgets(); + if (document.getElementById('plannerJiraListContainer')) { + filterPlannerJira(); + } if (isOutlookConnected) { if (syncIntervalSeconds > 0) {