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
+554 -34
View File
@@ -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'
};
?>
<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'): ?>
<!-- Widget 1: Dagfocus & Hoofddoel -->
@@ -1017,52 +1063,84 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div>
<?php elseif ($wId === 'jira_queue'): ?>
<!-- Widget 5: Jira Tickets & Wachtrijen -->
<div id="jiraPanel" class="fociz-card 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 items-center space-x-2">
<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">
<!-- Widget 5: Jira Tickets & Wachtrijen (Interactive v2.0) -->
<div id="jiraPanel" class="fociz-card p-5 sm:p-6">
<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.5">
<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>
</div>
<h3 class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider">
Jira Wachtrij & Tickets
</h3>
<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' ?>">
<?= count($jiraIssues) ?>
<div>
<h3 class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider">
Jira Wachtrijen & Tickets
</h3>
</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>
</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">
<span>Alle Tickets</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
<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>Jira Hub</span>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</a>
</div>
<?php if (empty($jiraIssues)): ?>
<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>
<!-- Filter Queue Pills & Quick Search -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 mb-3">
<div class="flex items-center space-x-1 overflow-x-auto pb-1 sm:pb-0 scrollbar-none" id="plannerJiraFilterTabs">
<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>
<?php else: ?>
<div class="space-y-2 max-h-72 overflow-y-auto pr-1">
<?php foreach (array_slice($jiraIssues, 0, 8) as $issue): ?>
<div class="relative min-w-[140px] sm:w-44 flex-shrink-0">
<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="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">
<?= htmlspecialchars($issue['key']) ?>
<?= htmlspecialchars($issue['key'] ?? '') ?>
</span>
<span class="text-xs font-semibold text-slate-900 dark:text-white truncate">
<?= htmlspecialchars($issue['summary']) ?>
<?= htmlspecialchars($issue['summary'] ?? '') ?>
</span>
</div>
<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>
</div>
<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"
title="Plan in als dagtaak"
>
@@ -1071,8 +1149,14 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</button>
</div>
<?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>
<?php elseif ($wId === 'calendar_meetings'): ?>
@@ -1424,7 +1508,8 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div>
<?php endif; ?>
</div>
</div><!-- /.widget-content-body -->
</div><!-- /.fociz-widget-wrapper -->
<?php endforeach; ?>
</div>
<?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 ---
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) {