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.
This commit is contained in:
2026-09-10 15:44:37 +02:00
parent 72ad1fb70e
commit e161c72fc4
6 changed files with 867 additions and 100 deletions
+13 -1
View File
@@ -4,16 +4,28 @@
* Ontvangt webhook events van Atlassian Jira Cloud (issue_created, issue_updated, etc.) * Ontvangt webhook events van Atlassian Jira Cloud (issue_created, issue_updated, etc.)
*/ */
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../../config/database.php';
header('Content-Type: application/json'); 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'); $rawPayload = file_get_contents('php://input');
$data = json_decode($rawPayload, true); $data = json_decode($rawPayload, true);
if (!$data) { if (!$data) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Invalid JSON payload']); echo json_encode(['error' => 'Ongeldige JSON payload']);
exit; exit;
} }
+3
View File
@@ -17,6 +17,9 @@ if (!headers_sent()) {
header('X-XSS-Protection: 1; mode=block'); header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin'); header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); 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'); header_remove('X-Powered-By');
} }
@ini_set('expose_php', 'off'); @ini_set('expose_php', 'off');
+50 -9
View File
@@ -221,6 +221,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
$authHeader = jira_get_auth_header($email, $apiToken); $authHeader = jira_get_auth_header($email, $apiToken);
$fieldsList = [ $fieldsList = [
'*navigable',
'summary', 'summary',
'status', 'status',
'priority', 'priority',
@@ -233,6 +234,8 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
'updated', 'updated',
'created', 'created',
'description', 'description',
'labels',
'components',
]; ];
// 1. Moderne Atlassian Jira Cloud endpoint (/rest/api/3/search/jql) conform CHANGE-2046 via POST // 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'] ?? ''); $descriptionText = jira_adf_to_text($fields['description'] ?? '');
// Detectie van custom field "Onderwerp logging" // Detectie van custom field "Onderwerp logging" of routeringsveld
$onderwerpLogging = ''; $onderwerpLogging = '';
$knownTopics = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie', 'incident', 'vraag', 'algemeen'];
foreach ($fields as $fKey => $fVal) { foreach ($fields as $fKey => $fVal) {
if (str_starts_with($fKey, 'customfield_')) { if (str_starts_with($fKey, 'customfield_')) {
if (is_array($fVal) && isset($fVal['value']) && is_string($fVal['value'])) { if (is_array($fVal)) {
$valStr = trim($fVal['value']); if (isset($fVal['value']) && is_string($fVal['value'])) {
$known = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie', 'incident', 'vraag', 'algemeen']; $valStr = trim($fVal['value']);
if (in_array(strtolower($valStr), $known)) { if (!empty($valStr)) {
$onderwerpLogging = $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)) { } elseif (is_string($fVal) && !empty($fVal)) {
$valStr = trim($fVal); $valStr = trim($fVal);
$known = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie']; foreach ($knownTopics as $kt) {
if (in_array(strtolower($valStr), $known)) { if (str_contains(strtolower($valStr), $kt)) {
$onderwerpLogging = $valStr; $onderwerpLogging = $valStr;
break 2;
}
} }
} }
} }
} }
if (empty($onderwerpLogging) && !empty($fields['onderwerp_logging'])) { if (empty($onderwerpLogging) && !empty($fields['onderwerp_logging'])) {
$onderwerpLogging = is_string($fields['onderwerp_logging']) ? $fields['onderwerp_logging'] : ($fields['onderwerp_logging']['value'] ?? ''); $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[] = [ $issues[] = [
'key' => $key, 'key' => $key,
'summary' => $summary, 'summary' => $summary,
+132 -54
View File
@@ -320,6 +320,17 @@ $boardsJson = json_encode($boards, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT
<span id="queueCount_SD" class="px-1.5 py-0.5 rounded-full text-[10px] bg-sky-100 dark:bg-sky-950/60 text-sky-800 dark:text-sky-300 font-bold">0</span> <span id="queueCount_SD" class="px-1.5 py-0.5 rounded-full text-[10px] bg-sky-100 dark:bg-sky-950/60 text-sky-800 dark:text-sky-300 font-bold">0</span>
</button> </button>
<?php foreach ($queueFilters as $qf):
$code = $qf['filter_code'] ?? '';
if ($code === 'DX' || $code === 'SD' || empty($code)) continue;
?>
<button type="button" onclick="selectQueueFilter('<?= htmlspecialchars($code) ?>')" data-queue="<?= htmlspecialchars($code) ?>" class="queue-pill flex items-center space-x-1.5 px-3 py-1.5 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">
<i data-lucide="filter" class="w-3.5 h-3.5 text-indigo-500"></i>
<span><?= htmlspecialchars($qf['filter_name'] ?? $code) ?></span>
<span id="queueCount_<?= htmlspecialchars($code) ?>" class="px-1.5 py-0.5 rounded-full text-[10px] bg-indigo-100 dark:bg-indigo-950/60 text-indigo-800 dark:text-indigo-300 font-bold">0</span>
</button>
<?php endforeach; ?>
<?php if ($isAdmin): ?> <?php if ($isAdmin): ?>
<a href="/admin.php#jira-wachtrijen" title="Wachtrijfilters beheren in Admin" class="p-1.5 rounded-xl text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition cursor-pointer flex-shrink-0"> <a href="/admin.php#jira-wachtrijen" title="Wachtrijfilters beheren in Admin" class="p-1.5 rounded-xl text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition cursor-pointer flex-shrink-0">
<i data-lucide="settings" class="w-3.5 h-3.5"></i> <i data-lucide="settings" class="w-3.5 h-3.5"></i>
@@ -677,6 +688,7 @@ let currentProject = '';
let currentQueue = jiraData.default_queue_filter || ''; let currentQueue = jiraData.default_queue_filter || '';
let activeUrgentOnly = false; let activeUrgentOnly = false;
let expandedKeys = new Set(); let expandedKeys = new Set();
const ticketStore = new Map();
// Bereken statussen en KPI statistieken // Bereken statussen en KPI statistieken
function calculateKpis() { function calculateKpis() {
@@ -758,9 +770,10 @@ function onProjectSelectChange(proj) {
function updateProjectPills() { function updateProjectPills() {
const pills = document.querySelectorAll('.project-pill'); const pills = document.querySelectorAll('.project-pill');
const curNorm = (currentProject || '').trim().toLowerCase();
pills.forEach(p => { pills.forEach(p => {
const pKey = p.getAttribute('data-project') || ''; const pKey = (p.getAttribute('data-project') || '').trim().toLowerCase();
if (pKey === currentProject) { 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"; 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 { } 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"; 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 // Wachtrijfilter selectie
function selectQueueFilter(code) { function selectQueueFilter(code) {
currentQueue = code; currentQueue = code;
@@ -796,18 +843,34 @@ function updateQueueCounts() {
let currentList = all; let currentList = all;
if (currentTab === 'assigned') currentList = assigned; if (currentTab === 'assigned') currentList = assigned;
else if (currentTab === 'team') currentList = team; else if (currentTab === 'team') currentList = team;
else if (currentTab === 'in_progress') {
if (currentProject) { currentList = all.filter(iss => {
currentList = currentList.filter(iss => iss.project === currentProject); 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 dxCount = 0;
let sdCount = 0; let sdCount = 0;
currentList.forEach(iss => { currentList.forEach(iss => {
const ol = (iss.onderwerp_logging || '').toLowerCase().trim(); if (isIssueInQueue(iss, 'DX')) {
if (dxValues.includes(ol)) {
dxCount++; dxCount++;
} else { } else {
sdCount++; sdCount++;
@@ -821,6 +884,14 @@ function updateQueueCounts() {
if (elAll) elAll.innerText = currentList.length; if (elAll) elAll.innerText = currentList.length;
if (elDx) elDx.innerText = dxCount; if (elDx) elDx.innerText = dxCount;
if (elSd) elSd.innerText = sdCount; 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 // Snelkoppeling KPI-kaart Urgent
@@ -869,9 +940,9 @@ function resetAllFilters() {
function applyFilters() { function applyFilters() {
const searchVal = (document.getElementById('jiraSearchInput')?.value || '').trim().toLowerCase(); const searchVal = (document.getElementById('jiraSearchInput')?.value || '').trim().toLowerCase();
const projectVal = currentProject || (document.getElementById('filterProject')?.value || ''); const projectVal = currentProject || (document.getElementById('filterProject')?.value || '');
const typeVal = document.getElementById('filterType')?.value || ''; const typeVal = (document.getElementById('filterType')?.value || '').trim().toLowerCase();
const priorityVal = document.getElementById('filterPriority')?.value || ''; const priorityVal = (document.getElementById('filterPriority')?.value || '').trim();
const statusVal = document.getElementById('filterStatus')?.value || ''; const statusVal = (document.getElementById('filterStatus')?.value || '').trim().toLowerCase();
const clearBtn = document.getElementById('clearSearchBtn'); const clearBtn = document.getElementById('clearSearchBtn');
if (clearBtn) { 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 // Pas zoek-, project-, wachtrij-, type-, prioriteit- en statusfilters toe
const filtered = sourceList.filter(iss => { const filtered = sourceList.filter(iss => {
if (searchVal) { if (searchVal) {
const keyMatch = (iss.key || '').toLowerCase().includes(searchVal); const keyMatch = (iss.key || '').toLowerCase().includes(searchVal);
const sumMatch = (iss.summary || '').toLowerCase().includes(searchVal); const sumMatch = (iss.summary || '').toLowerCase().includes(searchVal);
const prjMatch = (iss.project || '').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 descMatch = (iss.description || '').toLowerCase().includes(searchVal);
const repMatch = (iss.reporter_name || '').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); 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) { if (projectVal) {
return false; const pNorm = projectVal.trim().toLowerCase();
} const pName = (iss.project || '').trim().toLowerCase();
const pKey = (iss.project_key || '').trim().toLowerCase();
if (currentQueue) { if (pName !== pNorm && pKey !== pNorm) {
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) {
return false; 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; return false;
} }
@@ -1010,6 +1080,7 @@ function renderTickets(tickets) {
let html = ''; let html = '';
tickets.forEach(ticket => { tickets.forEach(ticket => {
ticketStore.set(ticket.key, ticket);
// Issue type styling & icon // 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 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'; let typeIcon = 'check-square';
@@ -1118,9 +1189,6 @@ function renderTickets(tickets) {
const detailsClass = isExpanded ? '' : 'hidden'; const detailsClass = isExpanded ? '' : 'hidden';
const chevronRotateClass = isExpanded ? 'rotate-180' : ''; const chevronRotateClass = isExpanded ? 'rotate-180' : '';
// JSON stringified ticket data voor modalaanroep
const ticketSafeJson = JSON.stringify(ticket).replace(/"/g, '&quot;');
html += ` html += `
<div id="ticketCard_${escapeHtml(ticket.key)}" class="fociz-card p-4 sm:p-5 transition hover:border-indigo-500/40 dark:hover:border-indigo-500/40"> <div id="ticketCard_${escapeHtml(ticket.key)}" class="fociz-card p-4 sm:p-5 transition hover:border-indigo-500/40 dark:hover:border-indigo-500/40">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-3"> <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
@@ -1187,12 +1255,12 @@ function renderTickets(tickets) {
<i data-lucide="chevron-down" id="chevron_${escapeHtml(ticket.key)}" class="w-3.5 h-3.5 transition-transform duration-200 ${chevronRotateClass}"></i> <i data-lucide="chevron-down" id="chevron_${escapeHtml(ticket.key)}" class="w-3.5 h-3.5 transition-transform duration-200 ${chevronRotateClass}"></i>
</button> </button>
<button onclick='openPlanModal(${ticketSafeJson})' class="flex items-center space-x-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:hover:bg-indigo-900/60 dark:text-indigo-300 border border-indigo-200/80 dark:border-indigo-800/80 transition cursor-pointer shadow-2xs"> <button onclick="openPlanModal('${escapeHtml(ticket.key)}')" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950/50 dark:hover:bg-indigo-900/60 dark:text-indigo-300 border border-indigo-200/80 dark:border-indigo-800/80 transition cursor-pointer shadow-2xs">
<i data-lucide="calendar" class="w-3.5 h-3.5"></i> <i data-lucide="calendar" class="w-3.5 h-3.5"></i>
<span>Plan in Dagplanner</span> <span>Plan in Dagplanner</span>
</button> </button>
<button onclick='openBoardModal(${ticketSafeJson})' class="flex items-center space-x-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-emerald-50 hover:bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:hover:bg-emerald-900/60 dark:text-emerald-300 border border-emerald-200/80 dark:border-emerald-800/80 transition cursor-pointer shadow-2xs"> <button onclick="openBoardModal('${escapeHtml(ticket.key)}')" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl text-xs font-bold bg-emerald-50 hover:bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:hover:bg-indigo-900/60 dark:text-emerald-300 border border-emerald-200/80 dark:border-emerald-800/80 transition cursor-pointer shadow-2xs">
<i data-lucide="kanban" class="w-3.5 h-3.5"></i> <i data-lucide="kanban" class="w-3.5 h-3.5"></i>
<span>Naar Bord</span> <span>Naar Bord</span>
</button> </button>
@@ -1263,11 +1331,11 @@ function renderTickets(tickets) {
</a> </a>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button type="button" onclick='openPlanModal(${ticketSafeJson})' class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-indigo-600 hover:bg-indigo-700 text-white transition cursor-pointer shadow-xs"> <button type="button" onclick='openPlanModal("${escapeHtml(ticket.key)}")' class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-indigo-600 hover:bg-indigo-700 text-white transition cursor-pointer shadow-xs">
<i data-lucide="calendar" class="w-3 h-3"></i> <i data-lucide="calendar" class="w-3 h-3"></i>
<span>Inplannen in Dagplanner</span> <span>Inplannen in Dagplanner</span>
</button> </button>
<button type="button" onclick='openBoardModal(${ticketSafeJson})' class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-emerald-600 hover:bg-emerald-700 text-white transition cursor-pointer shadow-xs"> <button type="button" onclick='openBoardModal("${escapeHtml(ticket.key)}")' class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-emerald-600 hover:bg-emerald-700 text-white transition cursor-pointer shadow-xs">
<i data-lucide="kanban" class="w-3 h-3"></i> <i data-lucide="kanban" class="w-3 h-3"></i>
<span>Naar Kanban Bord</span> <span>Naar Kanban Bord</span>
</button> </button>
@@ -1289,7 +1357,12 @@ function renderTickets(tickets) {
} }
// Modal 1: Plan in Dagplanner // 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('planIssueKey').value = ticket.key;
document.getElementById('planIssueSummary').value = ticket.summary; document.getElementById('planIssueSummary').value = ticket.summary;
document.getElementById('planIssueUrl').value = ticket.url; document.getElementById('planIssueUrl').value = ticket.url;
@@ -1378,7 +1451,12 @@ async function submitPlanTask(e) {
} }
// Modal 2: Plaats op Kanban Bord // 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('boardIssueKey').value = ticket.key;
document.getElementById('boardIssueSummary').value = ticket.summary; document.getElementById('boardIssueSummary').value = ticket.summary;
document.getElementById('boardIssueUrl').value = ticket.url; document.getElementById('boardIssueUrl').value = ticket.url;
+115 -2
View File
@@ -535,7 +535,7 @@ async function selectMail(id) {
const res = await fetch(`/api/mail.php?action=get_message&id=${encodeURIComponent(id)}`); const res = await fetch(`/api/mail.php?action=get_message&id=${encodeURIComponent(id)}`);
const data = await res.json(); const data = await res.json();
if (data.success && data.message) { if (data.success && data.message) {
bodyEl.innerHTML = data.message.body_html || `<pre class="font-sans whitespace-pre-wrap">${escapeHtml(data.message.body_text || found.body_preview)}</pre>`; renderMailBodyContent(bodyEl, data.message.body_html, data.message.body_text || found.body_preview);
found.is_read = true; found.is_read = true;
updateUnreadCount(); updateUnreadCount();
renderMailList(allMessages); renderMailList(allMessages);
@@ -543,10 +543,123 @@ async function selectMail(id) {
bodyEl.innerHTML = `<div class="p-4 rounded-xl bg-red-50 text-red-700 text-xs">${data.error || 'Kon bericht niet inladen.'}</div>`; bodyEl.innerHTML = `<div class="p-4 rounded-xl bg-red-50 text-red-700 text-xs">${data.error || 'Kon bericht niet inladen.'}</div>`;
} }
} catch(err) { } catch(err) {
bodyEl.innerHTML = `<p class="whitespace-pre-wrap">${escapeHtml(found.body_preview)}</p>`; 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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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 = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base target="_blank">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
html {
color-scheme: ${isDark ? 'dark' : 'light'};
}
body {
margin: 0;
padding: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: ${isDark ? '#f1f5f9' : '#0f172a'};
background-color: ${isDark ? '#0c0f17' : '#ffffff'};
word-break: break-word;
}
img {
max-width: 100% !important;
height: auto !important;
}
table {
max-width: 100% !important;
}
pre, code {
white-space: pre-wrap;
word-break: break-all;
}
a {
color: #4f46e5;
}
</style>
</head>
<body>
${safeHtml}
</body>
</html>`;
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() { function updateUnreadCount() {
const unread = allMessages.filter(m => !m.is_read).length; const unread = allMessages.filter(m => !m.is_read).length;
const el = document.getElementById('countUnread'); const el = document.getElementById('countUnread');
+554 -34
View File
@@ -230,16 +230,30 @@ for ($i = 0; $i < 7; $i++) {
]; ];
} }
// 5. Haal Jira issues op voor de snelle import widget // 5. Haal Jira dashboard data op voor de snelle import widget en wachtrijfiltering
$jiraIssues = []; $jiraAssignedIssues = [];
$jiraTeamIssues = [];
$jiraAllIssues = [];
$jiraQueueFilters = [];
$isJiraLive = false; $isJiraLive = false;
try { try {
$jiraData = jira_get_user_issues($userId, $forceRefresh); $jiraDashboardData = jira_get_dashboard_data($userId, $forceRefresh);
$jiraIssues = $jiraData['issues'] ?? []; $jiraAssignedIssues = $jiraDashboardData['assigned_issues'] ?? [];
$isJiraLive = !empty($jiraData['success']) && empty($jiraData['is_demo']); $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) { } 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 // 5b. Haal recente e-mails op voor de Outlook E-mail Widget
$recentEmails = []; $recentEmails = [];
@@ -565,7 +579,39 @@ for ($h = $startHour; $h <= $endHour; $h++) {
default => 'lg:col-span-6' default => 'lg:col-span-6'
}; };
?> ?>
<div class="<?= $colClass ?> col-span-1 min-w-0 transition-all" data-widget-id="<?= htmlspecialchars($wId) ?>"> <div class="<?= $colClass ?> col-span-1 min-w-0 transition-all fociz-widget-wrapper group/widget" data-widget-id="<?= htmlspecialchars($wId) ?>" data-col-span="<?= $span ?>" id="widgetWrapper_<?= htmlspecialchars($wId) ?>">
<!-- Standardized In-Widget Control Toolbar -->
<div class="widget-toolbar flex items-center justify-between px-3 py-1.5 mb-2 rounded-xl bg-slate-100/80 dark:bg-[#0c0f17]/80 border border-slate-200/70 dark:border-slate-800/70 backdrop-blur-xs select-none shadow-2xs">
<div class="flex items-center space-x-2 min-w-0">
<div class="widget-drag-handle flex items-center space-x-1.5 px-2 py-1 rounded-lg cursor-grab active:cursor-grabbing text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-200/60 dark:hover:bg-slate-800/80 transition" title="Sleep om te verplaatsen" draggable="true" data-widget-id="<?= htmlspecialchars($wId) ?>">
<i data-lucide="grip-vertical" class="w-3.5 h-3.5 flex-shrink-0"></i>
<span class="text-xs font-bold text-slate-700 dark:text-slate-200 truncate max-w-[130px] sm:max-w-[200px]"><?= htmlspecialchars($widget['title']) ?></span>
</div>
</div>
<div class="flex items-center space-x-1 sm:space-x-1.5 flex-shrink-0">
<!-- Breedte Knoppen (Inline Resizing) -->
<div class="widget-size-picker inline-flex items-center rounded-lg bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 p-0.5 shadow-2xs">
<button type="button" onclick="setWidgetSpan('<?= $wId ?>', 4)" class="widget-span-btn px-1.5 py-0.5 rounded text-[10px] font-bold transition <?= $span == 4 ? 'bg-indigo-600 text-white shadow-2xs' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200' ?>" data-span="4" title="1/3 breedte (4 kolommen)">1/3</button>
<button type="button" onclick="setWidgetSpan('<?= $wId ?>', 6)" class="widget-span-btn px-1.5 py-0.5 rounded text-[10px] font-bold transition <?= $span == 6 ? 'bg-indigo-600 text-white shadow-2xs' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200' ?>" data-span="6" title="1/2 breedte (6 kolommen)">1/2</button>
<button type="button" onclick="setWidgetSpan('<?= $wId ?>', 8)" class="widget-span-btn px-1.5 py-0.5 rounded text-[10px] font-bold transition <?= $span == 8 ? 'bg-indigo-600 text-white shadow-2xs' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200' ?>" data-span="8" title="2/3 breedte (8 kolommen)">2/3</button>
<button type="button" onclick="setWidgetSpan('<?= $wId ?>', 12)" class="widget-span-btn px-1.5 py-0.5 rounded text-[10px] font-bold transition <?= $span == 12 ? 'bg-indigo-600 text-white shadow-2xs' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200' ?>" data-span="12" title="Volledige breedte (12 kolommen)">Vol</button>
</div>
<!-- Collapse / Expand Toggle -->
<button type="button" onclick="toggleWidgetCollapse('<?= $wId ?>')" class="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800/60 transition cursor-pointer" title="Inklappen / Uitklappen">
<i data-lucide="chevron-up" class="w-3.5 h-3.5 widget-collapse-icon transition-transform duration-200" id="collapse_icon_<?= htmlspecialchars($wId) ?>"></i>
</button>
<!-- Verbergen -->
<button type="button" onclick="hideWidgetOnScreen('<?= $wId ?>')" class="p-1 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer" title="Verberg widget van dashboard">
<i data-lucide="eye-off" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
<!-- Widget Content Body -->
<div class="widget-content-body transition-all duration-200" id="widget_body_<?= htmlspecialchars($wId) ?>">
<?php if ($wId === 'daily_goal'): ?> <?php if ($wId === 'daily_goal'): ?>
<!-- Widget 1: Dagfocus & Hoofddoel --> <!-- Widget 1: Dagfocus & Hoofddoel -->
@@ -1017,52 +1063,84 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div> </div>
<?php elseif ($wId === 'jira_queue'): ?> <?php elseif ($wId === 'jira_queue'): ?>
<!-- Widget 5: Jira Tickets & Wachtrijen --> <!-- Widget 5: Jira Tickets & Wachtrijen (Interactive v2.0) -->
<div id="jiraPanel" class="fociz-card p-6"> <div id="jiraPanel" class="fociz-card p-5 sm:p-6">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800 mb-3"> <div class="flex flex-col sm:flex-row sm:items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80 mb-3 gap-2">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2.5">
<div class="w-7 h-7 rounded-lg bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 flex items-center justify-center font-bold"> <div class="w-7 h-7 rounded-xl bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 flex items-center justify-center font-bold">
<i data-lucide="layers" class="w-4 h-4"></i> <i data-lucide="layers" class="w-4 h-4"></i>
</div> </div>
<h3 class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider"> <div>
Jira Wachtrij & Tickets <h3 class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider">
</h3> Jira Wachtrijen & Tickets
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold <?= $isJiraLive ? 'bg-sky-50 dark:bg-sky-500/15 text-sky-700 dark:text-sky-400 border border-sky-200 dark:border-sky-500/30' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400' ?>"> </h3>
<?= count($jiraIssues) ?> </div>
<span id="plannerJiraCountBadge" class="px-2 py-0.5 rounded-full text-[10px] font-bold <?= $isJiraLive ? 'bg-sky-50 dark:bg-sky-500/15 text-sky-700 dark:text-sky-400 border border-sky-200 dark:border-sky-500/30' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400' ?>">
<?= count($jiraAllIssues) ?>
</span> </span>
</div> </div>
<a href="/jira.php" class="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 transition flex items-center space-x-1 font-semibold"> <a href="/jira.php" class="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 transition flex items-center space-x-1 font-semibold flex-shrink-0">
<span>Alle Tickets</span> <span>Jira Hub</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5"></i> <i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</a> </a>
</div> </div>
<?php if (empty($jiraIssues)): ?> <!-- Filter Queue Pills & Quick Search -->
<div class="text-center py-6 text-slate-400 dark:text-slate-500 text-xs"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 mb-3">
<i data-lucide="ticket" class="w-8 h-8 text-slate-300 dark:text-slate-700 mx-auto mb-2"></i> <div class="flex items-center space-x-1 overflow-x-auto pb-1 sm:pb-0 scrollbar-none" id="plannerJiraFilterTabs">
<p>Geen openstaande tickets gevonden in Jira.</p> <button type="button" onclick="setPlannerJiraQueue('')" id="pjPill_all" class="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">
Alle (<span id="pjCount_all"><?= count($jiraAllIssues) ?></span>)
</button>
<button type="button" onclick="setPlannerJiraQueue('my')" id="pjPill_my" class="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">
Mijn (<span id="pjCount_my"><?= count($jiraAssignedIssues) ?></span>)
</button>
<button type="button" onclick="setPlannerJiraQueue('DX')" id="pjPill_DX" class="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">
DX Queue (<span id="pjCount_DX">0</span>)
</button>
<button type="button" onclick="setPlannerJiraQueue('SD')" id="pjPill_SD" class="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">
SD Queue (<span id="pjCount_SD">0</span>)
</button>
</div> </div>
<?php else: ?>
<div class="space-y-2 max-h-72 overflow-y-auto pr-1"> <div class="relative min-w-[140px] sm:w-44 flex-shrink-0">
<?php foreach (array_slice($jiraIssues, 0, 8) as $issue): ?> <i data-lucide="search" class="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input
type="text"
id="plannerJiraSearch"
oninput="filterPlannerJira()"
placeholder="Filter tickets..."
class="w-full pl-8 pr-2 py-1 text-xs bg-slate-50 dark:bg-[#0c0f17] border border-slate-200/80 dark:border-slate-800/80 rounded-xl text-slate-800 dark:text-slate-200 placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
</div>
</div>
<!-- Ticket List Container -->
<div id="plannerJiraListContainer" class="space-y-2 max-h-72 overflow-y-auto pr-1">
<?php if (empty($jiraAllIssues)): ?>
<div class="text-center py-6 text-slate-400 dark:text-slate-500 text-xs">
<i data-lucide="ticket" class="w-8 h-8 text-slate-300 dark:text-slate-700 mx-auto mb-2"></i>
<p>Geen openstaande tickets gevonden in Jira.</p>
</div>
<?php else: ?>
<?php foreach (array_slice($jiraAllIssues, 0, 8) as $issue): ?>
<div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs"> <div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs">
<div class="truncate mr-2 min-w-0 flex-1"> <div class="truncate mr-2 min-w-0 flex-1">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded"> <span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded">
<?= htmlspecialchars($issue['key']) ?> <?= htmlspecialchars($issue['key'] ?? '') ?>
</span> </span>
<span class="text-xs font-semibold text-slate-900 dark:text-white truncate"> <span class="text-xs font-semibold text-slate-900 dark:text-white truncate">
<?= htmlspecialchars($issue['summary']) ?> <?= htmlspecialchars($issue['summary'] ?? '') ?>
</span> </span>
</div> </div>
<span class="text-[10px] text-slate-500 mt-0.5 block"> <span class="text-[10px] text-slate-500 mt-0.5 block">
<?= htmlspecialchars($issue['status']) ?> &bull; <?= htmlspecialchars($issue['priority']) ?> <?= htmlspecialchars($issue['status'] ?? '') ?> &bull; <?= htmlspecialchars($issue['priority'] ?? '') ?>
</span> </span>
</div> </div>
<button <button
onclick="quickImportJira(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>')" onclick="quickImportJiraByKey(this, '<?= htmlspecialchars($issue['key'] ?? '') ?>')"
class="flex items-center space-x-1 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 bg-indigo-50 dark:bg-indigo-500/10 hover:bg-indigo-100 dark:hover:bg-indigo-500/20 border border-indigo-200 dark:border-indigo-500/20 px-2.5 py-1 rounded-lg transition flex-shrink-0 cursor-pointer" class="flex items-center space-x-1 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 bg-indigo-50 dark:bg-indigo-500/10 hover:bg-indigo-100 dark:hover:bg-indigo-500/20 border border-indigo-200 dark:border-indigo-500/20 px-2.5 py-1 rounded-lg transition flex-shrink-0 cursor-pointer"
title="Plan in als dagtaak" title="Plan in als dagtaak"
> >
@@ -1071,8 +1149,14 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</button> </button>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> <?php endif; ?>
<?php endif; ?> </div>
<!-- Lege Toestand (verborgen by default) -->
<div id="plannerJiraEmptyState" class="hidden text-center py-6 text-slate-400 dark:text-slate-500 text-xs">
<i data-lucide="ticket" class="w-8 h-8 text-slate-300 dark:text-slate-700 mx-auto mb-2"></i>
<p>Geen tickets gevonden voor deze filterselectie.</p>
</div>
</div> </div>
<?php elseif ($wId === 'calendar_meetings'): ?> <?php elseif ($wId === 'calendar_meetings'): ?>
@@ -1424,7 +1508,8 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div><!-- /.widget-content-body -->
</div><!-- /.fociz-widget-wrapper -->
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php <?php
@@ -1805,6 +1890,433 @@ async function saveWidgetLayoutConfig() {
} }
} }
// --- HTML Escaping Helper (XSS preventie) ---
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// --- 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 = `
<div class="w-5 h-5 rounded-full ${type === 'success' ? 'bg-emerald-500/20 text-emerald-400 dark:text-emerald-600' : 'bg-indigo-500/20 text-indigo-400 dark:text-indigo-600'} flex items-center justify-center flex-shrink-0">
<i data-lucide="${icon}" class="w-3.5 h-3.5"></i>
</div>
<div class="flex-1">${htmlMessage}</div>
`;
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 <button type="button" onclick="undoHideWidget('${wId}')" class="underline font-bold ml-2 text-indigo-400 hover:text-indigo-300 cursor-pointer">Ongedaan maken</button>`, '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 = '<div class="flex items-center space-x-2"><i data-lucide="arrow-down-circle" class="w-4 h-4"></i><span>Laat los om hier te plaatsen</span></div>';
}
// 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 = <?= json_encode($jiraAssignedIssues, JSON_UNESCAPED_UNICODE) ?>;
const plannerJiraAll = <?= json_encode($jiraAllIssues, JSON_UNESCAPED_UNICODE) ?>;
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
? '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20">DX</span>'
: '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-500/20">SD</span>';
return `
<div class="fociz-card-interactive flex items-center justify-between p-2.5 rounded-xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-300 dark:hover:border-indigo-600/50 transition shadow-2xs">
<div class="truncate mr-2 min-w-0 flex-1">
<div class="flex items-center space-x-2">
<span class="font-mono text-[10px] font-bold text-sky-700 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-1.5 py-0.5 rounded">
${keyEsc}
</span>
${queuePill}
<span class="text-xs font-semibold text-slate-900 dark:text-white truncate">
${summaryEsc}
</span>
</div>
<span class="text-[10px] text-slate-500 mt-0.5 block">
${statusEsc} &bull; ${priorityEsc}
</span>
</div>
<button
onclick="quickImportJiraByKey(this, '${keyEsc}')"
class="flex items-center space-x-1 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 bg-indigo-50 dark:bg-indigo-500/10 hover:bg-indigo-100 dark:hover:bg-indigo-500/20 border border-indigo-200 dark:border-indigo-500/20 px-2.5 py-1 rounded-lg transition flex-shrink-0 cursor-pointer"
title="Plan in als dagtaak"
>
<i data-lucide="plus" class="w-3 h-3"></i>
<span>Plan</span>
</button>
</div>
`;
}).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 --- // --- Direct E-mail Inplannen als Dagtaak ---
function openPlanEmailDirect(mailId, subject, sender, webLink) { function openPlanEmailDirect(mailId, subject, sender, webLink) {
document.getElementById('planEmailId').value = mailId; document.getElementById('planEmailId').value = mailId;
@@ -2045,6 +2557,10 @@ async function switchPlannerDate(newDate, event = null, pushState = true) {
if (typeof lucide !== 'undefined' && lucide.createIcons) { if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons(); lucide.createIcons();
} }
initPlannerWidgets();
if (document.getElementById('plannerJiraListContainer')) {
filterPlannerJira();
}
// Garandeer dat het scherm exact op dezelfde scrollhoogte blijft // Garandeer dat het scherm exact op dezelfde scrollhoogte blijft
window.scrollTo({ window.scrollTo({
@@ -2679,6 +3195,10 @@ function triggerManualSync() {
// Initialisatie bij pagina-inladen // Initialisatie bij pagina-inladen
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
restoreTimerStateIfAny(); restoreTimerStateIfAny();
initPlannerWidgets();
if (document.getElementById('plannerJiraListContainer')) {
filterPlannerJira();
}
if (isOutlookConnected) { if (isOutlookConnected) {
if (syncIntervalSeconds > 0) { if (syncIntervalSeconds > 0) {