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:
+554
-34
@@ -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']) ?> • <?= htmlspecialchars($issue['priority']) ?>
|
||||
<?= htmlspecialchars($issue['status'] ?? '') ?> • <?= 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// --- Floating Toast Notificatie ---
|
||||
function showWidgetToast(htmlMessage, type = 'success', duration = 3500) {
|
||||
let toast = document.getElementById('widgetGlobalToast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'widgetGlobalToast';
|
||||
toast.className = 'fixed bottom-6 right-6 z-50 transform translate-y-20 opacity-0 transition-all duration-300 pointer-events-auto flex items-center space-x-2.5 px-4 py-2.5 rounded-2xl bg-slate-900/95 dark:bg-white text-white dark:text-slate-900 text-xs font-semibold shadow-2xl border border-slate-700/60 dark:border-slate-200/80';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
const icon = type === 'success' ? 'check-circle-2' : 'info';
|
||||
toast.innerHTML = `
|
||||
<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} • ${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) {
|
||||
|
||||
Reference in New Issue
Block a user