2823 lines
147 KiB
PHP
2823 lines
147 KiB
PHP
<?php
|
||
/**
|
||
* Fociz — Atlassian Jira Hub
|
||
* Toont tickets toegewezen aan de gebruiker en project-/teamtickets.
|
||
* Biedt filters, zoekfunctie, KPI-statistieken en 1-klik import naar de Dagplanner en het Kanban bord.
|
||
*/
|
||
|
||
$pageTitle = 'Fociz — Jira Hub';
|
||
require_once __DIR__ . '/includes/header.php';
|
||
require_once __DIR__ . '/includes/integrations/jira.php';
|
||
|
||
$userId = (int)($currentUser['id'] ?? 0);
|
||
$isAdmin = (($currentUser['role'] ?? '') === 'ADMIN');
|
||
$forceRefresh = isset($_GET['refresh']) && $_GET['refresh'] === '1';
|
||
|
||
// Haal Jira data op
|
||
$jiraData = jira_get_dashboard_data($userId, $forceRefresh);
|
||
$isDemo = !empty($jiraData['is_demo']);
|
||
$isLive = !empty($jiraData['success']) && !$isDemo;
|
||
$domain = $jiraData['domain'] ?? '';
|
||
$email = $jiraData['email'] ?? '';
|
||
$accountName = $jiraData['account_name'] ?? '';
|
||
$lastSync = $jiraData['last_sync_at'] ?? date('Y-m-d H:i:s');
|
||
$syncError = $jiraData['error'] ?? null;
|
||
|
||
$assignedIssues = $jiraData['assigned_issues'] ?? [];
|
||
$teamIssues = $jiraData['team_issues'] ?? [];
|
||
|
||
// Haal borden en kolommen op voor het "Naar Bord" modal
|
||
$db = get_db_connection();
|
||
if ($isAdmin) {
|
||
$boards = $db->query("SELECT id, title FROM boards WHERE is_archived = 0 ORDER BY id ASC")->fetchAll();
|
||
} else {
|
||
$stmt = $db->prepare("
|
||
SELECT b.id, b.title
|
||
FROM boards b
|
||
JOIN board_members bm ON b.id = bm.board_id
|
||
WHERE bm.user_id = ? AND b.is_archived = 0
|
||
ORDER BY b.id ASC
|
||
");
|
||
$stmt->execute([$userId]);
|
||
$boards = $stmt->fetchAll();
|
||
}
|
||
|
||
$boardIds = array_column($boards, 'id');
|
||
$lanesByBoard = [];
|
||
if (!empty($boardIds)) {
|
||
$inClause = implode(',', array_map('intval', $boardIds));
|
||
$lanes = $db->query("SELECT id, board_id, title, color FROM board_lanes WHERE board_id IN ({$inClause}) ORDER BY position ASC")->fetchAll();
|
||
foreach ($lanes as $lane) {
|
||
$lanesByBoard[$lane['board_id']][] = $lane;
|
||
}
|
||
}
|
||
foreach ($boards as &$b) {
|
||
$b['lanes'] = $lanesByBoard[$b['id']] ?? [];
|
||
}
|
||
unset($b);
|
||
|
||
// Verzamel unieke projecten en types voor filter dropdowns en project switcher
|
||
// Verzamel unieke projecten en types voor filter dropdowns en project switcher
|
||
$allIssues = array_merge($assignedIssues, $teamIssues);
|
||
$allProjects = [];
|
||
$allTypes = [];
|
||
$allPriorities = ['Highest', 'High', 'Medium', 'Low'];
|
||
$projectCounts = [];
|
||
|
||
// Core projecten met styling en namen
|
||
$coreProjects = [
|
||
'SD' => ['key' => 'SD', 'name' => 'Servicedesk', 'color' => 'sky'],
|
||
'IN' => ['key' => 'IN', 'name' => 'Interfaces', 'color' => 'amber'],
|
||
'AAN' => ['key' => 'AAN', 'name' => 'Aanvragen', 'color' => 'emerald'],
|
||
'GEN' => ['key' => 'GEN', 'name' => 'General', 'color' => 'indigo'],
|
||
];
|
||
|
||
$usedProjectKeys = [];
|
||
foreach ($allIssues as $iss) {
|
||
$pKey = strtoupper(trim($iss['project_key'] ?? ''));
|
||
if (empty($pKey) && !empty($iss['key']) && preg_match('/^([A-Z0-9]+)-/i', $iss['key'], $m)) {
|
||
$pKey = strtoupper($m[1]);
|
||
}
|
||
if (!empty($pKey)) {
|
||
$usedProjectKeys[$pKey] = true;
|
||
}
|
||
|
||
if (!empty($iss['project'])) {
|
||
$p = $iss['project'];
|
||
$allProjects[$p] = [
|
||
'name' => $p,
|
||
'key' => $pKey ?: ($iss['project_key'] ?? ''),
|
||
];
|
||
$projectCounts[$p] = ($projectCounts[$p] ?? 0) + 1;
|
||
}
|
||
if (!empty($iss['issue_type'])) {
|
||
$allTypes[$iss['issue_type']] = $iss['issue_type'];
|
||
}
|
||
if (!empty($iss['status'])) {
|
||
$allStatuses[$iss['status']] = $iss['status'];
|
||
}
|
||
}
|
||
|
||
// Bepaal welke projecten getoond worden: alleen projecten die tickets hebben, plus SD/IN/AAN/GEN
|
||
// Geen statussen van projecten die niet gebruikt worden!
|
||
$activeProjectKeys = [];
|
||
foreach ($coreProjects as $cKey => $cMeta) {
|
||
if (isset($usedProjectKeys[$cKey]) || !empty($projectCounts[$cMeta['name']]) || !empty($projectCounts[$cKey])) {
|
||
$activeProjectKeys[$cKey] = true;
|
||
}
|
||
}
|
||
foreach ($usedProjectKeys as $uKey => $val) {
|
||
$activeProjectKeys[$uKey] = true;
|
||
}
|
||
if (empty($activeProjectKeys)) {
|
||
$activeProjectKeys = array_fill_keys(['SD', 'IN', 'AAN', 'GEN'], true);
|
||
}
|
||
|
||
// Haal projectstatussen op uit jiraData
|
||
$rawProjectStatuses = $jiraData['project_statuses'] ?? [];
|
||
|
||
// Bouw gestructureerde lijst van statussen gegroepeerd per project
|
||
$projectStatusesData = [];
|
||
$allStatusesMap = [];
|
||
|
||
foreach (array_keys($activeProjectKeys) as $pKey) {
|
||
$pName = $coreProjects[$pKey]['name'] ?? ($allProjects[$pKey]['name'] ?? $pKey);
|
||
$pColor = $coreProjects[$pKey]['color'] ?? 'slate';
|
||
|
||
$pStatuses = [];
|
||
|
||
// 1. Statussen uit Jira API voor dit project
|
||
if (!empty($rawProjectStatuses[$pKey])) {
|
||
foreach ($rawProjectStatuses[$pKey] as $st) {
|
||
$sName = trim($st['name'] ?? '');
|
||
if (empty($sName)) continue;
|
||
$sKey = strtolower($sName);
|
||
$pStatuses[$sKey] = [
|
||
'name' => $sName,
|
||
'status_category' => $st['status_category'] ?? 'indeterminate',
|
||
'category_name' => $st['category_name'] ?? '',
|
||
];
|
||
}
|
||
}
|
||
|
||
// 2. Statussen die op de tickets van dit project voorkomen toevoegen/mergen
|
||
foreach ($allIssues as $iss) {
|
||
$issPKey = strtoupper(trim($iss['project_key'] ?? ''));
|
||
if (empty($issPKey) && !empty($iss['key']) && preg_match('/^([A-Z0-9]+)-/i', $iss['key'], $m)) {
|
||
$issPKey = strtoupper($m[1]);
|
||
}
|
||
if ($issPKey === $pKey && !empty($iss['status'])) {
|
||
$sName = trim($iss['status']);
|
||
$sKey = strtolower($sName);
|
||
if (!isset($pStatuses[$sKey])) {
|
||
$pStatuses[$sKey] = [
|
||
'name' => $sName,
|
||
'status_category' => $iss['status_category'] ?? 'indeterminate',
|
||
'category_name' => '',
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback als een project nog geen statussen heeft
|
||
if (empty($pStatuses)) {
|
||
$fallback = [
|
||
['name' => 'To Do', 'status_category' => 'new'],
|
||
['name' => 'In behandeling', 'status_category' => 'indeterminate'],
|
||
['name' => 'Gesloten', 'status_category' => 'done'],
|
||
];
|
||
foreach ($fallback as $fb) {
|
||
$pStatuses[strtolower($fb['name'])] = $fb;
|
||
}
|
||
}
|
||
|
||
// Sorteren: To Do / Nieuw (1), In behandeling / Uitvoering (2), Gesloten / Gereed (3)
|
||
uasort($pStatuses, function($a, $b) {
|
||
$order = ['new' => 1, 'indeterminate' => 2, 'done' => 3];
|
||
$oa = $order[$a['status_category'] ?? 'indeterminate'] ?? 2;
|
||
$ob = $order[$b['status_category'] ?? 'indeterminate'] ?? 2;
|
||
if ($oa !== $ob) return $oa - $ob;
|
||
return strcasecmp($a['name'], $b['name']);
|
||
});
|
||
|
||
$statusList = array_values($pStatuses);
|
||
foreach ($statusList as $st) {
|
||
$allStatusesMap[strtolower($st['name'])] = $st;
|
||
}
|
||
|
||
$projectStatusesData[] = [
|
||
'key' => $pKey,
|
||
'name' => $pName,
|
||
'color' => $pColor,
|
||
'statuses' => $statusList,
|
||
];
|
||
}
|
||
|
||
$allStatusesList = array_values($allStatusesMap);
|
||
|
||
ksort($allProjects);
|
||
ksort($allTypes);
|
||
|
||
$queueFilters = $jiraData['queue_filters'] ?? jira_get_queue_filters(true);
|
||
$defaultQueueFilter = $jiraData['default_queue_filter'] ?? jira_get_user_queue_preference($userId);
|
||
$userQueueFilters = $jiraData['user_queue_filters'] ?? jira_get_user_queue_preferences($userId);
|
||
|
||
$jiraDataJson = json_encode($jiraData, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||
$boardsJson = json_encode($boards, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||
$projectStatusesJson = json_encode($projectStatusesData, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||
$statusesJson = json_encode($allStatusesList, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||
?>
|
||
|
||
<div class="space-y-6 w-full">
|
||
|
||
<!-- Bovenbalk: Titel, Jira status & Actieknoppen -->
|
||
<div class="fociz-card p-5 sm:p-6">
|
||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||
|
||
<!-- Linkerkant: Titel & Atlassian Jira branding -->
|
||
<div class="flex items-center space-x-3.5">
|
||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-tr from-blue-600 via-indigo-600 to-sky-500 text-white flex items-center justify-center shadow-md shadow-indigo-500/20 flex-shrink-0">
|
||
<i data-lucide="layers" class="w-6 h-6"></i>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center space-x-2.5">
|
||
<h1 class="text-2xl font-black text-slate-900 dark:text-white tracking-tight">Atlassian Jira Hub</h1>
|
||
<?php if ($isLive): ?>
|
||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800/80">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse"></span>
|
||
Live Verbonden
|
||
</span>
|
||
<?php else: ?>
|
||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-300 dark:border-amber-800/80">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 mr-1.5"></span>
|
||
Demomodus
|
||
</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rechterkant: Knoppen Vernieuwen & Instellingen -->
|
||
<div class="flex items-center space-x-2.5 self-start md:self-center">
|
||
<button onclick="refreshJiraData()" id="refreshBtn" class="flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-semibold bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700 transition cursor-pointer shadow-2xs">
|
||
<i data-lucide="refresh-cw" id="refreshIcon" class="w-3.5 h-3.5"></i>
|
||
<span>Vernieuwen</span>
|
||
</button>
|
||
<a href="/profile.php#integraties" class="flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-semibold bg-indigo-600 hover:bg-indigo-700 text-white transition cursor-pointer shadow-sm shadow-indigo-600/20">
|
||
<i data-lucide="settings" class="w-3.5 h-3.5"></i>
|
||
<span>Jira Instellingen</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($isDemo): ?>
|
||
<!-- Demomodus Informatiebanner -->
|
||
<div class="mt-5 p-4 rounded-xl bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20 border border-amber-200/90 dark:border-amber-800/50 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||
<div class="flex items-start space-x-3">
|
||
<div class="w-8 h-8 rounded-lg bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<i data-lucide="info" class="w-4 h-4"></i>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-bold text-amber-900 dark:text-amber-200">
|
||
Interactieve Voorbeeldweergave (Sandbox)
|
||
</p>
|
||
<p class="text-xs text-amber-700 dark:text-amber-300/80 mt-0.5">
|
||
Je bekijkt momenteel demotickets. Koppel je Atlassian Jira Cloud instantie in je profiel om direct de live tickets van jouw organisatie in te laden.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<a href="/profile.php#integraties" class="px-3.5 py-1.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white font-bold text-xs shadow-xs transition flex-shrink-0 cursor-pointer">
|
||
Koppel Jira Cloud
|
||
</a>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($syncError)): ?>
|
||
<!-- Synchronisatiefout Banner -->
|
||
<div class="mt-4 p-3 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900/50 flex items-center space-x-3 text-xs text-red-700 dark:text-red-300">
|
||
<i data-lucide="alert-triangle" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
|
||
<div class="flex-1">
|
||
<strong>Synchronisatiefout:</strong> <?= htmlspecialchars($syncError) ?>
|
||
</div>
|
||
<a href="/profile.php#integraties" class="underline hover:no-underline font-semibold">Controleer instellingen</a>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- Hub Voettekst: Live Status & Filters Uitklapknop (Rechtsonder) -->
|
||
<div class="mt-4 pt-3 border-t border-slate-100 dark:border-slate-800/60 flex flex-wrap items-center justify-between text-xs text-slate-500 dark:text-slate-400 gap-2">
|
||
<div class="flex items-center space-x-2">
|
||
<?php if ($isLive): ?>
|
||
<i data-lucide="check-circle-2" class="w-3.5 h-3.5 text-emerald-500"></i>
|
||
<span>Verbonden met <strong class="text-slate-700 dark:text-slate-200"><?= htmlspecialchars($domain) ?></strong> als <strong class="text-slate-700 dark:text-slate-200"><?= htmlspecialchars($accountName ?: $email) ?></strong></span>
|
||
<span class="text-slate-300 dark:text-slate-700 hidden sm:inline">•</span>
|
||
<span class="text-[11px] text-slate-400 hidden sm:inline">Sync: <?= date('d-m-Y H:i', strtotime($lastSync)) ?></span>
|
||
<?php elseif ($isDemo): ?>
|
||
<i data-lucide="database" class="w-3.5 h-3.5 text-amber-500"></i>
|
||
<span>Demodataset actief met voorbeeldtickets</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- Uitklapknop in de rechter onderhoek -->
|
||
<div class="flex items-center space-x-2 ml-auto">
|
||
<button type="button"
|
||
onclick="toggleJiraFiltersDrawer()"
|
||
id="btnToggleFiltersDrawer"
|
||
class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700 transition cursor-pointer shadow-2xs group">
|
||
<i data-lucide="sliders-horizontal" class="w-3.5 h-3.5 text-indigo-500 group-hover:rotate-12 transition-transform"></i>
|
||
<span>Filters & Statistieken</span>
|
||
<span id="activeFilterBadge" class="hidden px-1.5 py-0.5 rounded-full text-[10px] font-black bg-indigo-600 text-white leading-tight">0</span>
|
||
<i data-lucide="chevron-down" id="filtersDrawerChevron" class="w-3.5 h-3.5 transition-transform duration-200"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Uitklapbare Filters & Statistieken Drawer -->
|
||
<div id="jiraFiltersDrawer" class="hidden mt-4 pt-4 border-t border-slate-100 dark:border-slate-800/80 space-y-4">
|
||
|
||
<!-- 1. 4 KPI Statistieken Kaarten -->
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<div onclick="filterByMyTickets()" class="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200/70 dark:border-slate-700/60 cursor-pointer transition group">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-500 dark:text-slate-400">Aan mij toegewezen</span>
|
||
<div class="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-950/40 text-blue-600 dark:text-blue-400 flex items-center justify-center transition group-hover:scale-110">
|
||
<i data-lucide="user-check" class="w-3.5 h-3.5"></i>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 flex items-baseline space-x-2">
|
||
<span id="statAssignedCount" class="text-xl font-black text-slate-900 dark:text-white"><?= count($assignedIssues) ?></span>
|
||
<span class="text-[11px] text-slate-400 font-medium">persoonlijke taken</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div onclick="filterByAllTickets()" class="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200/70 dark:border-slate-700/60 cursor-pointer transition group">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-500 dark:text-slate-400">Team & Projecten</span>
|
||
<div class="w-7 h-7 rounded-lg bg-purple-50 dark:bg-purple-950/40 text-purple-600 dark:text-purple-400 flex items-center justify-center transition group-hover:scale-110">
|
||
<i data-lucide="users" class="w-3.5 h-3.5"></i>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 flex items-baseline space-x-2">
|
||
<span id="statTeamCount" class="text-xl font-black text-slate-900 dark:text-white"><?= count($teamIssues) ?></span>
|
||
<span class="text-[11px] text-slate-400 font-medium">in actieve projecten</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div onclick="selectActiveStatusesOnly()" class="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200/70 dark:border-slate-700/60 cursor-pointer transition group">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-500 dark:text-slate-400">In Uitvoering</span>
|
||
<div class="w-7 h-7 rounded-lg bg-amber-50 dark:bg-amber-950/40 text-amber-600 dark:text-amber-400 flex items-center justify-center transition group-hover:scale-110">
|
||
<i data-lucide="clock" class="w-3.5 h-3.5"></i>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 flex items-baseline space-x-2">
|
||
<span id="statInProgressCount" class="text-xl font-black text-slate-900 dark:text-white">...</span>
|
||
<span class="text-[11px] text-slate-400 font-medium">actief mee bezig</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div onclick="filterByUrgent()" class="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200/70 dark:border-slate-700/60 cursor-pointer transition group">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-500 dark:text-slate-400">Hoge Prioriteit / Urgent</span>
|
||
<div class="w-7 h-7 rounded-lg bg-rose-50 dark:bg-rose-950/40 text-rose-600 dark:text-rose-400 flex items-center justify-center transition group-hover:scale-110">
|
||
<i data-lucide="alert-circle" class="w-3.5 h-3.5"></i>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 flex items-baseline space-x-2">
|
||
<span id="statHighPriorityCount" class="text-xl font-black text-slate-900 dark:text-white">...</span>
|
||
<span class="text-[11px] text-slate-400 font-medium">Highest / High</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. Geavanceerde Filter Dropdowns -->
|
||
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/30 border border-slate-200/70 dark:border-slate-800">
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||
<div>
|
||
<label class="block text-[11px] font-semibold text-slate-500 dark:text-slate-400 mb-1">Issue Type</label>
|
||
<select id="filterType" onchange="applyFilters()" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 cursor-pointer">
|
||
<option value="">Alle typen</option>
|
||
<?php foreach ($allTypes as $t): ?>
|
||
<option value="<?= htmlspecialchars($t) ?>"><?= htmlspecialchars($t) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-[11px] font-semibold text-slate-500 dark:text-slate-400 mb-1">Prioriteit</label>
|
||
<select id="filterPriority" onchange="applyFilters()" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 cursor-pointer">
|
||
<option value="">Alle prioriteiten</option>
|
||
<option value="Highest">Highest</option>
|
||
<option value="High">High</option>
|
||
<option value="Medium">Medium</option>
|
||
<option value="Low">Low</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="flex items-end">
|
||
<button type="button" onclick="resetAllFilters()" class="w-full flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white bg-white hover:bg-slate-100 dark:bg-slate-800 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 transition cursor-pointer">
|
||
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
|
||
<span>Herstel Alle Filters</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hoofdbalk: Project Switcher, Wachtrijen, Tabs & Status Filtering -->
|
||
<div class="fociz-card relative z-30 p-4 sm:p-5 space-y-4 shadow-sm border border-slate-200/80 dark:border-slate-800 overflow-visible">
|
||
|
||
<!-- Rij 1: Prominente Project Switcher (SD, IN, AAN, GEN, Alle) -->
|
||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3 pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||
<div class="flex items-center space-x-2.5">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-950/60 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>
|
||
<div>
|
||
<span class="text-xs font-black uppercase tracking-wider text-slate-900 dark:text-white">Projecten</span>
|
||
<p class="text-[10px] text-slate-400">Direct filteren op projectcode</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" id="projectPillsContainer">
|
||
<button type="button" onclick="selectProject('')" data-project="" class="project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-xs flex-shrink-0">
|
||
<i data-lucide="grid" class="w-3.5 h-3.5"></i>
|
||
<span>Alle Projecten</span>
|
||
<span id="projCount_all" class="px-1.5 py-0.5 rounded-full text-[10px] bg-white/20 dark:bg-slate-900/20 font-black"><?= count($allIssues) ?></span>
|
||
</button>
|
||
|
||
<!-- SD: Servicedesk -->
|
||
<button type="button" onclick="selectProject('SD')" data-project="SD" class="project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-sky-50 dark:bg-sky-950/40 text-sky-800 dark:text-sky-300 hover:bg-sky-100 dark:hover:bg-sky-900/60 border border-sky-300 dark:border-sky-800 flex-shrink-0">
|
||
<span class="px-1.5 py-0.5 rounded font-mono text-[10px] font-black bg-sky-200 dark:bg-sky-800 text-sky-900 dark:text-sky-100">SD</span>
|
||
<span>Servicedesk</span>
|
||
<span id="projCount_SD" class="px-1.5 py-0.5 rounded-full text-[10px] bg-sky-200/80 dark:bg-sky-800/80 font-bold">0</span>
|
||
</button>
|
||
|
||
<!-- IN: Interfaces -->
|
||
<button type="button" onclick="selectProject('IN')" data-project="IN" class="project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-amber-50 dark:bg-amber-950/40 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/60 border border-amber-300 dark:border-amber-800 flex-shrink-0">
|
||
<span class="px-1.5 py-0.5 rounded font-mono text-[10px] font-black bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100">IN</span>
|
||
<span>Interfaces</span>
|
||
<span id="projCount_IN" class="px-1.5 py-0.5 rounded-full text-[10px] bg-amber-200/80 dark:bg-amber-800/80 font-bold">0</span>
|
||
</button>
|
||
|
||
<!-- AAN: Aanvragen -->
|
||
<button type="button" onclick="selectProject('AAN')" data-project="AAN" class="project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 border border-emerald-300 dark:border-emerald-800 flex-shrink-0">
|
||
<span class="px-1.5 py-0.5 rounded font-mono text-[10px] font-black bg-emerald-200 dark:bg-emerald-800 text-emerald-900 dark:text-emerald-100">AAN</span>
|
||
<span>Aanvragen</span>
|
||
<span id="projCount_AAN" class="px-1.5 py-0.5 rounded-full text-[10px] bg-emerald-200/80 dark:bg-emerald-800/80 font-bold">0</span>
|
||
</button>
|
||
|
||
<!-- GEN: General -->
|
||
<button type="button" onclick="selectProject('GEN')" data-project="GEN" class="project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-50 dark:bg-indigo-950/40 text-indigo-800 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/60 border border-indigo-300 dark:border-indigo-800 flex-shrink-0">
|
||
<span class="px-1.5 py-0.5 rounded font-mono text-[10px] font-black bg-indigo-200 dark:bg-indigo-800 text-indigo-900 dark:text-indigo-100">GEN</span>
|
||
<span>General</span>
|
||
<span id="projCount_GEN" class="px-1.5 py-0.5 rounded-full text-[10px] bg-indigo-200/80 dark:bg-indigo-800/80 font-bold">0</span>
|
||
</button>
|
||
|
||
<?php foreach ($allProjects as $pKey => $pData):
|
||
$pKeyNorm = strtoupper($pKey);
|
||
$pDataKeyNorm = strtoupper($pData['key'] ?? '');
|
||
if (in_array($pKeyNorm, ['SD', 'IN', 'AAN', 'GEN']) || in_array($pDataKeyNorm, ['SD', 'IN', 'AAN', 'GEN'])) continue;
|
||
$displayKey = !empty($pData['key']) ? $pData['key'] : $pKey;
|
||
?>
|
||
<button type="button" onclick="selectProject('<?= htmlspecialchars(addslashes($displayKey)) ?>')" data-project="<?= htmlspecialchars($displayKey) ?>" class="project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<span class="px-1.5 py-0.5 rounded font-mono text-[10px] font-black bg-slate-200 dark:bg-slate-700"><?= htmlspecialchars($displayKey) ?></span>
|
||
<span><?= htmlspecialchars($pData['name']) ?></span>
|
||
<span class="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"><?= $projectCounts[$pKey] ?? 0 ?></span>
|
||
</button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rij 2: Wachtrijen & Multi-queue voorkeur -->
|
||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3 pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||
<div class="flex items-center space-x-2.5">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-400 flex items-center justify-center font-bold">
|
||
<i data-lucide="filter" class="w-4 h-4"></i>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs font-black uppercase tracking-wider text-slate-900 dark:text-white">Wachtrij</span>
|
||
<p class="text-[10px] text-slate-400">Filter op gekoppelde wachtrijen</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-1.5" id="queueButtonsContainer">
|
||
<button type="button" onclick="selectQueueFilter('')" data-queue="" class="queue-pill flex items-center space-x-1 px-2.5 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 flex-shrink-0">
|
||
<i data-lucide="list" class="w-3 h-3"></i>
|
||
<span>Alle</span>
|
||
<span id="queueCount_all" class="px-1.5 py-0.5 rounded-full text-[10px] bg-slate-300 dark:bg-slate-700 font-bold">0</span>
|
||
</button>
|
||
|
||
<?php if (!empty($userQueueFilters)): ?>
|
||
<button type="button" onclick="selectQueueFilter('MY_QUEUES')" data-queue="MY_QUEUES" 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-amber-50 dark:bg-amber-950/50 text-amber-800 dark:text-amber-200 border border-amber-300 dark:border-amber-700/80 flex-shrink-0" title="Alleen tickets uit jouw gekoppelde wachtrijen (<?= htmlspecialchars(implode(', ', $userQueueFilters)) ?>)">
|
||
<i data-lucide="star" class="w-3.5 h-3.5 text-amber-500 fill-amber-500"></i>
|
||
<span>Mijn</span>
|
||
<span id="queueCount_MY_QUEUES" class="px-1.5 py-0.5 rounded-full text-[10px] bg-amber-200 dark:bg-amber-800 font-bold">0</span>
|
||
</button>
|
||
<?php endif; ?>
|
||
|
||
<button type="button" onclick="selectQueueFilter('SD')" data-queue="SD" class="queue-pill flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<span class="w-2 h-2 rounded-full bg-sky-500"></span>
|
||
<span>SD</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 type="button" onclick="selectQueueFilter('IN')" data-queue="IN" class="queue-pill flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<span class="w-2 h-2 rounded-full bg-amber-500"></span>
|
||
<span>IN</span>
|
||
<span id="queueCount_IN" class="px-1.5 py-0.5 rounded-full text-[10px] bg-amber-100 dark:bg-amber-950/60 text-amber-800 dark:text-amber-300 font-bold">0</span>
|
||
</button>
|
||
|
||
<button type="button" onclick="selectQueueFilter('AAN')" data-queue="AAN" class="queue-pill flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||
<span>AAN</span>
|
||
<span id="queueCount_AAN" class="px-1.5 py-0.5 rounded-full text-[10px] bg-emerald-100 dark:bg-emerald-950/60 text-emerald-800 dark:text-emerald-300 font-bold">0</span>
|
||
</button>
|
||
|
||
<button type="button" onclick="selectQueueFilter('GEN')" data-queue="GEN" class="queue-pill flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<span class="w-2 h-2 rounded-full bg-indigo-500"></span>
|
||
<span>GEN</span>
|
||
<span id="queueCount_GEN" 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>
|
||
|
||
<button type="button" onclick="selectQueueFilter('DX')" data-queue="DX" class="queue-pill flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>
|
||
<span>DX</span>
|
||
<span id="queueCount_DX" class="px-1.5 py-0.5 rounded-full text-[10px] bg-amber-100 dark:bg-amber-950/60 text-amber-800 dark:text-amber-300 font-bold">0</span>
|
||
</button>
|
||
|
||
<?php if ($isAdmin): ?>
|
||
<a href="/admin.php#projects" title="Wachtrijfilters beheren in Beheer" class="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer flex-shrink-0">
|
||
<i data-lucide="settings" class="w-3.5 h-3.5"></i>
|
||
</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rij 3: Status Multi-select Dropdown & Zoekveld -->
|
||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 relative z-30">
|
||
|
||
<!-- Linkerkant: Statussen multi-select dropdown -->
|
||
<div class="flex flex-wrap items-center gap-2 relative z-50">
|
||
<div class="relative z-50" id="statusDropdownWrapper">
|
||
<button
|
||
type="button"
|
||
onclick="toggleStatusDropdown()"
|
||
id="statusDropdownBtn"
|
||
class="flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-semibold bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 text-slate-800 dark:text-slate-200 transition cursor-pointer shadow-2xs"
|
||
aria-expanded="false"
|
||
>
|
||
<i data-lucide="check-square" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||
<span>Status:</span>
|
||
<span id="statusDropdownLabel" class="font-bold text-indigo-600 dark:text-indigo-400">Alle statussen</span>
|
||
<span id="statusDropdownBadge" class="hidden px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-indigo-100 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300"></span>
|
||
<i data-lucide="chevron-down" id="statusDropdownChevron" class="w-3.5 h-3.5 text-slate-400 transition-transform duration-200"></i>
|
||
</button>
|
||
|
||
<!-- Dropdown Menu met Checkboxes (Gegroepeerd per project) -->
|
||
<div
|
||
id="statusDropdownMenu"
|
||
class="hidden absolute left-0 top-full mt-2 w-80 sm:w-96 max-w-[95vw] bg-white dark:bg-slate-900 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-800 py-3 px-3.5 z-50 text-xs space-y-2.5"
|
||
>
|
||
<!-- Quick actions header -->
|
||
<div class="flex items-center justify-between pb-2 border-b border-slate-100 dark:border-slate-800">
|
||
<span class="font-bold text-slate-900 dark:text-white text-[11px] uppercase tracking-wider">Statussen per Project</span>
|
||
<div class="flex items-center space-x-1.5">
|
||
<button type="button" onclick="selectAllStatuses(true)" class="px-2 py-0.5 rounded-md text-[10px] font-bold text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950/60 transition cursor-pointer">
|
||
Alles
|
||
</button>
|
||
<span class="text-slate-300 dark:text-slate-700">•</span>
|
||
<button type="button" onclick="selectActiveStatusesOnly()" class="px-2 py-0.5 rounded-md text-[10px] font-bold text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-950/60 transition cursor-pointer" title="Alleen actieve taken (geen gesloten)">
|
||
Actief
|
||
</button>
|
||
<span class="text-slate-300 dark:text-slate-700">•</span>
|
||
<button type="button" onclick="selectAllStatuses(false)" class="px-2 py-0.5 rounded-md text-[10px] font-bold text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||
Geen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status Checkboxes List (scrollable, gegroepeerd per project) -->
|
||
<div class="max-h-80 overflow-y-auto space-y-2.5 pr-1 custom-scrollbar" id="statusCheckboxList">
|
||
<!-- Dynamisch gevuld via renderStatusCheckboxes() -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actieve filters reset knop als er iets gefilterd is -->
|
||
<button type="button" onclick="resetAllFilters()" id="btnResetFiltersInline" class="hidden flex items-center space-x-1 px-2.5 py-1.5 rounded-xl text-xs font-semibold text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||
<i data-lucide="rotate-ccw" class="w-3 h-3"></i>
|
||
<span>Filters wissen</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Rechterkant: Zoekveld -->
|
||
<div class="relative w-full sm:w-64">
|
||
<i data-lucide="search" class="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"></i>
|
||
<input
|
||
type="text"
|
||
id="jiraSearchInput"
|
||
placeholder="Zoek in alle tickets..."
|
||
oninput="applyFilters()"
|
||
class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl pl-7 pr-6 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||
>
|
||
<button onclick="clearSearch()" id="clearSearchBtn" class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-pointer">
|
||
<i data-lucide="x" class="w-3 h-3"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Tickets Container -->
|
||
<div id="ticketsContainer" class="relative z-10 space-y-3">
|
||
<!-- Wordt dynamisch gerenderd via JavaScript -->
|
||
</div>
|
||
|
||
<!-- Lege Toestand (No Results) -->
|
||
<div id="emptyStateContainer" class="hidden fociz-card p-12 text-center">
|
||
<div class="w-12 h-12 mx-auto rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-400 flex items-center justify-center mb-3">
|
||
<i data-lucide="inbox" class="w-6 h-6"></i>
|
||
</div>
|
||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Geen tickets gevonden</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
|
||
Er zijn geen Jira tickets die overeenkomen met de geselecteerde tab of zoekfilters.
|
||
</p>
|
||
<button onclick="resetAllFilters()" class="mt-4 px-4 py-2 rounded-xl text-xs font-semibold bg-indigo-600 hover:bg-indigo-700 text-white transition cursor-pointer">
|
||
Filters Wissen
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- MODAL 1: Inplannen in Dagplanner (planner.php) -->
|
||
<div id="planTaskModal" onclick="if(event.target === this) closePlanModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl p-6 transition transform">
|
||
<div class="flex items-center justify-between pb-4 border-b border-slate-100 dark:border-slate-800">
|
||
<div class="flex items-center space-x-2.5">
|
||
<div class="w-8 h-8 rounded-lg bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
|
||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Inplannen in Dagplanner</h3>
|
||
<p class="text-[11px] text-slate-400">Voeg toe aan je dagfocus op de tijdlijn</p>
|
||
</div>
|
||
</div>
|
||
<button onclick="closePlanModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 p-1.5 rounded-lg transition cursor-pointer">
|
||
<i data-lucide="x" class="w-4 h-4"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form id="planTaskForm" onsubmit="submitPlanTask(event)" class="mt-4 space-y-4">
|
||
<input type="hidden" id="planIssueKey" value="">
|
||
<input type="hidden" id="planIssueSummary" value="">
|
||
<input type="hidden" id="planIssueUrl" value="">
|
||
|
||
<!-- Ticket Info Preview met uitklapbare inhoud -->
|
||
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-950 border border-slate-200/80 dark:border-slate-800/80 space-y-2">
|
||
<div class="flex items-center space-x-2">
|
||
<span id="planTicketKeyBadge" class="font-mono text-xs font-bold text-indigo-600 dark:text-indigo-400">FOC-101</span>
|
||
<span id="planTicketProject" class="text-[11px] text-slate-400">• Fociz Platform</span>
|
||
<span id="planSpBadge" class="hidden ml-auto text-[10px] font-bold px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-500/10 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-500/20"></span>
|
||
</div>
|
||
<div id="planTicketSummaryText" class="text-xs font-semibold text-slate-800 dark:text-slate-200 line-clamp-2">
|
||
Tweestapsverificatie implementeren
|
||
</div>
|
||
<div id="planSpHint" class="hidden mt-1 text-[11px] text-violet-600 dark:text-violet-400 flex items-center space-x-1">
|
||
<i data-lucide="zap" class="w-3 h-3"></i>
|
||
<span id="planSpHintText"></span>
|
||
</div>
|
||
|
||
<!-- Uitklapbare beschrijving (10 regels verticaal scrollen) -->
|
||
<div class="pt-1.5 border-t border-slate-200/60 dark:border-slate-800/60">
|
||
<button type="button" onclick="togglePlanTicketDesc()" class="text-[11px] text-indigo-600 dark:text-indigo-400 hover:underline flex items-center space-x-1 font-medium cursor-pointer">
|
||
<i data-lucide="file-text" class="w-3 h-3"></i>
|
||
<span id="planTicketDescToggleText">Toon ticket inhoud</span>
|
||
<i data-lucide="chevron-down" id="planTicketDescChevron" class="w-3 h-3 transition-transform duration-200"></i>
|
||
</button>
|
||
<div id="planTicketDescContainer" class="hidden mt-2 p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-[11px] text-slate-700 dark:text-slate-300 max-h-40 overflow-y-auto leading-relaxed whitespace-pre-wrap select-text font-normal">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aanpasbare Taaktitel in Agenda -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-1">
|
||
<label for="planTaskTitleInput" class="block text-xs font-bold text-slate-700 dark:text-slate-300">Naam op de tijdlijn</label>
|
||
<button type="button" onclick="clearPlanTaskTitle()" class="text-[10px] text-slate-400 hover:text-rose-500 cursor-pointer flex items-center space-x-0.5" title="Titel wissen">
|
||
<i data-lucide="x-circle" class="w-3 h-3"></i>
|
||
<span>Wissen</span>
|
||
</button>
|
||
</div>
|
||
<div class="relative">
|
||
<input
|
||
type="text"
|
||
id="planTaskTitleInput"
|
||
required
|
||
placeholder="Bijv. Oppakken Jira item FOC-101"
|
||
class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl pl-3 pr-8 py-2 text-xs text-slate-900 dark:text-white font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||
>
|
||
<button type="button" onclick="clearPlanTaskTitle()" class="absolute right-2.5 top-2.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-pointer" title="Wissen">
|
||
<i data-lucide="x" class="w-3.5 h-3.5"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Datum & Slimme "Plan in open blok" knop -->
|
||
<div class="space-y-2 pt-1 border-t border-slate-100 dark:border-slate-800">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs font-bold text-slate-700 dark:text-slate-300">Datum & Tijdblok</span>
|
||
<button type="button" onclick="planInNextOpenBlockJira()" id="btnJiraPlanOpenBlock" class="inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-gradient-to-r from-indigo-600 to-sky-600 hover:from-indigo-500 hover:to-sky-500 text-white text-xs font-bold transition shadow-xs cursor-pointer active:scale-95" title="Vind automatisch het eerstvolgende vrije kwartierblok binnen werktijden">
|
||
<i data-lucide="sparkles" class="w-3.5 h-3.5"></i>
|
||
<span>Plan in open blok</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Open blok banner -->
|
||
<div id="jiraPlanOpenBlockNotice" class="hidden p-2.5 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-800 dark:text-emerald-300 text-[11px] flex items-center justify-between">
|
||
<div class="flex items-center space-x-1.5 min-w-0 flex-1 mr-2">
|
||
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 flex-shrink-0"></i>
|
||
<span id="jiraPlanOpenBlockNoticeText" class="truncate font-semibold"></span>
|
||
</div>
|
||
<button type="button" onclick="document.getElementById('jiraPlanOpenBlockNotice').classList.add('hidden')" class="p-0.5 text-emerald-600 dark:text-emerald-400 hover:text-emerald-800 rounded cursor-pointer">
|
||
<i data-lucide="x" class="w-3 h-3"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Datum Kiezer -->
|
||
<div>
|
||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
|
||
<input
|
||
type="date"
|
||
id="planDateInput"
|
||
value="<?= date('Y-m-d') ?>"
|
||
required
|
||
class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||
>
|
||
</div>
|
||
|
||
<!-- Tijdsblok Starttijd & Duur -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<div class="flex items-center justify-between mb-1">
|
||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">Starttijd (optioneel)</label>
|
||
<span id="planEndTimeBadge" class="text-[10px] text-indigo-600 dark:text-indigo-400 font-mono font-semibold"></span>
|
||
</div>
|
||
<div class="relative">
|
||
<input
|
||
type="time"
|
||
id="planStartTimeInput"
|
||
value="09:00"
|
||
oninput="updatePlanEndTimeBadge()"
|
||
class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs font-mono text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||
>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Verwachte Duur</label>
|
||
<select id="planDurationInput" onchange="updatePlanEndTimeBadge()" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 cursor-pointer">
|
||
<option value="15">15 minuten</option>
|
||
<option value="30">30 minuten</option>
|
||
<option value="45" selected>45 minuten</option>
|
||
<option value="60">1 uur (60 min)</option>
|
||
<option value="90">1,5 uur (90 min)</option>
|
||
<option value="120">2 uur (120 min)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prioriteit -->
|
||
<div>
|
||
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">Focus Prioriteit</label>
|
||
<div class="grid grid-cols-3 gap-2">
|
||
<label class="flex items-center justify-center p-2 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-bold cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 has-checked:border-red-500 has-checked:bg-red-50 dark:has-checked:bg-red-950/30 has-checked:text-red-600">
|
||
<input type="radio" name="planPriority" value="P1" class="sr-only">
|
||
<span>P1 • Hoog</span>
|
||
</label>
|
||
<label class="flex items-center justify-center p-2 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-bold cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 has-checked:border-amber-500 has-checked:bg-amber-50 dark:has-checked:bg-amber-950/30 has-checked:text-amber-600">
|
||
<input type="radio" name="planPriority" value="P2" checked class="sr-only">
|
||
<span>P2 • Normaal</span>
|
||
</label>
|
||
<label class="flex items-center justify-center p-2 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-bold cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 has-checked:border-slate-500 has-checked:bg-slate-100 dark:has-checked:bg-slate-800 has-checked:text-slate-800 dark:has-checked:text-slate-200">
|
||
<input type="radio" name="planPriority" value="P3" class="sr-only">
|
||
<span>P3 • Laag</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Knoppen -->
|
||
<div class="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||
<button type="button" onclick="closePlanModal()" class="px-4 py-2 rounded-xl text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||
Annuleren
|
||
</button>
|
||
<button type="submit" id="planSubmitBtn" class="flex items-center space-x-1.5 px-4 py-2 rounded-xl text-xs font-bold bg-indigo-600 hover:bg-indigo-700 text-white transition cursor-pointer shadow-sm shadow-indigo-600/20">
|
||
<i data-lucide="check" class="w-3.5 h-3.5"></i>
|
||
<span>In Dagplanner Plakken</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Waarschuwing Buiten Werktijden (Jira) -->
|
||
<div id="outsideWorkhoursModalJira" onclick="if(event.target === this) closeOutsideWorkhoursModalJira('CANCEL')" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||
<div class="bg-white dark:bg-slate-900 border border-amber-500/30 rounded-2xl w-full max-w-md shadow-2xl p-6 space-y-4">
|
||
<div class="flex items-start space-x-3">
|
||
<div class="w-10 h-10 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-500 flex items-center justify-center flex-shrink-0">
|
||
<i data-lucide="alert-triangle" class="w-5 h-5"></i>
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<h3 class="text-base font-bold text-slate-900 dark:text-white leading-tight">
|
||
Let op! Je plant buiten werktijden
|
||
</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||
Het ingeplande tijdblok valt buiten de gezette werktijden.
|
||
</p>
|
||
</div>
|
||
<button type="button" onclick="closeOutsideWorkhoursModalJira('CANCEL')" class="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg transition cursor-pointer flex-shrink-0">
|
||
<i data-lucide="x" class="w-4 h-4"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800/40 rounded-xl p-3.5 text-xs text-amber-800 dark:text-amber-300 space-y-1.5">
|
||
<div class="flex items-center justify-between text-[11px] font-semibold text-amber-900 dark:text-amber-200">
|
||
<span>Gekozen tijdblok:</span>
|
||
<span id="outsideWorkhoursSelectedSlotJira" class="font-mono font-bold"></span>
|
||
</div>
|
||
<div class="flex items-center justify-between text-[11px] text-amber-700 dark:text-amber-400">
|
||
<span>Gezette werktijden:</span>
|
||
<span id="outsideWorkhoursBoundsJira" class="font-mono font-semibold">08:30 – 17:00</span>
|
||
</div>
|
||
<p class="text-[11px] text-amber-700/80 dark:text-amber-400/80 pt-1 border-t border-amber-200/60 dark:border-amber-800/30">
|
||
Kies <strong>Nee</strong> om het item automatisch in te plannen in het eerstvolgende open blok op de volgende werkdag, of kies <strong>Ja</strong> om toch nu buiten werktijd te plannen.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex items-center space-x-2.5 pt-2">
|
||
<button type="button" id="btnOutsideWorkhoursNoJira" onclick="closeOutsideWorkhoursModalJira('NEE')" class="flex-1 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs transition shadow-xs flex items-center justify-center space-x-1.5 cursor-pointer">
|
||
<i data-lucide="calendar-arrow-up" class="w-4 h-4"></i>
|
||
<span>Nee (Volgende werkdag)</span>
|
||
</button>
|
||
<button type="button" id="btnOutsideWorkhoursYesJira" onclick="closeOutsideWorkhoursModalJira('JA')" class="px-4 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 font-semibold text-xs transition cursor-pointer">
|
||
<span>Ja, toch inplannen</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MODAL 2: Toevoegen aan Kanban Bord (board.php) -->
|
||
<div id="boardTaskModal" onclick="if(event.target === this) closeBoardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-md shadow-2xl p-6 transition transform">
|
||
<div class="flex items-center justify-between pb-4 border-b border-slate-100 dark:border-slate-800">
|
||
<div class="flex items-center space-x-2.5">
|
||
<div class="w-8 h-8 rounded-lg bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
|
||
<i data-lucide="kanban" class="w-4 h-4"></i>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Plaats op Kanban Bord</h3>
|
||
<p class="text-[11px] text-slate-400">Maak een post-it kaart aan</p>
|
||
</div>
|
||
</div>
|
||
<button onclick="closeBoardModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 p-1.5 rounded-lg transition cursor-pointer">
|
||
<i data-lucide="x" class="w-4 h-4"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form id="boardTaskForm" onsubmit="submitBoardTask(event)" class="mt-4 space-y-4">
|
||
<input type="hidden" id="boardIssueKey" value="">
|
||
<input type="hidden" id="boardIssueSummary" value="">
|
||
<input type="hidden" id="boardIssueUrl" value="">
|
||
<input type="hidden" id="boardIssueProject" value="">
|
||
|
||
<!-- Ticket Info Preview -->
|
||
<div class="p-3 rounded-xl bg-slate-50 dark:bg-slate-950 border border-slate-200/80 dark:border-slate-800/80">
|
||
<div class="flex items-center space-x-2">
|
||
<span id="boardTicketKeyBadge" class="font-mono text-xs font-bold text-emerald-600 dark:text-emerald-400">FOC-101</span>
|
||
<span id="boardTicketProject" class="text-[11px] text-slate-400">• Fociz Platform</span>
|
||
</div>
|
||
<div id="boardTicketSummaryText" class="text-xs font-semibold text-slate-800 dark:text-slate-200 mt-1 line-clamp-2">
|
||
Tweestapsverificatie implementeren
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bord Selectie -->
|
||
<div>
|
||
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">Kies Kanban Bord</label>
|
||
<select id="boardSelectInput" onchange="updateLaneOptions()" required class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/40 cursor-pointer">
|
||
<?php if (empty($boards)): ?>
|
||
<option value="">Geen borden beschikbaar</option>
|
||
<?php else: ?>
|
||
<?php foreach ($boards as $b): ?>
|
||
<option value="<?= (int)$b['id'] ?>"><?= htmlspecialchars($b['title']) ?></option>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Kolom / Lane Selectie -->
|
||
<div>
|
||
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">Kies Kolom / Fase</label>
|
||
<select id="laneSelectInput" required class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/40 cursor-pointer">
|
||
<!-- Wordt dynamisch gevuld -->
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Kaartkleur & Prioriteit -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">Kaartkleur</label>
|
||
<select id="cardColorInput" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/40 cursor-pointer">
|
||
<option value="blue" selected>Blauw (Standaard)</option>
|
||
<option value="yellow">Geel</option>
|
||
<option value="green">Groen</option>
|
||
<option value="purple">Paars</option>
|
||
<option value="red">Rood</option>
|
||
<option value="pink">Roze</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
|
||
<select id="cardPriorityInput" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/40 cursor-pointer">
|
||
<option value="P1">P1 • Hoog</option>
|
||
<option value="P2" selected>P2 • Normaal</option>
|
||
<option value="P3">P3 • Laag</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Knoppen -->
|
||
<div class="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||
<button type="button" onclick="closeBoardModal()" class="px-4 py-2 rounded-xl text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||
Annuleren
|
||
</button>
|
||
<button type="submit" id="boardSubmitBtn" class="flex items-center space-x-1.5 px-4 py-2 rounded-xl text-xs font-bold bg-emerald-600 hover:bg-emerald-700 text-white transition cursor-pointer shadow-sm shadow-emerald-600/20">
|
||
<i data-lucide="plus-circle" class="w-3.5 h-3.5"></i>
|
||
<span>Plaats op Bord</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast Notificatie Element -->
|
||
<div id="jiraToast" class="fixed bottom-6 right-6 z-50 transform translate-y-24 opacity-0 pointer-events-none transition-all duration-300 flex items-center space-x-3 p-4 rounded-2xl bg-slate-900/95 dark:bg-white text-white dark:text-slate-900 shadow-2xl border border-slate-800 dark:border-slate-200 max-w-md">
|
||
<div id="toastIconWrapper" class="w-8 h-8 rounded-xl bg-emerald-500/20 text-emerald-400 dark:text-emerald-600 flex items-center justify-center flex-shrink-0">
|
||
<i data-lucide="check" class="w-4 h-4"></i>
|
||
</div>
|
||
<div class="flex-1 text-xs">
|
||
<p id="toastMessage" class="font-bold">Ticket succesvol verwerkt</p>
|
||
<p id="toastSubMessage" class="text-slate-400 dark:text-slate-600 text-[11px] mt-0.5"></p>
|
||
</div>
|
||
<a id="toastActionLink" href="#" class="hidden px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-bold transition flex-shrink-0">
|
||
Bekijken
|
||
</a>
|
||
</div>
|
||
|
||
<script>
|
||
// Data vanuit PHP
|
||
const jiraData = <?= $jiraDataJson ?>;
|
||
const availableBoards = <?= $boardsJson ?>;
|
||
const projectStatusesData = <?= $projectStatusesJson ?>;
|
||
const allJiraStatuses = <?= $statusesJson ?>;
|
||
|
||
let currentProject = '';
|
||
let currentQueue = (jiraData.user_queue_filters && jiraData.user_queue_filters.length > 0) ? 'MY_QUEUES' : (jiraData.default_queue_filter || '');
|
||
let selectedStatuses = new Set();
|
||
let activeUrgentOnly = false;
|
||
let expandedKeys = new Set();
|
||
const ticketStore = new Map();
|
||
|
||
// Hulpfunctie voor deduplicatie van tickets (assigned + team)
|
||
function getDeduplicatedIssues() {
|
||
const assigned = jiraData.assigned_issues || [];
|
||
const team = jiraData.team_issues || [];
|
||
const seen = new Set();
|
||
const list = [];
|
||
[...assigned, ...team].forEach(iss => {
|
||
if (iss && iss.key && !seen.has(iss.key)) {
|
||
seen.add(iss.key);
|
||
list.push(iss);
|
||
}
|
||
});
|
||
return list;
|
||
}
|
||
|
||
function isDoneStatus(st) {
|
||
const s = (st || '').toLowerCase().trim();
|
||
return s.includes('done') || s.includes('gesloten') || s.includes('afgerond') || s.includes('gereed') || s.includes('closed') || s.includes('resolved');
|
||
}
|
||
|
||
function isInProgressStatus(st, sc) {
|
||
const s = (st || '').toLowerCase().trim();
|
||
const c = (sc || '').toLowerCase().trim();
|
||
return c === 'indeterminate' || s.includes('progress') || s.includes('bezig') || s.includes('review') || s.includes('uitvoering') || s.includes('behandeling') || s.includes('wachten');
|
||
}
|
||
|
||
// Bereken statussen en KPI statistieken voor de drawer
|
||
function calculateKpis() {
|
||
const all = getDeduplicatedIssues();
|
||
const assigned = jiraData.assigned_issues || [];
|
||
const team = jiraData.team_issues || [];
|
||
|
||
let inProgressCount = 0;
|
||
let highPriorityCount = 0;
|
||
|
||
all.forEach(iss => {
|
||
const s = (iss.status || '').toLowerCase();
|
||
const sc = (iss.status_category || '').toLowerCase();
|
||
const p = (iss.priority || '').toLowerCase();
|
||
|
||
if (isInProgressStatus(s, sc)) {
|
||
inProgressCount++;
|
||
}
|
||
if (p === 'highest' || p === 'high' || p === 'p1') {
|
||
highPriorityCount++;
|
||
}
|
||
});
|
||
|
||
const elStatAssigned = document.getElementById('statAssignedCount');
|
||
const elStatTeam = document.getElementById('statTeamCount');
|
||
const elStatInProg = document.getElementById('statInProgressCount');
|
||
const elStatHigh = document.getElementById('statHighPriorityCount');
|
||
|
||
if (elStatAssigned) elStatAssigned.innerText = assigned.length;
|
||
if (elStatTeam) elStatTeam.innerText = team.length;
|
||
if (elStatInProg) elStatInProg.innerText = inProgressCount;
|
||
if (elStatHigh) elStatHigh.innerText = highPriorityCount;
|
||
|
||
updateProjectCounts();
|
||
updateQueueCounts();
|
||
}
|
||
|
||
// Drawer open/dicht
|
||
function toggleJiraFiltersDrawer(forceOpen) {
|
||
const drawer = document.getElementById('jiraFiltersDrawer');
|
||
const chevron = document.getElementById('filtersDrawerChevron');
|
||
if (!drawer) return;
|
||
|
||
const willOpen = (typeof forceOpen === 'boolean') ? forceOpen : drawer.classList.contains('hidden');
|
||
if (willOpen) {
|
||
drawer.classList.remove('hidden');
|
||
if (chevron) chevron.classList.add('rotate-180');
|
||
localStorage.setItem('fociz_jira_filters_open', '1');
|
||
} else {
|
||
drawer.classList.add('hidden');
|
||
if (chevron) chevron.classList.remove('rotate-180');
|
||
localStorage.setItem('fociz_jira_filters_open', '0');
|
||
}
|
||
}
|
||
|
||
// Bepaal aantal actieve filters voor de badge in de rechter onderhoek
|
||
function updateActiveFilterBadge() {
|
||
const badge = document.getElementById('activeFilterBadge');
|
||
if (!badge) return;
|
||
|
||
let activeCount = 0;
|
||
if (currentProject) activeCount++;
|
||
if (currentQueue && currentQueue !== 'MY_QUEUES') activeCount++;
|
||
if (document.getElementById('filterType')?.value) activeCount++;
|
||
if (document.getElementById('filterPriority')?.value) activeCount++;
|
||
const total = getTotalStatusCount();
|
||
if (selectedStatuses.size > 0 && selectedStatuses.size < total) activeCount++;
|
||
|
||
if (activeCount > 0) {
|
||
badge.innerText = activeCount;
|
||
badge.classList.remove('hidden');
|
||
} else {
|
||
badge.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// STATUS MULTI-SELECT CHECKBOX DROPDOWN LOGICA (GEGROEPEERD PER PROJECT)
|
||
// =========================================================================
|
||
|
||
function getTotalStatusCount() {
|
||
let total = 0;
|
||
(projectStatusesData || []).forEach(p => {
|
||
total += (p.statuses || []).length;
|
||
});
|
||
return total;
|
||
}
|
||
|
||
// Hulpfunctie: Bepaal of de status van een ticket geselecteerd is (ondersteunt compound keys en statusnamen)
|
||
function isIssueStatusSelected(iss) {
|
||
const total = getTotalStatusCount();
|
||
if (selectedStatuses.size === 0) return false;
|
||
if (selectedStatuses.size >= total) return true;
|
||
|
||
let issPKey = (iss.project_key || '').toUpperCase().trim();
|
||
if (!issPKey && iss.key) {
|
||
const m = iss.key.match(/^([A-Z0-9]+)-/i);
|
||
if (m) issPKey = m[1].toUpperCase();
|
||
}
|
||
const issStatus = (iss.status || '').toLowerCase().trim();
|
||
const compound = issPKey ? `${issPKey.toLowerCase()}::${issStatus}` : '';
|
||
|
||
if (compound && selectedStatuses.has(compound)) return true;
|
||
if (selectedStatuses.has(issStatus)) return true;
|
||
return false;
|
||
}
|
||
|
||
function renderStatusCheckboxes() {
|
||
const container = document.getElementById('statusCheckboxList');
|
||
if (!container) return;
|
||
|
||
if (!projectStatusesData || projectStatusesData.length === 0) {
|
||
container.innerHTML = '<div class="py-2 text-center text-slate-400 italic text-[11px]">Geen project statussen gevonden</div>';
|
||
return;
|
||
}
|
||
|
||
const colorClasses = {
|
||
sky: {
|
||
badge: 'bg-sky-100 dark:bg-sky-900/60 text-sky-800 dark:text-sky-200 border border-sky-300 dark:border-sky-800',
|
||
hdrBg: 'bg-sky-50/60 dark:bg-sky-950/30'
|
||
},
|
||
amber: {
|
||
badge: 'bg-amber-100 dark:bg-amber-900/60 text-amber-800 dark:text-amber-200 border border-amber-300 dark:border-amber-800',
|
||
hdrBg: 'bg-amber-50/60 dark:bg-amber-950/30'
|
||
},
|
||
emerald: {
|
||
badge: 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-800 dark:text-emerald-200 border border-emerald-300 dark:border-emerald-800',
|
||
hdrBg: 'bg-emerald-50/60 dark:bg-emerald-950/30'
|
||
},
|
||
indigo: {
|
||
badge: 'bg-indigo-100 dark:bg-indigo-900/60 text-indigo-800 dark:text-indigo-200 border border-indigo-300 dark:border-indigo-800',
|
||
hdrBg: 'bg-indigo-50/60 dark:bg-indigo-950/30'
|
||
},
|
||
slate: {
|
||
badge: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200 border border-slate-300 dark:border-slate-700',
|
||
hdrBg: 'bg-slate-50 dark:bg-slate-800/40'
|
||
}
|
||
};
|
||
|
||
let html = '';
|
||
projectStatusesData.forEach((proj, pIdx) => {
|
||
const pKey = (proj.key || '').toUpperCase();
|
||
const pKeyLower = pKey.toLowerCase();
|
||
const pColor = proj.color || 'slate';
|
||
const styles = colorClasses[pColor] || colorClasses.slate;
|
||
const statuses = proj.statuses || [];
|
||
|
||
html += `
|
||
<div class="project-status-group pb-2 mb-2 border-b border-slate-100 dark:border-slate-800/80 last:border-b-0 last:pb-0 last:mb-0">
|
||
<div class="flex items-center justify-between px-2 py-1.5 mb-1.5 rounded-lg ${styles.hdrBg}">
|
||
<div class="flex items-center space-x-2 min-w-0">
|
||
<span class="px-1.5 py-0.5 rounded font-mono text-[10px] font-black ${styles.badge}">${escapeHtml(pKey)}</span>
|
||
<span class="font-bold text-slate-800 dark:text-slate-200 text-xs truncate">${escapeHtml(proj.name || pKey)}</span>
|
||
<span id="projTotal_${pKey}" class="text-[10px] text-slate-400 font-bold">(0)</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1.5 text-[10px] flex-shrink-0">
|
||
<button type="button" onclick="selectProjectStatuses('${escapeHtml(pKey)}', true)" class="px-1.5 py-0.5 rounded font-semibold text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950/60 transition cursor-pointer">Alles</button>
|
||
<span class="text-slate-300 dark:text-slate-700">•</span>
|
||
<button type="button" onclick="selectProjectStatuses('${escapeHtml(pKey)}', false)" class="px-1.5 py-0.5 rounded font-semibold text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition cursor-pointer">Geen</button>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-0.5 pl-1">
|
||
`;
|
||
|
||
statuses.forEach((st, sIdx) => {
|
||
const sNorm = (st.name || '').toLowerCase().trim();
|
||
const compoundVal = `${pKeyLower}::${sNorm}`;
|
||
const cat = (st.status_category || '').toLowerCase().trim();
|
||
const isDone = (cat === 'done' || isDoneStatus(sNorm));
|
||
|
||
const isChecked = selectedStatuses.has(compoundVal) || selectedStatuses.has(sNorm);
|
||
if (isChecked) {
|
||
selectedStatuses.add(compoundVal);
|
||
}
|
||
|
||
let dotColor = 'bg-blue-500';
|
||
if (cat === 'new' || sNorm.includes('to do') || sNorm.includes('open') || sNorm.includes('nieuw')) {
|
||
dotColor = 'bg-slate-400';
|
||
} else if (isDone) {
|
||
dotColor = 'bg-emerald-500';
|
||
} else if (sNorm.includes('wacht') || sNorm.includes('klant') || sNorm.includes('pending')) {
|
||
dotColor = 'bg-amber-500';
|
||
}
|
||
|
||
const safeKey = `${pKey}_${sNorm.replace(/[^a-z0-9]/gi, '_')}`;
|
||
const safeId = `chkStatus_${pIdx}_${sIdx}`;
|
||
|
||
html += `
|
||
<label for="${safeId}" class="flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800/70 cursor-pointer transition select-none group">
|
||
<div class="flex items-center space-x-2.5 min-w-0 flex-1">
|
||
<input
|
||
type="checkbox"
|
||
id="${safeId}"
|
||
value="${escapeHtml(compoundVal)}"
|
||
data-project="${escapeHtml(pKey)}"
|
||
data-status="${escapeHtml(sNorm)}"
|
||
data-is-done="${isDone ? 1 : 0}"
|
||
${isChecked ? 'checked' : ''}
|
||
onchange="onStatusCheckboxChange()"
|
||
class="status-checkbox w-4 h-4 rounded text-indigo-600 border-slate-300 dark:border-slate-700 focus:ring-indigo-500 cursor-pointer flex-shrink-0"
|
||
>
|
||
<span class="w-2 h-2 rounded-full ${dotColor} flex-shrink-0"></span>
|
||
<span class="font-semibold text-slate-700 dark:text-slate-300 truncate text-xs group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition">${escapeHtml(st.name)}</span>
|
||
</div>
|
||
<span id="statusCount_${safeKey}" class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 ml-2 flex-shrink-0">0</span>
|
||
</label>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
updateStatusCounts();
|
||
}
|
||
|
||
function updateStatusCounts() {
|
||
const all = getDeduplicatedIssues();
|
||
|
||
(projectStatusesData || []).forEach(proj => {
|
||
const pKey = (proj.key || '').toUpperCase().trim();
|
||
let projIssues = all.filter(iss => {
|
||
let k = (iss.project_key || '').toUpperCase().trim();
|
||
if (!k && iss.key) {
|
||
const parts = iss.key.split('-');
|
||
if (parts.length > 1) k = parts[0].toUpperCase().trim();
|
||
}
|
||
const pn = (iss.project || '').toUpperCase().trim();
|
||
return k === pKey || pn.includes(pKey);
|
||
});
|
||
|
||
// Wachtrij filter meenemen indien actief
|
||
if (currentQueue) {
|
||
projIssues = projIssues.filter(iss => isIssueInQueue(iss, currentQueue));
|
||
}
|
||
|
||
const projTotalEl = document.getElementById(`projTotal_${pKey}`);
|
||
if (projTotalEl) {
|
||
projTotalEl.innerText = `(${projIssues.length})`;
|
||
}
|
||
|
||
(proj.statuses || []).forEach(st => {
|
||
const sNorm = (st.name || '').toLowerCase().trim();
|
||
const safeKey = `${pKey}_${sNorm.replace(/[^a-z0-9]/gi, '_')}`;
|
||
const el = document.getElementById(`statusCount_${safeKey}`);
|
||
if (el) {
|
||
const cnt = projIssues.filter(iss => (iss.status || '').toLowerCase().trim() === sNorm).length;
|
||
el.innerText = cnt;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function toggleStatusDropdown(force) {
|
||
const menu = document.getElementById('statusDropdownMenu');
|
||
const chevron = document.getElementById('statusDropdownChevron');
|
||
const btn = document.getElementById('statusDropdownBtn');
|
||
if (!menu) return;
|
||
|
||
const willOpen = (typeof force === 'boolean') ? force : menu.classList.contains('hidden');
|
||
if (willOpen) {
|
||
menu.classList.remove('hidden');
|
||
if (chevron) chevron.classList.add('rotate-180');
|
||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||
updateStatusCounts();
|
||
} else {
|
||
menu.classList.add('hidden');
|
||
if (chevron) chevron.classList.remove('rotate-180');
|
||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', (e) => {
|
||
const wrapper = document.getElementById('statusDropdownWrapper');
|
||
if (wrapper && !wrapper.contains(e.target)) {
|
||
toggleStatusDropdown(false);
|
||
}
|
||
});
|
||
|
||
function onStatusCheckboxChange() {
|
||
selectedStatuses.clear();
|
||
const checked = document.querySelectorAll('.status-checkbox:checked');
|
||
checked.forEach(chk => {
|
||
if (chk.value) selectedStatuses.add(chk.value.toLowerCase().trim());
|
||
});
|
||
|
||
try {
|
||
localStorage.setItem('fociz_jira_selected_statuses', JSON.stringify([...selectedStatuses]));
|
||
} catch (e) {}
|
||
|
||
updateStatusDropdownLabel();
|
||
applyFilters();
|
||
}
|
||
|
||
function selectProjectStatuses(projectKey, selectAll) {
|
||
const targetKey = (projectKey || '').toUpperCase().trim();
|
||
const chks = document.querySelectorAll(`.status-checkbox[data-project="${targetKey}"]`);
|
||
chks.forEach(chk => {
|
||
chk.checked = !!selectAll;
|
||
const val = chk.value.toLowerCase().trim();
|
||
if (selectAll) {
|
||
selectedStatuses.add(val);
|
||
} else {
|
||
selectedStatuses.delete(val);
|
||
}
|
||
});
|
||
|
||
try {
|
||
localStorage.setItem('fociz_jira_selected_statuses', JSON.stringify([...selectedStatuses]));
|
||
} catch (e) {}
|
||
|
||
updateStatusDropdownLabel();
|
||
applyFilters();
|
||
}
|
||
|
||
function selectAllStatuses(selectAll) {
|
||
selectedStatuses.clear();
|
||
const chks = document.querySelectorAll('.status-checkbox');
|
||
chks.forEach(chk => {
|
||
chk.checked = !!selectAll;
|
||
if (selectAll && chk.value) {
|
||
selectedStatuses.add(chk.value.toLowerCase().trim());
|
||
}
|
||
});
|
||
|
||
try {
|
||
localStorage.setItem('fociz_jira_selected_statuses', JSON.stringify([...selectedStatuses]));
|
||
} catch (e) {}
|
||
|
||
updateStatusDropdownLabel();
|
||
applyFilters();
|
||
}
|
||
|
||
function selectActiveStatusesOnly() {
|
||
selectedStatuses.clear();
|
||
const chks = document.querySelectorAll('.status-checkbox');
|
||
chks.forEach(chk => {
|
||
const isDone = chk.getAttribute('data-is-done') === '1';
|
||
chk.checked = !isDone;
|
||
if (!isDone && chk.value) {
|
||
selectedStatuses.add(chk.value.toLowerCase().trim());
|
||
}
|
||
});
|
||
|
||
try {
|
||
localStorage.setItem('fociz_jira_selected_statuses', JSON.stringify([...selectedStatuses]));
|
||
} catch (e) {}
|
||
|
||
updateStatusDropdownLabel();
|
||
applyFilters();
|
||
}
|
||
|
||
function updateStatusDropdownLabel() {
|
||
const labelEl = document.getElementById('statusDropdownLabel');
|
||
const badgeEl = document.getElementById('statusDropdownBadge');
|
||
if (!labelEl) return;
|
||
|
||
const total = getTotalStatusCount();
|
||
const selected = selectedStatuses.size;
|
||
|
||
if (selected === total || (total === 0 && selected === 0)) {
|
||
labelEl.innerText = "Alle statussen";
|
||
if (badgeEl) badgeEl.classList.add('hidden');
|
||
} else if (selected === 0) {
|
||
labelEl.innerText = "Geen geselecteerd";
|
||
if (badgeEl) {
|
||
badgeEl.innerText = "0";
|
||
badgeEl.classList.remove('hidden');
|
||
badgeEl.className = "px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-100 dark:bg-rose-950/60 text-rose-700 dark:text-rose-300";
|
||
}
|
||
} else {
|
||
labelEl.innerText = `${selected} statussen`;
|
||
if (badgeEl) {
|
||
badgeEl.innerText = `${selected}/${total}`;
|
||
badgeEl.classList.remove('hidden');
|
||
badgeEl.className = "px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-indigo-100 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300";
|
||
}
|
||
}
|
||
|
||
const btnReset = document.getElementById('btnResetFiltersInline');
|
||
if (btnReset) {
|
||
const isCustom = selected < total || currentProject !== '' || (currentQueue !== '' && currentQueue !== 'MY_QUEUES') || document.getElementById('jiraSearchInput')?.value;
|
||
if (isCustom) btnReset.classList.remove('hidden');
|
||
else btnReset.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function initStatusSelection() {
|
||
selectedStatuses = new Set();
|
||
let saved = null;
|
||
try {
|
||
const raw = localStorage.getItem('fociz_jira_selected_statuses');
|
||
if (raw) saved = JSON.parse(raw);
|
||
} catch (e) {}
|
||
|
||
let validSaved = false;
|
||
if (Array.isArray(saved) && saved.length > 0) {
|
||
const hasCompound = saved.some(s => String(s).includes('::'));
|
||
if (hasCompound) {
|
||
saved.forEach(s => {
|
||
if (s) selectedStatuses.add(String(s).toLowerCase().trim());
|
||
});
|
||
validSaved = true;
|
||
}
|
||
}
|
||
|
||
if (!validSaved) {
|
||
// Standaard alle actieve statussen selecteren, gesloten uit conform gebruikerswens
|
||
(projectStatusesData || []).forEach(proj => {
|
||
const pKeyLower = (proj.key || '').toLowerCase().trim();
|
||
(proj.statuses || []).forEach(st => {
|
||
const sNorm = (st.name || '').toLowerCase().trim();
|
||
const cat = (st.status_category || '').toLowerCase().trim();
|
||
const isDone = (cat === 'done' || isDoneStatus(sNorm));
|
||
if (!isDone) {
|
||
selectedStatuses.add(`${pKeyLower}::${sNorm}`);
|
||
}
|
||
});
|
||
});
|
||
if (selectedStatuses.size === 0) {
|
||
(projectStatusesData || []).forEach(proj => {
|
||
const pKeyLower = (proj.key || '').toLowerCase().trim();
|
||
(proj.statuses || []).forEach(st => {
|
||
const sNorm = (st.name || '').toLowerCase().trim();
|
||
selectedStatuses.add(`${pKeyLower}::${sNorm}`);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
renderStatusCheckboxes();
|
||
updateStatusDropdownLabel();
|
||
}
|
||
|
||
// Project selectie via pill tabs
|
||
function selectProject(proj) {
|
||
currentProject = (proj || '').trim();
|
||
updateProjectPills();
|
||
applyFilters();
|
||
}
|
||
|
||
function updateProjectPills() {
|
||
const pills = document.querySelectorAll('.project-pill');
|
||
const curNorm = (currentProject || '').toUpperCase().trim();
|
||
pills.forEach(p => {
|
||
const pKey = (p.getAttribute('data-project') || '').toUpperCase().trim();
|
||
const isSelected = (pKey === curNorm);
|
||
|
||
if (pKey === '') {
|
||
p.className = isSelected
|
||
? "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-xs flex-shrink-0"
|
||
: "project-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 border border-slate-200 dark:border-slate-700 flex-shrink-0";
|
||
} else if (pKey === 'SD') {
|
||
p.className = isSelected
|
||
? "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-sky-600 text-white shadow-xs flex-shrink-0"
|
||
: "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-sky-50 dark:bg-sky-950/40 text-sky-800 dark:text-sky-300 hover:bg-sky-100 dark:hover:bg-sky-900/60 border border-sky-300 dark:border-sky-800 flex-shrink-0";
|
||
} else if (pKey === 'IN') {
|
||
p.className = isSelected
|
||
? "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-amber-600 text-white shadow-xs flex-shrink-0"
|
||
: "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-amber-50 dark:bg-amber-950/40 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/60 border border-amber-300 dark:border-amber-800 flex-shrink-0";
|
||
} else if (pKey === 'AAN') {
|
||
p.className = isSelected
|
||
? "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-emerald-600 text-white shadow-xs flex-shrink-0"
|
||
: "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 border border-emerald-300 dark:border-emerald-800 flex-shrink-0";
|
||
} else if (pKey === 'GEN') {
|
||
p.className = isSelected
|
||
? "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-600 text-white shadow-xs flex-shrink-0"
|
||
: "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-50 dark:bg-indigo-950/40 text-indigo-800 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/60 border border-indigo-300 dark:border-indigo-800 flex-shrink-0";
|
||
} else {
|
||
p.className = isSelected
|
||
? "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-600 text-white shadow-xs flex-shrink-0"
|
||
: "project-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0";
|
||
}
|
||
});
|
||
}
|
||
|
||
// Hulpfunctie: Match een issue tegen een JQL clausule
|
||
function matchJqlClause(iss, jql) {
|
||
if (!jql || typeof jql !== 'string') return true;
|
||
const trimmed = jql.trim();
|
||
if (!trimmed) return true;
|
||
|
||
const issPKey = getIssueProjectKey(iss);
|
||
const issPName = (iss.project || '').toLowerCase().trim();
|
||
const issOl = (iss.onderwerp_logging || '').toLowerCase().trim();
|
||
const issSum = (iss.summary || '').toLowerCase();
|
||
const issPriority = (iss.priority || '').toLowerCase().trim();
|
||
const issType = (iss.issue_type || '').toLowerCase().trim();
|
||
const issStatus = (iss.status || '').toLowerCase().trim();
|
||
|
||
// Normalisatie helper voor onderwerpteksten (verwijder leestekens, spaties, streepjes voor flexibele matching)
|
||
function normalizeText(str) {
|
||
if (!str) return '';
|
||
return str.toLowerCase().replace(/["']/g, '').replace(/[-_\s]/g, '').trim();
|
||
}
|
||
|
||
// Splits condities op AND (hoofdletterongevoelig)
|
||
const conditions = trimmed.split(/\s+AND\s+/i);
|
||
|
||
for (let rawCond of conditions) {
|
||
const cond = rawCond.trim();
|
||
if (!cond) continue;
|
||
|
||
// --- 1. Project condities ---
|
||
// project = "SD" of project = SD
|
||
const projEqMatch = cond.match(/^project\s*=\s*["']?([A-Z0-9_-]+)["']?$/i);
|
||
if (projEqMatch) {
|
||
const req = projEqMatch[1].toUpperCase().trim();
|
||
if (issPKey !== req && !issPName.includes(req.toLowerCase())) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// project != "SD" of project != SD
|
||
const projNeqMatch = cond.match(/^project\s*!=\s*["']?([A-Z0-9_-]+)["']?$/i);
|
||
if (projNeqMatch) {
|
||
const notReq = projNeqMatch[1].toUpperCase().trim();
|
||
if (issPKey === notReq || issPName.includes(notReq.toLowerCase())) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// project in ("SD", "IN")
|
||
const projInMatch = cond.match(/^project\s+in\s*\(([^)]+)\)$/i);
|
||
if (projInMatch) {
|
||
const allowed = projInMatch[1].split(',').map(s => s.replace(/["']/g, '').toUpperCase().trim());
|
||
if (!allowed.includes(issPKey) && !allowed.some(p => issPName.includes(p.toLowerCase()))) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// project not in ("SD", "IN")
|
||
const projNotInMatch = cond.match(/^project\s+not\s+in\s*\(([^)]+)\)$/i);
|
||
if (projNotInMatch) {
|
||
const disallowed = projNotInMatch[1].split(',').map(s => s.replace(/["']/g, '').toUpperCase().trim());
|
||
if (disallowed.includes(issPKey) || disallowed.some(p => issPName.includes(p.toLowerCase()))) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// --- 2. Onderwerp logging condities ---
|
||
// bv: "Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)
|
||
const olInMatch = cond.match(/["']?(?:onderwerp(?:[\w\s\[\]_-]*)?|customfield_\d+)["']?\s+in\s*\(([^)]+)\)/i);
|
||
if (olInMatch) {
|
||
const allowedTopics = olInMatch[1].split(',').map(s => normalizeText(s)).filter(Boolean);
|
||
const normOl = normalizeText(issOl);
|
||
let matched = false;
|
||
if (normOl) {
|
||
matched = allowedTopics.some(t => normOl === t || normOl.includes(t) || t.includes(normOl));
|
||
}
|
||
if (!matched && !normOl) {
|
||
// Als onderwerp_logging op het ticket leeg is, kijk in de summary
|
||
const normSum = normalizeText(issSum);
|
||
matched = allowedTopics.some(t => normSum.includes(t));
|
||
}
|
||
if (!matched) return false;
|
||
continue;
|
||
}
|
||
|
||
// "Onderwerp logging[dropdown]" not in (...)
|
||
const olNotInMatch = cond.match(/["']?(?:onderwerp(?:[\w\s\[\]_-]*)?|customfield_\d+)["']?\s+not\s+in\s*\(([^)]+)\)/i);
|
||
if (olNotInMatch) {
|
||
const forbiddenTopics = olNotInMatch[1].split(',').map(s => normalizeText(s)).filter(Boolean);
|
||
const normOl = normalizeText(issOl);
|
||
if (normOl && forbiddenTopics.some(t => normOl === t || normOl.includes(t) || t.includes(normOl))) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// "Onderwerp logging[dropdown]" = "..."
|
||
const olEqMatch = cond.match(/["']?(?:onderwerp(?:[\w\s\[\]_-]*)?|customfield_\d+)["']?\s*=\s*["']?([^"'\)]+)["']?/i);
|
||
if (olEqMatch) {
|
||
const reqTopic = normalizeText(olEqMatch[1]);
|
||
const normOl = normalizeText(issOl);
|
||
const matched = (normOl === reqTopic || normOl.includes(reqTopic) || reqTopic.includes(normOl) || (!normOl && normalizeText(issSum).includes(reqTopic)));
|
||
if (!matched) return false;
|
||
continue;
|
||
}
|
||
|
||
// "Onderwerp logging[dropdown]" != "..."
|
||
const olNeqMatch = cond.match(/["']?(?:onderwerp(?:[\w\s\[\]_-]*)?|customfield_\d+)["']?\s*!=\s*["']?([^"'\)]+)["']?/i);
|
||
if (olNeqMatch) {
|
||
const notTopic = normalizeText(olNeqMatch[1]);
|
||
const normOl = normalizeText(issOl);
|
||
if (normOl && (normOl === notTopic || normOl.includes(notTopic))) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// --- 3. Prioriteit condities ---
|
||
const prioInMatch = cond.match(/^priority\s+in\s*\(([^)]+)\)$/i);
|
||
if (prioInMatch) {
|
||
const allowed = prioInMatch[1].split(',').map(s => s.replace(/["']/g, '').toLowerCase().trim());
|
||
if (!allowed.includes(issPriority)) return false;
|
||
continue;
|
||
}
|
||
const prioEqMatch = cond.match(/^priority\s*=\s*["']?([^"']+)["']?$/i);
|
||
if (prioEqMatch) {
|
||
const req = prioEqMatch[1].toLowerCase().trim();
|
||
if (issPriority !== req) return false;
|
||
continue;
|
||
}
|
||
|
||
// --- 4. Type condities ---
|
||
const typeInMatch = cond.match(/^(?:issuetype|type)\s+in\s*\(([^)]+)\)$/i);
|
||
if (typeInMatch) {
|
||
const allowed = typeInMatch[1].split(',').map(s => s.replace(/["']/g, '').toLowerCase().trim());
|
||
if (!allowed.includes(issType)) return false;
|
||
continue;
|
||
}
|
||
const typeEqMatch = cond.match(/^(?:issuetype|type)\s*=\s*["']?([^"']+)["']?$/i);
|
||
if (typeEqMatch) {
|
||
const req = typeEqMatch[1].toLowerCase().trim();
|
||
if (issType !== req) return false;
|
||
continue;
|
||
}
|
||
|
||
// --- 5. StatusCategory conditie ---
|
||
if (/statusCategory\s*!=\s*Done/i.test(cond)) {
|
||
if (isDoneStatus(issStatus) || (iss.status_category || '').toLowerCase() === 'done') {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
if (/statusCategory\s*=\s*Done/i.test(cond)) {
|
||
if (!isDoneStatus(issStatus) && (iss.status_category || '').toLowerCase() !== 'done') {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// --- 6. Status condities ---
|
||
const statusInMatch = cond.match(/^status\s+in\s*\(([^)]+)\)$/i);
|
||
if (statusInMatch) {
|
||
const allowed = statusInMatch[1].split(',').map(s => s.replace(/["']/g, '').toLowerCase().trim());
|
||
if (!allowed.includes(issStatus)) return false;
|
||
continue;
|
||
}
|
||
const statusEqMatch = cond.match(/^status\s*=\s*["']?([^"']+)["']?$/i);
|
||
if (statusEqMatch) {
|
||
const req = statusEqMatch[1].toLowerCase().trim();
|
||
if (issStatus !== req) return false;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// Hulpfunctie: Bepaal of een ticket tot een specifieke wachtrij behoort
|
||
function isIssueInQueue(iss, qCode) {
|
||
if (!qCode) return true;
|
||
const code = qCode.toUpperCase().trim();
|
||
const pKey = getIssueProjectKey(iss);
|
||
const pName = (iss.project || '').toLowerCase().trim();
|
||
const ol = (iss.onderwerp_logging || '').toLowerCase().trim();
|
||
const sum = (iss.summary || '').toLowerCase();
|
||
|
||
// Multi-queue matching voor de ingelogde gebruiker
|
||
if (code === 'MY_QUEUES') {
|
||
const myQueues = jiraData.user_queue_filters || [];
|
||
if (myQueues.length === 0) return true;
|
||
return myQueues.some(q => isIssueInQueue(iss, q));
|
||
}
|
||
|
||
// 1. Zoek geconfigureerd wachtrijfilter in jiraData.queue_filters (database)
|
||
const fObj = (jiraData.queue_filters || []).find(f => f.filter_code && f.filter_code.toUpperCase() === code);
|
||
if (fObj) {
|
||
// Project key validatie indien expliciet in filter ingesteld
|
||
if (fObj.project_key) {
|
||
const filterPKey = fObj.project_key.toUpperCase().trim();
|
||
if (pKey !== filterPKey && !pName.includes(filterPKey.toLowerCase())) {
|
||
return false;
|
||
}
|
||
}
|
||
// JQL clausule evaluatie
|
||
if (fObj.jql_clause && fObj.jql_clause.trim()) {
|
||
return matchJqlClause(iss, fObj.jql_clause);
|
||
}
|
||
// Als er alleen een project_key was zonder nadere jql_clause:
|
||
if (fObj.project_key) {
|
||
const filterPKey = fObj.project_key.toUpperCase().trim();
|
||
return pKey === filterPKey || pName.includes(filterPKey.toLowerCase());
|
||
}
|
||
}
|
||
|
||
// 2. Fallbacks voor standaard wachtrijen (als er geen filter in de database staat)
|
||
if (code === 'DX') {
|
||
const isSd = (pKey === 'SD' || pName.includes('servicedesk'));
|
||
if (!isSd) return false;
|
||
const dxTerms = ['koppeling', 'sso', 'endpoint', 'rest', 'datadump', 'uwv', 'migratie'];
|
||
return dxTerms.some(t => ol.includes(t) || sum.includes(t));
|
||
}
|
||
|
||
if (code === 'SD') {
|
||
return (pKey === 'SD' || pName.includes('servicedesk'));
|
||
}
|
||
|
||
if (code === 'IN') {
|
||
return (pKey === 'IN' || pName.includes('interface'));
|
||
}
|
||
|
||
if (code === 'AAN') {
|
||
return (pKey === 'AAN' || pName.includes('aanvraag'));
|
||
}
|
||
|
||
if (code === 'GEN') {
|
||
return (pKey === 'GEN' || pName.includes('general') || pName.includes('algemeen'));
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function selectQueueFilter(code) {
|
||
currentQueue = code;
|
||
updateQueuePills();
|
||
applyFilters();
|
||
}
|
||
|
||
function updateQueuePills() {
|
||
const pills = document.querySelectorAll('.queue-pill');
|
||
pills.forEach(p => {
|
||
const qCode = p.getAttribute('data-queue') || '';
|
||
const isSelected = (qCode === currentQueue);
|
||
if (isSelected) {
|
||
p.className = "queue-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 flex-shrink-0";
|
||
} else {
|
||
p.className = "queue-pill flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 border border-slate-200/80 dark:border-slate-700/80 flex-shrink-0";
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateProjectCounts() {
|
||
const all = getDeduplicatedIssues();
|
||
const total = getTotalStatusCount();
|
||
let currentList = all;
|
||
|
||
if (selectedStatuses.size > 0 && selectedStatuses.size < total) {
|
||
currentList = currentList.filter(iss => isIssueStatusSelected(iss));
|
||
} else if (selectedStatuses.size === 0) {
|
||
currentList = [];
|
||
}
|
||
|
||
const elAll = document.getElementById('projCount_all');
|
||
if (elAll) elAll.innerText = currentList.length;
|
||
|
||
['SD', 'IN', 'AAN', 'GEN'].forEach(key => {
|
||
const el = document.getElementById(`projCount_${key}`);
|
||
if (el) {
|
||
const cnt = currentList.filter(iss => {
|
||
const pk = getIssueProjectKey(iss);
|
||
const pn = (iss.project || '').toLowerCase().trim();
|
||
if (pk === key) return true;
|
||
if (key === 'SD' && pn.includes('servicedesk')) return true;
|
||
if (key === 'IN' && pn.includes('interface')) return true;
|
||
if (key === 'AAN' && pn.includes('aanvraag')) return true;
|
||
if (key === 'GEN' && (pn.includes('general') || pn.includes('algemeen'))) return true;
|
||
return false;
|
||
}).length;
|
||
el.innerText = cnt;
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateQueueCounts() {
|
||
const all = getDeduplicatedIssues();
|
||
const total = getTotalStatusCount();
|
||
let currentList = all;
|
||
|
||
if (selectedStatuses.size > 0 && selectedStatuses.size < total) {
|
||
currentList = currentList.filter(iss => isIssueStatusSelected(iss));
|
||
} else if (selectedStatuses.size === 0) {
|
||
currentList = [];
|
||
}
|
||
|
||
if (currentProject) {
|
||
const pNorm = currentProject.trim().toUpperCase();
|
||
currentList = currentList.filter(iss => {
|
||
const pk = getIssueProjectKey(iss);
|
||
const pn = (iss.project || '').trim().toLowerCase();
|
||
return pk === pNorm || pn.includes(pNorm.toLowerCase());
|
||
});
|
||
}
|
||
|
||
const elAll = document.getElementById('queueCount_all');
|
||
if (elAll) elAll.innerText = currentList.length;
|
||
|
||
const elMy = document.getElementById('queueCount_MY_QUEUES');
|
||
if (elMy) {
|
||
elMy.innerText = currentList.filter(iss => isIssueInQueue(iss, 'MY_QUEUES')).length;
|
||
}
|
||
|
||
['SD', 'IN', 'AAN', 'GEN', 'DX'].forEach(code => {
|
||
const el = document.getElementById(`queueCount_${code}`);
|
||
if (el) {
|
||
el.innerText = currentList.filter(iss => isIssueInQueue(iss, code)).length;
|
||
}
|
||
});
|
||
|
||
(jiraData.queue_filters || []).forEach(f => {
|
||
if (!['SD', 'IN', 'AAN', 'GEN', 'DX'].includes(f.filter_code)) {
|
||
const el = document.getElementById(`queueCount_${f.filter_code}`);
|
||
if (el) {
|
||
el.innerText = currentList.filter(iss => isIssueInQueue(iss, f.filter_code)).length;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function filterByMyTickets() {
|
||
const sInput = document.getElementById('jiraSearchInput');
|
||
if (sInput) {
|
||
sInput.value = 'Mijzelf';
|
||
}
|
||
applyFilters();
|
||
}
|
||
|
||
function filterByAllTickets() {
|
||
const sInput = document.getElementById('jiraSearchInput');
|
||
if (sInput) {
|
||
sInput.value = '';
|
||
}
|
||
applyFilters();
|
||
}
|
||
|
||
function filterByUrgent() {
|
||
const pSelect = document.getElementById('filterPriority');
|
||
if (pSelect) {
|
||
pSelect.value = 'High';
|
||
}
|
||
applyFilters();
|
||
}
|
||
|
||
function clearSearch() {
|
||
const input = document.getElementById('jiraSearchInput');
|
||
if (input) {
|
||
input.value = '';
|
||
}
|
||
document.getElementById('clearSearchBtn')?.classList.add('hidden');
|
||
applyFilters();
|
||
}
|
||
|
||
function resetAllFilters() {
|
||
currentProject = '';
|
||
currentQueue = '';
|
||
selectAllStatuses(true);
|
||
|
||
const searchInput = document.getElementById('jiraSearchInput');
|
||
const typeSelect = document.getElementById('filterType');
|
||
const prioritySelect = document.getElementById('filterPriority');
|
||
|
||
if (searchInput) searchInput.value = '';
|
||
if (typeSelect) typeSelect.value = '';
|
||
if (prioritySelect) prioritySelect.value = '';
|
||
|
||
document.getElementById('clearSearchBtn')?.classList.add('hidden');
|
||
updateProjectPills();
|
||
updateQueuePills();
|
||
applyFilters();
|
||
}
|
||
|
||
// Filter en render logica
|
||
function applyFilters() {
|
||
const searchVal = (document.getElementById('jiraSearchInput')?.value || '').trim().toLowerCase();
|
||
const projectVal = (currentProject || '').toUpperCase().trim();
|
||
const typeVal = (document.getElementById('filterType')?.value || '').trim().toLowerCase();
|
||
const priorityVal = (document.getElementById('filterPriority')?.value || '').trim();
|
||
|
||
const clearBtn = document.getElementById('clearSearchBtn');
|
||
if (clearBtn) {
|
||
if (searchVal.length > 0) clearBtn.classList.remove('hidden');
|
||
else clearBtn.classList.add('hidden');
|
||
}
|
||
|
||
const all = getDeduplicatedIssues();
|
||
|
||
// Filter tickets
|
||
const filtered = all.filter(iss => {
|
||
const issPKey = getIssueProjectKey(iss);
|
||
const issPName = (iss.project || '').toLowerCase().trim();
|
||
|
||
// 1. Status Filter via Checkboxes
|
||
const totalStatuses = getTotalStatusCount();
|
||
if (selectedStatuses.size > 0 && selectedStatuses.size < totalStatuses) {
|
||
if (!isIssueStatusSelected(iss)) {
|
||
return false;
|
||
}
|
||
} else if (selectedStatuses.size === 0) {
|
||
return false;
|
||
}
|
||
|
||
// 2. Project Filter (SD, IN, AAN, GEN of ander)
|
||
if (projectVal) {
|
||
let matchesProj = (issPKey === projectVal);
|
||
if (!matchesProj) {
|
||
if (projectVal === 'SD' && issPName.includes('servicedesk')) matchesProj = true;
|
||
else if (projectVal === 'IN' && issPName.includes('interface')) matchesProj = true;
|
||
else if (projectVal === 'AAN' && issPName.includes('aanvraag')) matchesProj = true;
|
||
else if (projectVal === 'GEN' && (issPName.includes('general') || issPName.includes('algemeen'))) matchesProj = true;
|
||
else if (issPName === projectVal.toLowerCase()) matchesProj = true;
|
||
}
|
||
if (!matchesProj) return false;
|
||
}
|
||
|
||
// 3. Wachtrijfilter (MY_QUEUES, SD, IN, AAN, GEN, DX)
|
||
if (currentQueue) {
|
||
if (!isIssueInQueue(iss, currentQueue)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 4. Type filter (uit drawer)
|
||
if (typeVal && (iss.issue_type || '').toLowerCase() !== typeVal) {
|
||
return false;
|
||
}
|
||
|
||
// 5. Prioriteit filter (uit drawer)
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 6. Zoekterm
|
||
if (searchVal) {
|
||
const keyMatch = (iss.key || '').toLowerCase().includes(searchVal);
|
||
const sumMatch = (iss.summary || '').toLowerCase().includes(searchVal);
|
||
const prjMatch = issPName.includes(searchVal);
|
||
const prjKeyMatch = issPKey.includes(searchVal.toUpperCase());
|
||
const descMatch = (iss.description || '').toLowerCase().includes(searchVal);
|
||
const repMatch = (iss.reporter_name || '').toLowerCase().includes(searchVal);
|
||
const assMatch = (iss.assignee_name || '').toLowerCase().includes(searchVal);
|
||
const olMatch = (iss.onderwerp_logging || '').toLowerCase().includes(searchVal);
|
||
if (!keyMatch && !sumMatch && !prjMatch && !prjKeyMatch && !descMatch && !repMatch && !assMatch && !olMatch) return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
updateActiveFilterBadge();
|
||
updateProjectCounts();
|
||
updateQueueCounts();
|
||
updateStatusCounts();
|
||
renderTickets(filtered);
|
||
}
|
||
|
||
// Uitklappen en inklappen van ticket details
|
||
function toggleTicketDetails(key, event) {
|
||
if (event) {
|
||
event.stopPropagation();
|
||
}
|
||
const details = document.getElementById(`details_${key}`);
|
||
const chevron = document.getElementById(`chevron_${key}`);
|
||
if (!details) return;
|
||
|
||
if (details.classList.contains('hidden')) {
|
||
details.classList.remove('hidden');
|
||
if (chevron) chevron.classList.add('rotate-180');
|
||
expandedKeys.add(key);
|
||
} else {
|
||
details.classList.add('hidden');
|
||
if (chevron) chevron.classList.remove('rotate-180');
|
||
expandedKeys.delete(key);
|
||
}
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
|
||
function copyTicketKey(key) {
|
||
navigator.clipboard.writeText(key).then(() => {
|
||
showToast({
|
||
message: `Ticket ${key} gekopieerd!`,
|
||
subMessage: 'De ticketsleutel is direct naar je klembord gekopieerd.'
|
||
});
|
||
});
|
||
}
|
||
|
||
// Renders the list of tickets
|
||
function renderTickets(tickets) {
|
||
const container = document.getElementById('ticketsContainer');
|
||
const emptyState = document.getElementById('emptyStateContainer');
|
||
const countSpan = document.getElementById('renderedCount');
|
||
|
||
if (countSpan) {
|
||
countSpan.innerText = tickets.length;
|
||
}
|
||
|
||
if (!tickets || tickets.length === 0) {
|
||
container.innerHTML = '';
|
||
emptyState.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
emptyState.classList.add('hidden');
|
||
|
||
let html = '';
|
||
tickets.forEach(ticket => {
|
||
ticketStore.set(ticket.key, ticket);
|
||
// Issue type styling & icon
|
||
let typeBadgeClass = 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-400 border-sky-200 dark:border-sky-800/60';
|
||
let typeIcon = 'check-square';
|
||
const tLower = (ticket.issue_type || '').toLowerCase();
|
||
if (tLower.includes('bug')) {
|
||
typeBadgeClass = 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400 border-red-200 dark:border-red-800/60';
|
||
typeIcon = 'alert-circle';
|
||
} else if (tLower.includes('story')) {
|
||
typeBadgeClass = 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800/60';
|
||
typeIcon = 'bookmark';
|
||
} else if (tLower.includes('epic')) {
|
||
typeBadgeClass = 'bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-400 border-purple-200 dark:border-purple-800/60';
|
||
typeIcon = 'zap';
|
||
} else if (tLower.includes('feature')) {
|
||
typeBadgeClass = 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400 border-amber-200 dark:border-amber-800/60';
|
||
typeIcon = 'star';
|
||
}
|
||
|
||
// Priority badge styling
|
||
let prioBadgeClass = 'text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700';
|
||
let prioIcon = 'minus';
|
||
const pLower = (ticket.priority || '').toLowerCase();
|
||
if (pLower === 'highest') {
|
||
prioBadgeClass = 'text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800/60';
|
||
prioIcon = 'chevrons-up';
|
||
} else if (pLower === 'high') {
|
||
prioBadgeClass = 'text-orange-700 dark:text-orange-400 bg-orange-50 dark:bg-orange-950/40 border-orange-200 dark:border-orange-800/60';
|
||
prioIcon = 'chevron-up';
|
||
} else if (pLower === 'low' || pLower === 'lowest') {
|
||
prioBadgeClass = 'text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-800';
|
||
prioIcon = 'chevron-down';
|
||
}
|
||
|
||
// Project badge styling (SD, IN, AAN, GEN)
|
||
const pKey = (ticket.project_key || '').toUpperCase().trim();
|
||
const pName = (ticket.project || '').toLowerCase().trim();
|
||
let projBadgeClass = 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border border-slate-200/80 dark:border-slate-700/80';
|
||
let projLabel = escapeHtml(ticket.project || 'Project');
|
||
|
||
if (pKey === 'SD' || pName.includes('servicedesk')) {
|
||
projBadgeClass = 'bg-sky-50 dark:bg-sky-950/40 text-sky-700 dark:text-sky-300 border border-sky-300 dark:border-sky-800/80';
|
||
projLabel = 'SD • Servicedesk';
|
||
} else if (pKey === 'IN' || pName.includes('interface')) {
|
||
projBadgeClass = 'bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-800/80';
|
||
projLabel = 'IN • Interfaces';
|
||
} else if (pKey === 'AAN' || pName.includes('aanvraag')) {
|
||
projBadgeClass = 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800/80';
|
||
projLabel = 'AAN • Aanvragen';
|
||
} else if (pKey === 'GEN' || pName.includes('general') || pName.includes('algemeen')) {
|
||
projBadgeClass = 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-300 dark:border-indigo-800/80';
|
||
projLabel = 'GEN • General';
|
||
}
|
||
|
||
// Status badge styling
|
||
let statusBadgeClass = 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 border-slate-200 dark:border-slate-700';
|
||
let statusDot = '';
|
||
const sLower = (ticket.status || '').toLowerCase();
|
||
const scLower = (ticket.status_category || '').toLowerCase();
|
||
if (sLower.includes('progress') || sLower.includes('bezig') || sLower.includes('behandeling') || sLower.includes('uitvoering') || scLower === 'indeterminate') {
|
||
statusBadgeClass = 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300 border-blue-200 dark:border-blue-800/60';
|
||
statusDot = '<span class="w-1.5 h-1.5 rounded-full bg-blue-500 mr-1.5 animate-pulse"></span>';
|
||
} else if (sLower.includes('wachten') || sLower.includes('klant') || sLower.includes('waiting') || sLower.includes('pending')) {
|
||
statusBadgeClass = 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 border-amber-200 dark:border-amber-800/60';
|
||
statusDot = '<span class="w-1.5 h-1.5 rounded-full bg-amber-500 mr-1.5"></span>';
|
||
} else if (sLower.includes('review') || sLower.includes('test')) {
|
||
statusBadgeClass = 'bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border-purple-200 dark:border-purple-800/60';
|
||
statusDot = '<span class="w-1.5 h-1.5 rounded-full bg-purple-500 mr-1.5"></span>';
|
||
} else if (sLower.includes('done') || sLower.includes('afgerond') || sLower.includes('gesloten') || sLower.includes('gereed') || sLower.includes('closed') || scLower === 'done') {
|
||
statusBadgeClass = 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800/60';
|
||
statusDot = '<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5"></span>';
|
||
} else if (sLower.includes('to do') || sLower.includes('open') || sLower.includes('nieuw') || scLower === 'new') {
|
||
statusBadgeClass = 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 border-slate-200 dark:border-slate-700';
|
||
statusDot = '<span class="w-1.5 h-1.5 rounded-full bg-slate-400 mr-1.5"></span>';
|
||
}
|
||
|
||
// Due date badge
|
||
let dueDateHtml = '';
|
||
if (ticket.due_date) {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const isOverdue = ticket.due_date < today;
|
||
const isToday = ticket.due_date === today;
|
||
let dueClass = 'text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800/60';
|
||
if (isOverdue) dueClass = 'text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/50';
|
||
else if (isToday) dueClass = 'text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-900/50';
|
||
|
||
dueDateHtml = `
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-semibold ${dueClass}">
|
||
<i data-lucide="calendar" class="w-3 h-3"></i>
|
||
<span>${ticket.due_date}</span>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
// Assignee badge
|
||
let assigneeHtml = '';
|
||
if (ticket.assignee_name) {
|
||
const initials = ticket.assignee_name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||
assigneeHtml = `
|
||
<div class="flex items-center space-x-1.5 text-xs text-slate-600 dark:text-slate-400">
|
||
<div class="w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900/60 text-indigo-700 dark:text-indigo-300 flex items-center justify-center font-bold text-[10px]">
|
||
${initials}
|
||
</div>
|
||
<span class="truncate max-w-[120px] sm:max-w-[160px]">${escapeHtml(ticket.assignee_name)}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Onderwerp logging badge
|
||
let onderwerpBadge = '<span class="text-slate-400 italic font-normal">Niet ingevuld</span>';
|
||
if (ticket.onderwerp_logging) {
|
||
const olLower = ticket.onderwerp_logging.toLowerCase();
|
||
const dxList = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie'];
|
||
const isDx = dxList.includes(olLower);
|
||
if (isDx) {
|
||
onderwerpBadge = `
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-bold bg-amber-50 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-300 dark:border-amber-800/80">
|
||
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>
|
||
<span>DX: ${escapeHtml(ticket.onderwerp_logging)}</span>
|
||
</span>
|
||
`;
|
||
} else {
|
||
onderwerpBadge = `
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-bold bg-sky-50 text-sky-800 dark:bg-sky-950/60 dark:text-sky-300 border border-sky-300 dark:border-sky-800/80">
|
||
<i data-lucide="wrench" class="w-3 h-3 text-sky-500"></i>
|
||
<span>SD: ${escapeHtml(ticket.onderwerp_logging)}</span>
|
||
</span>
|
||
`;
|
||
}
|
||
}
|
||
|
||
const isExpanded = expandedKeys.has(ticket.key);
|
||
const detailsClass = isExpanded ? '' : 'hidden';
|
||
const chevronRotateClass = isExpanded ? 'rotate-180' : '';
|
||
|
||
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 class="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||
|
||
<!-- Linkerkant: Metadata & Titel -->
|
||
<div class="flex-1 min-w-0 space-y-2">
|
||
|
||
<!-- Header regel met Type, Key, Project, Prioriteit & Status -->
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<!-- Type Badge -->
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider border ${typeBadgeClass}">
|
||
<i data-lucide="${typeIcon}" class="w-3 h-3"></i>
|
||
<span>${escapeHtml(ticket.issue_type || 'Taak')}</span>
|
||
</span>
|
||
|
||
<!-- Key Link -->
|
||
<a href="${escapeHtml(ticket.url)}" target="_blank" rel="noopener noreferrer" class="font-mono text-xs font-black text-indigo-600 dark:text-indigo-400 hover:underline flex items-center space-x-1">
|
||
<span>${escapeHtml(ticket.key)}</span>
|
||
<i data-lucide="external-link" class="w-3 h-3 text-slate-400"></i>
|
||
</a>
|
||
|
||
<!-- Project Badge -->
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-bold ${projBadgeClass}">
|
||
<i data-lucide="layers" class="w-3 h-3 opacity-75"></i>
|
||
<span>${projLabel}</span>
|
||
</span>
|
||
|
||
<!-- Status Badge -->
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-bold border ${statusBadgeClass}">
|
||
${statusDot}
|
||
<span>${escapeHtml(ticket.status || 'To Do')}</span>
|
||
</span>
|
||
|
||
<!-- Priority Badge -->
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-bold border ${prioBadgeClass}">
|
||
<i data-lucide="${prioIcon}" class="w-3 h-3"></i>
|
||
<span>${escapeHtml(ticket.priority || 'Medium')}</span>
|
||
</span>
|
||
|
||
<!-- Due date -->
|
||
${dueDateHtml}
|
||
</div>
|
||
|
||
<!-- Ticket Titel / Summary (Klikbaar voor accordion) -->
|
||
<h3 class="text-sm sm:text-base font-bold text-slate-900 dark:text-white leading-snug cursor-pointer group" onclick="toggleTicketDetails('${escapeHtml(ticket.key)}', event)">
|
||
<span class="group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition">
|
||
${escapeHtml(ticket.summary || '(Geen titel)')}
|
||
</span>
|
||
</h3>
|
||
|
||
<!-- Sub-info: Assignee, Update timestamp & Inklap knop -->
|
||
<div class="flex flex-wrap items-center gap-4 pt-1">
|
||
${assigneeHtml}
|
||
${ticket.updated ? `<span class="text-[11px] text-slate-400">Gewijzigd: ${escapeHtml(ticket.updated)}</span>` : ''}
|
||
${ticket.onderwerp_logging ? onderwerpBadge : ''}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Rechterkant: Snelle Actieknoppen + Uitklapknop -->
|
||
<div class="flex items-center space-x-2 pt-2 lg:pt-0 flex-shrink-0 border-t lg:border-t-0 border-slate-100 dark:border-slate-800">
|
||
<button type="button" onclick="toggleTicketDetails('${escapeHtml(ticket.key)}', event)" class="flex items-center space-x-1 px-2.5 py-2 rounded-xl text-xs font-bold text-slate-600 dark:text-slate-300 hover:text-indigo-600 dark:hover:text-indigo-400 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700/80 transition cursor-pointer shadow-2xs">
|
||
<span>Details</span>
|
||
<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 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>
|
||
<span>Plan in Dagplanner</span>
|
||
</button>
|
||
|
||
<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>
|
||
<span>Naar Bord</span>
|
||
</button>
|
||
|
||
<a href="${escapeHtml(ticket.url)}" target="_blank" rel="noopener noreferrer" title="Bekijk in Jira" class="p-2 rounded-xl text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||
<i data-lucide="external-link" class="w-4 h-4"></i>
|
||
</a>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- UITKLAPBAAR PANEEL (Accordion Details) -->
|
||
<div id="details_${escapeHtml(ticket.key)}" class="${detailsClass} mt-4 pt-4 border-t border-slate-100 dark:border-slate-800 space-y-4 text-xs">
|
||
|
||
<!-- Beschrijving -->
|
||
<div class="space-y-1.5">
|
||
<div class="flex items-center space-x-1.5 font-bold text-slate-700 dark:text-slate-300">
|
||
<i data-lucide="align-left" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||
<span>Volledige Ticket Omschrijving</span>
|
||
</div>
|
||
<div class="p-4 rounded-xl bg-slate-50 dark:bg-slate-900/90 border border-slate-200/80 dark:border-slate-800 text-slate-800 dark:text-slate-200 leading-relaxed whitespace-pre-wrap select-text font-normal">
|
||
${escapeHtml(ticket.description || 'Geen uitgebreide omschrijving beschikbaar in Jira.')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Metadata Grid (Reporter, Aanmaakdatum, Wijziging, Onderwerp) -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-3.5 rounded-xl bg-slate-50/60 dark:bg-slate-950/40 border border-slate-200/60 dark:border-slate-800/60 text-[11px]">
|
||
<div>
|
||
<span class="text-slate-400 block mb-1 font-medium">Aangemeld door (Reporter):</span>
|
||
<div class="flex items-center space-x-1.5 font-semibold text-slate-800 dark:text-slate-200">
|
||
<div class="w-4 h-4 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center font-bold text-[9px] flex-shrink-0">
|
||
${escapeHtml((ticket.reporter_name || 'J').charAt(0).toUpperCase())}
|
||
</div>
|
||
<span class="truncate">${escapeHtml(ticket.reporter_name || 'Onbekend')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<span class="text-slate-400 block mb-1 font-medium">Aangemaakt op:</span>
|
||
<span class="font-medium text-slate-700 dark:text-slate-300">
|
||
${ticket.created ? escapeHtml(ticket.created) : 'Niet opgegeven'}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span class="text-slate-400 block mb-1 font-medium">Laatst gewijzigd:</span>
|
||
<span class="font-medium text-slate-700 dark:text-slate-300">
|
||
${ticket.updated ? escapeHtml(ticket.updated) : 'Recent'}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span class="text-slate-400 block mb-1 font-medium">Onderwerp logging:</span>
|
||
${onderwerpBadge}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Snelle acties onderaan detailvak -->
|
||
<div class="flex flex-wrap items-center justify-between gap-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||
<div class="flex items-center space-x-2">
|
||
<button type="button" onclick='copyTicketKey("${escapeHtml(ticket.key)}")' class="flex items-center space-x-1 px-2.5 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold transition cursor-pointer">
|
||
<i data-lucide="copy" class="w-3 h-3"></i>
|
||
<span>Sleutel Kopiëren</span>
|
||
</button>
|
||
<a href="${escapeHtml(ticket.url)}" target="_blank" rel="noopener noreferrer" class="flex items-center space-x-1 px-2.5 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-indigo-600 dark:text-indigo-400 text-xs font-semibold transition cursor-pointer">
|
||
<i data-lucide="external-link" class="w-3 h-3"></i>
|
||
<span>Open in Jira Cloud</span>
|
||
</a>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<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>
|
||
<span>Inplannen in Dagplanner</span>
|
||
</button>
|
||
<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>
|
||
<span>Naar Kanban Bord</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Herinitialiseer Lucide iconen
|
||
if (typeof lucide !== 'undefined') {
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
// Modal 1: Plan in Dagplanner
|
||
let _activeJiraTicket = null;
|
||
let _outsideWorkhoursPendingJira = null;
|
||
|
||
function togglePlanTicketDesc() {
|
||
const container = document.getElementById('planTicketDescContainer');
|
||
const chevron = document.getElementById('planTicketDescChevron');
|
||
const text = document.getElementById('planTicketDescToggleText');
|
||
if (!container) return;
|
||
|
||
if (container.classList.contains('hidden')) {
|
||
container.classList.remove('hidden');
|
||
if (chevron) chevron.classList.add('rotate-180');
|
||
if (text) text.textContent = 'Verberg ticket inhoud';
|
||
} else {
|
||
container.classList.add('hidden');
|
||
if (chevron) chevron.classList.remove('rotate-180');
|
||
if (text) text.textContent = 'Toon ticket inhoud';
|
||
}
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
|
||
function clearPlanTaskTitle() {
|
||
const input = document.getElementById('planTaskTitleInput');
|
||
if (input) {
|
||
input.value = '';
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
function updatePlanEndTimeBadge() {
|
||
const startInput = document.getElementById('planStartTimeInput');
|
||
const durationInput = document.getElementById('planDurationInput');
|
||
const badge = document.getElementById('planEndTimeBadge');
|
||
if (!startInput || !durationInput || !badge) return;
|
||
|
||
const startVal = (startInput.value || '').trim();
|
||
const duration = parseInt(durationInput.value || '30', 10);
|
||
if (!startVal || !startVal.includes(':') || isNaN(duration)) {
|
||
badge.innerText = '';
|
||
return;
|
||
}
|
||
|
||
const [hStr, mStr] = startVal.split(':');
|
||
const totalMinutes = (parseInt(hStr, 10) * 60 + parseInt(mStr, 10)) + duration;
|
||
const endH = Math.floor((totalMinutes / 60) % 24);
|
||
const endM = totalMinutes % 60;
|
||
const endStr = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
|
||
badge.innerText = `(tot ${endStr})`;
|
||
}
|
||
|
||
async function planInNextOpenBlockJira(startFromNextWorkday = false) {
|
||
const durationInput = document.getElementById('planDurationInput');
|
||
const duration = parseInt(durationInput?.value || '45', 10);
|
||
const btn = document.getElementById('btnJiraPlanOpenBlock');
|
||
const origHtml = btn ? btn.innerHTML : '';
|
||
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Zoeken...</span>`;
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
|
||
try {
|
||
let url = `/api/planner.php?action=find_next_free_slot&duration=${duration}`;
|
||
if (startFromNextWorkday) {
|
||
url += '&start_from_next_workday=1';
|
||
}
|
||
const resp = await fetch(url);
|
||
const data = await resp.json();
|
||
|
||
if (data.success && data.slot) {
|
||
const slot = data.slot;
|
||
const dateInput = document.getElementById('planDateInput');
|
||
const startInput = document.getElementById('planStartTimeInput');
|
||
if (dateInput) dateInput.value = slot.date;
|
||
if (startInput) startInput.value = slot.start;
|
||
|
||
updatePlanEndTimeBadge();
|
||
|
||
const notice = document.getElementById('jiraPlanOpenBlockNotice');
|
||
const noticeText = document.getElementById('jiraPlanOpenBlockNoticeText');
|
||
if (notice && noticeText) {
|
||
const formattedDate = new Date(slot.date + 'T00:00:00').toLocaleDateString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short' });
|
||
noticeText.innerText = `Vrij blok gevonden: ${formattedDate} om ${slot.start} – ${slot.end} (${duration} min)`;
|
||
notice.classList.remove('hidden');
|
||
}
|
||
} else {
|
||
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen de komende 14 dagen.');
|
||
}
|
||
} catch (err) {
|
||
alert('Netwerkfout bij zoeken van open blok: ' + err.message);
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = origHtml;
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeOutsideWorkhoursModalJira(choice) {
|
||
const modal = document.getElementById('outsideWorkhoursModalJira');
|
||
if (modal) modal.classList.add('hidden');
|
||
|
||
if (choice === 'JA') {
|
||
if (_outsideWorkhoursPendingJira) {
|
||
_outsideWorkhoursPendingJira.bypassWorkhours = true;
|
||
submitPlanTask(null, _outsideWorkhoursPendingJira);
|
||
}
|
||
} else if (choice === 'NEE') {
|
||
_outsideWorkhoursPendingJira = null;
|
||
planInNextOpenBlockJira(true);
|
||
} else {
|
||
_outsideWorkhoursPendingJira = null;
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
_activeJiraTicket = ticket;
|
||
|
||
document.getElementById('planIssueKey').value = ticket.key;
|
||
document.getElementById('planIssueSummary').value = ticket.summary;
|
||
document.getElementById('planIssueUrl').value = ticket.url;
|
||
|
||
document.getElementById('planTicketKeyBadge').innerText = ticket.key;
|
||
document.getElementById('planTicketProject').innerText = '• ' + (ticket.project || 'Project');
|
||
document.getElementById('planTicketSummaryText').innerText = ticket.summary;
|
||
|
||
// Vul aanpasbare taaktitel in
|
||
const titleInput = document.getElementById('planTaskTitleInput');
|
||
if (titleInput) {
|
||
titleInput.value = `Oppakken Jira item ${ticket.key} - ${ticket.summary}`;
|
||
}
|
||
|
||
// Vul ticket omschrijving preview
|
||
const descContainer = document.getElementById('planTicketDescContainer');
|
||
if (descContainer) {
|
||
descContainer.textContent = ticket.description || 'Geen gedetailleerde omschrijving beschikbaar in Jira.';
|
||
descContainer.classList.add('hidden');
|
||
}
|
||
const chevron = document.getElementById('planTicketDescChevron');
|
||
if (chevron) chevron.classList.remove('rotate-180');
|
||
const descText = document.getElementById('planTicketDescToggleText');
|
||
if (descText) descText.textContent = 'Toon ticket inhoud';
|
||
|
||
// Verberg open blok notice
|
||
const notice = document.getElementById('jiraPlanOpenBlockNotice');
|
||
if (notice) notice.classList.add('hidden');
|
||
|
||
// Story Points → Duur conversie (1 SP = 30 minuten, configurabel)
|
||
const spBadge = document.getElementById('planSpBadge');
|
||
const spHint = document.getElementById('planSpHint');
|
||
const spHintText = document.getElementById('planSpHintText');
|
||
const sp = parseFloat(ticket.story_points);
|
||
if (!isNaN(sp) && sp > 0) {
|
||
const spMinutes = Math.round(sp * 30); // 1 SP = 30 minuten
|
||
const closestDuration = [15, 30, 45, 60, 90, 120].reduce((prev, curr) =>
|
||
Math.abs(curr - spMinutes) < Math.abs(prev - spMinutes) ? curr : prev
|
||
);
|
||
const durationSelect = document.getElementById('planDurationInput');
|
||
if (durationSelect) {
|
||
const opt = durationSelect.querySelector(`option[value="${closestDuration}"]`);
|
||
if (opt) opt.selected = true;
|
||
}
|
||
spBadge.textContent = `${sp} SP`;
|
||
spBadge.classList.remove('hidden');
|
||
spHintText.textContent = `${sp} story ${sp === 1 ? 'point' : 'points'} ≈ ${spMinutes} min → automatisch ${closestDuration} min ingesteld`;
|
||
spHint.classList.remove('hidden');
|
||
spHint.style.display = 'flex';
|
||
} else {
|
||
spBadge.classList.add('hidden');
|
||
spHint.classList.add('hidden');
|
||
}
|
||
|
||
// Suggesteer prioriteit op basis van Jira prio
|
||
let focizPrio = 'P2';
|
||
const jp = (ticket.priority || '').toLowerCase();
|
||
if (jp === 'highest' || jp === 'high') focizPrio = 'P1';
|
||
else if (jp === 'low' || jp === 'lowest') focizPrio = 'P3';
|
||
|
||
const prioRadios = document.getElementsByName('planPriority');
|
||
for (let r of prioRadios) {
|
||
if (r.value === focizPrio) r.checked = true;
|
||
}
|
||
|
||
updatePlanEndTimeBadge();
|
||
|
||
const modal = document.getElementById('planTaskModal');
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
|
||
function closePlanModal() {
|
||
const modal = document.getElementById('planTaskModal');
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
|
||
async function submitPlanTask(e, options = {}) {
|
||
if (e) e.preventDefault();
|
||
|
||
const btn = document.getElementById('planSubmitBtn');
|
||
const oldBtnText = btn ? btn.innerHTML : '';
|
||
|
||
const key = document.getElementById('planIssueKey').value;
|
||
const summary = document.getElementById('planIssueSummary').value;
|
||
const customTitle = (document.getElementById('planTaskTitleInput')?.value || '').trim();
|
||
const url = document.getElementById('planIssueUrl').value;
|
||
const planDate = document.getElementById('planDateInput').value;
|
||
const timeBlockStart = (document.getElementById('planStartTimeInput')?.value || '').trim();
|
||
const durationMinutes = parseInt(document.getElementById('planDurationInput').value, 10);
|
||
|
||
let priority = 'P2';
|
||
const prioRadios = document.getElementsByName('planPriority');
|
||
for (let r of prioRadios) {
|
||
if (r.checked) priority = r.value;
|
||
}
|
||
|
||
// Controleer of de geplande tijd buiten de gezette werktijden valt (standaard 08:30 - 17:00)
|
||
if (!options.bypassWorkhours && timeBlockStart && timeBlockStart.includes(':')) {
|
||
const [shStr, smStr] = timeBlockStart.split(':');
|
||
const startMin = parseInt(shStr, 10) * 60 + parseInt(smStr, 10);
|
||
const endMin = startMin + durationMinutes;
|
||
|
||
const workStartMin = 8 * 60 + 30; // 08:30
|
||
const workEndMin = 17 * 60; // 17:00
|
||
|
||
if (startMin < workStartMin || endMin > workEndMin) {
|
||
_outsideWorkhoursPendingJira = { key, summary, title: customTitle, url, planDate, timeBlockStart, durationMinutes, priority };
|
||
|
||
const endH = Math.floor((endMin / 60) % 24);
|
||
const endM = endMin % 60;
|
||
const endStr = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
|
||
|
||
const slotEl = document.getElementById('outsideWorkhoursSelectedSlotJira');
|
||
if (slotEl) slotEl.innerText = `${timeBlockStart} – ${endStr} (${durationMinutes} min)`;
|
||
|
||
const outsideModal = document.getElementById('outsideWorkhoursModalJira');
|
||
if (outsideModal) {
|
||
outsideModal.classList.remove('hidden');
|
||
outsideModal.classList.add('flex');
|
||
setTimeout(() => {
|
||
const btnNo = document.getElementById('btnOutsideWorkhoursNoJira');
|
||
if (btnNo) btnNo.focus();
|
||
}, 50);
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Inplannen...</span>`;
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/integrations.php?action=plan_jira_task', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
key,
|
||
summary,
|
||
title: customTitle || `[${key}] ${summary}`,
|
||
url,
|
||
plan_date: planDate,
|
||
time_block_start: timeBlockStart,
|
||
duration_minutes: durationMinutes,
|
||
priority
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
closePlanModal();
|
||
showToast({
|
||
message: `Ticket ${key} ingepland!`,
|
||
subMessage: `Ingepland voor ${planDate} om ${timeBlockStart} (${durationMinutes}m).`,
|
||
linkText: 'Naar Dagplanner',
|
||
linkUrl: `/planner.php?date=${planDate}`
|
||
});
|
||
} else {
|
||
alert(data.error || 'Er is een fout opgetreden bij het inplannen.');
|
||
}
|
||
} catch (err) {
|
||
alert('Netwerkfout: ' + err.message);
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = oldBtnText;
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Modal 2: Plaats op Kanban Bord
|
||
function openBoardModal(item) {
|
||
const ticket = (typeof item === 'string')
|
||
? (ticketStore.get(item) || (jiraData.assigned_issues || []).concat(jiraData.team_issues || []).find(t => t.key === item))
|
||
: item;
|
||
if (!ticket) return;
|
||
|
||
document.getElementById('boardIssueKey').value = ticket.key;
|
||
document.getElementById('boardIssueSummary').value = ticket.summary;
|
||
document.getElementById('boardIssueUrl').value = ticket.url;
|
||
document.getElementById('boardIssueProject').value = ticket.project || '';
|
||
|
||
document.getElementById('boardTicketKeyBadge').innerText = ticket.key;
|
||
document.getElementById('boardTicketProject').innerText = '• ' + (ticket.project || 'Project');
|
||
document.getElementById('boardTicketSummaryText').innerText = ticket.summary;
|
||
|
||
updateLaneOptions();
|
||
|
||
const modal = document.getElementById('boardTaskModal');
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
}
|
||
|
||
function closeBoardModal() {
|
||
const modal = document.getElementById('boardTaskModal');
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
|
||
function updateLaneOptions() {
|
||
const boardSelect = document.getElementById('boardSelectInput');
|
||
const laneSelect = document.getElementById('laneSelectInput');
|
||
if (!boardSelect || !laneSelect) return;
|
||
|
||
const boardId = parseInt(boardSelect.value, 10);
|
||
const targetBoard = availableBoards.find(b => parseInt(b.id, 10) === boardId);
|
||
|
||
laneSelect.innerHTML = '';
|
||
if (!targetBoard || !targetBoard.lanes || targetBoard.lanes.length === 0) {
|
||
laneSelect.innerHTML = '<option value="">Geen kolommen beschikbaar</option>';
|
||
return;
|
||
}
|
||
|
||
targetBoard.lanes.forEach(lane => {
|
||
const opt = document.createElement('option');
|
||
opt.value = lane.id;
|
||
opt.innerText = lane.title;
|
||
laneSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function submitBoardTask(e) {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('boardSubmitBtn');
|
||
const oldBtnText = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Plaatsen...</span>`;
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
|
||
const key = document.getElementById('boardIssueKey').value;
|
||
const summary = document.getElementById('boardIssueSummary').value;
|
||
const url = document.getElementById('boardIssueUrl').value;
|
||
const project = document.getElementById('boardIssueProject').value;
|
||
const boardId = parseInt(document.getElementById('boardSelectInput').value, 10);
|
||
const laneId = parseInt(document.getElementById('laneSelectInput').value, 10);
|
||
const color = document.getElementById('cardColorInput').value;
|
||
const priority = document.getElementById('cardPriorityInput').value;
|
||
|
||
if (!boardId || !laneId) {
|
||
alert('Selecteer een geldig bord en een kolom.');
|
||
btn.disabled = false;
|
||
btn.innerHTML = oldBtnText;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/integrations.php?action=board_jira_card', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
board_id: boardId,
|
||
lane_id: laneId,
|
||
key,
|
||
summary,
|
||
url,
|
||
description: `Project: ${project}\nJira URL: ${url}`,
|
||
color,
|
||
priority
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
closeBoardModal();
|
||
showToast({
|
||
message: `Ticket ${key} geplaatst op bord!`,
|
||
subMessage: 'De kaart is direct zichtbaar in de gekozen kolom.',
|
||
linkText: 'Naar Bord',
|
||
linkUrl: `/board.php?board_id=${boardId}`
|
||
});
|
||
} else {
|
||
alert(data.error || 'Er is een fout opgetreden bij het plaatsen van de kaart.');
|
||
}
|
||
} catch (err) {
|
||
alert('Netwerkfout: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = oldBtnText;
|
||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
// Vernieuwen van Jira data
|
||
async function refreshJiraData() {
|
||
const btn = document.getElementById('refreshBtn');
|
||
const icon = document.getElementById('refreshIcon');
|
||
if (icon) icon.classList.add('animate-spin');
|
||
|
||
window.location.href = '/jira.php?refresh=1';
|
||
}
|
||
|
||
// Toast Notificatiesysteem
|
||
let toastTimer = null;
|
||
function showToast({ message, subMessage, linkText, linkUrl }) {
|
||
const toast = document.getElementById('jiraToast');
|
||
const msgEl = document.getElementById('toastMessage');
|
||
const subMsgEl = document.getElementById('toastSubMessage');
|
||
const linkEl = document.getElementById('toastActionLink');
|
||
|
||
if (!toast || !msgEl) return;
|
||
|
||
msgEl.innerText = message;
|
||
if (subMsgEl) subMsgEl.innerText = subMessage || '';
|
||
|
||
if (linkText && linkUrl && linkEl) {
|
||
linkEl.innerText = linkText;
|
||
linkEl.href = linkUrl;
|
||
linkEl.classList.remove('hidden');
|
||
} else if (linkEl) {
|
||
linkEl.classList.add('hidden');
|
||
}
|
||
|
||
toast.classList.remove('translate-y-24', 'opacity-0', 'pointer-events-none');
|
||
toast.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto');
|
||
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
toast.classList.add('translate-y-24', 'opacity-0', 'pointer-events-none');
|
||
toast.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto');
|
||
}, 5000);
|
||
}
|
||
|
||
// Helper voor veilige HTML rendering
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// Toetsenbordnavigatie voor alle modals (Escape & Enter)
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
const outsideModal = document.getElementById('outsideWorkhoursModalJira');
|
||
if (outsideModal && !outsideModal.classList.contains('hidden')) {
|
||
closeOutsideWorkhoursModalJira('CANCEL');
|
||
return;
|
||
}
|
||
closePlanModal();
|
||
closeBoardModal();
|
||
} else if (e.key === 'Enter') {
|
||
const outsideModal = document.getElementById('outsideWorkhoursModalJira');
|
||
if (outsideModal && !outsideModal.classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
closeOutsideWorkhoursModalJira('NEE');
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Initiële opstart
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Herstel filters drawer open/dicht status
|
||
if (localStorage.getItem('fociz_jira_filters_open') === '1') {
|
||
toggleJiraFiltersDrawer(true);
|
||
}
|
||
|
||
calculateKpis();
|
||
initStatusSelection();
|
||
updateProjectPills();
|
||
updateQueueCounts();
|
||
if (currentQueue) {
|
||
updateQueuePills();
|
||
}
|
||
applyFilters();
|
||
|
||
// Luister naar live events voor nieuwe/gewijzigde Jira tickets
|
||
window.addEventListener('fociz:jira-updated', () => {
|
||
window.location.reload();
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||
|