2020 lines
110 KiB
PHP
2020 lines
110 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
|
||
$allIssues = array_merge($assignedIssues, $teamIssues);
|
||
$allProjects = [];
|
||
$allTypes = [];
|
||
$allPriorities = ['Highest', 'High', 'Medium', 'Low'];
|
||
$allStatuses = [];
|
||
$projectCounts = [];
|
||
|
||
foreach ($allIssues as $iss) {
|
||
if (!empty($iss['project'])) {
|
||
$p = $iss['project'];
|
||
$allProjects[$p] = [
|
||
'name' => $p,
|
||
'key' => $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'];
|
||
}
|
||
}
|
||
ksort($allProjects);
|
||
ksort($allTypes);
|
||
ksort($allStatuses);
|
||
|
||
$queueFilters = $jiraData['queue_filters'] ?? jira_get_queue_filters(true);
|
||
$defaultQueueFilter = $jiraData['default_queue_filter'] ?? jira_get_user_queue_preference($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);
|
||
?>
|
||
|
||
<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>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||
Beheer tickets toegewezen aan jou en je projectteams. Plan ze direct in je dagplanning of zet ze op een Kanban bord.
|
||
</p>
|
||
</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="switchTab('assigned')" 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="switchTab('team')" 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="switchTab('in_progress')" 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. Snelle Project Switcher (Pill Tabs) -->
|
||
<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="flex items-center justify-between pb-2 mb-2 border-b border-slate-200/60 dark:border-slate-800">
|
||
<div class="flex items-center space-x-2">
|
||
<i data-lucide="folder-kanban" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||
<span class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider">Jira Projecten</span>
|
||
</div>
|
||
<span class="text-[11px] text-slate-400"><?= count($allProjects) ?> projecten</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 overflow-x-auto pb-1" 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-indigo-600 text-white shadow-xs flex-shrink-0">
|
||
<i data-lucide="layers" class="w-3 h-3"></i>
|
||
<span>Alle Projecten</span>
|
||
<span class="px-1.5 py-0.5 rounded-full text-[10px] bg-white/20 text-white font-black"><?= count($allIssues) ?></span>
|
||
</button>
|
||
<?php foreach ($allProjects as $pName => $pData): ?>
|
||
<button type="button" onclick="selectProject('<?= htmlspecialchars(addslashes($pName)) ?>')" data-project="<?= htmlspecialchars($pName) ?>" 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/70 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 flex-shrink-0">
|
||
<?php if (!empty($pData['key'])): ?>
|
||
<span class="px-1 py-0.5 rounded text-[10px] font-mono font-black bg-indigo-100 dark:bg-indigo-950/80 text-indigo-700 dark:text-indigo-300"><?= htmlspecialchars($pData['key']) ?></span>
|
||
<?php else: ?>
|
||
<i data-lucide="folder" class="w-3 h-3 text-slate-400"></i>
|
||
<?php endif; ?>
|
||
<span><?= htmlspecialchars($pName) ?></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[$pName] ?? 0 ?></span>
|
||
</button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. Servicedesk Wachtrijfilter Sub-balk (DX / SD) -->
|
||
<div id="queueFiltersBar" class="p-3.5 rounded-xl bg-gradient-to-r from-slate-50 via-indigo-50/20 to-sky-50/10 dark:from-slate-900 dark:via-indigo-950/20 dark:to-sky-950/10 border border-indigo-100 dark:border-indigo-900/40">
|
||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-2.5">
|
||
<div class="flex items-center space-x-2">
|
||
<i data-lucide="filter" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||
<span class="text-xs font-bold text-slate-900 dark:text-white">Servicedesk Wachtrijen</span>
|
||
<?php if (!empty($defaultQueueFilter)): ?>
|
||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-950/70 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/80">
|
||
Jouw voorkeur: <?= htmlspecialchars($defaultQueueFilter) ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 overflow-x-auto" 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 Wachtrijen</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>
|
||
<button type="button" onclick="selectQueueFilter('DX')" data-queue="DX" 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-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 (Koppelingen)</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>
|
||
<button type="button" onclick="selectQueueFilter('SD')" data-queue="SD" 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-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="wrench" class="w-3 h-3 text-sky-500"></i>
|
||
<span>SD (Regulier)</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>
|
||
<?php foreach ($queueFilters as $qf):
|
||
$code = $qf['filter_code'] ?? '';
|
||
if ($code === 'DX' || $code === 'SD' || empty($code)) continue;
|
||
?>
|
||
<button type="button" onclick="selectQueueFilter('<?= htmlspecialchars($code) ?>')" data-queue="<?= htmlspecialchars($code) ?>" class="queue-pill flex items-center space-x-1 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="filter" class="w-3 h-3 text-indigo-500"></i>
|
||
<span><?= htmlspecialchars($qf['filter_name'] ?? $code) ?></span>
|
||
<span id="queueCount_<?= htmlspecialchars($code) ?>" class="px-1.5 py-0.5 rounded-full text-[10px] bg-indigo-100 dark:bg-indigo-950/60 text-indigo-800 dark:text-indigo-300 font-bold">0</span>
|
||
</button>
|
||
<?php endforeach; ?>
|
||
<?php if ($isAdmin): ?>
|
||
<a href="/admin.php#jira-wachtrijen" title="Wachtrijfilters beheren in Admin" class="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition cursor-pointer flex-shrink-0">
|
||
<i data-lucide="settings" class="w-3.5 h-3.5"></i>
|
||
</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 4. Gedetailleerde 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-2 lg:grid-cols-5 gap-2.5">
|
||
<div>
|
||
<label class="block text-[11px] font-semibold text-slate-500 dark:text-slate-400 mb-1">Project</label>
|
||
<select id="filterProject" onchange="onProjectSelectChange(this.value)" 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 projecten</option>
|
||
<?php foreach ($allProjects as $pName => $pData): ?>
|
||
<option value="<?= htmlspecialchars($pName) ?>"><?= htmlspecialchars($pName) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-[11px] font-semibold text-slate-500 dark:text-slate-400 mb-1">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>
|
||
<label class="block text-[11px] font-semibold text-slate-500 dark:text-slate-400 mb-1">Status</label>
|
||
<select id="filterStatus" 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 statussen</option>
|
||
<?php foreach ($allStatuses as $s): ?>
|
||
<option value="<?= htmlspecialchars($s) ?>"><?= htmlspecialchars($s) ?></option>
|
||
<?php endforeach; ?>
|
||
</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 Filters</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Compacte Werkbalk: Tabs & Zoekveld direct boven de tickets -->
|
||
<div class="fociz-card p-3 sm:p-4 flex flex-col md:flex-row md:items-center justify-between gap-3 shadow-xs">
|
||
<!-- Tabs Navigatie -->
|
||
<div class="flex items-center space-x-1 sm:space-x-1.5 overflow-x-auto pb-1 md:pb-0">
|
||
<button onclick="switchTab('assigned')" id="tabBtn_assigned" class="flex items-center space-x-1.5 px-3.5 py-2 text-xs font-bold rounded-xl transition cursor-pointer bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800/60 flex-shrink-0">
|
||
<i data-lucide="user-check" class="w-3.5 h-3.5"></i>
|
||
<span>Aan mij toegewezen</span>
|
||
<span id="tabCount_assigned" class="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-indigo-200/70 dark:bg-indigo-800/60 text-indigo-800 dark:text-indigo-200">
|
||
<?= count($assignedIssues) ?>
|
||
</span>
|
||
</button>
|
||
|
||
<button onclick="switchTab('team')" id="tabBtn_team" class="flex items-center space-x-1.5 px-3.5 py-2 text-xs font-bold rounded-xl transition cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent flex-shrink-0">
|
||
<i data-lucide="users" class="w-3.5 h-3.5"></i>
|
||
<span>Teams & Projecten</span>
|
||
<span id="tabCount_team" class="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||
<?= count($teamIssues) ?>
|
||
</span>
|
||
</button>
|
||
|
||
<button onclick="switchTab('in_progress')" id="tabBtn_in_progress" class="flex items-center space-x-1.5 px-3.5 py-2 text-xs font-bold rounded-xl transition cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent flex-shrink-0">
|
||
<i data-lucide="clock" class="w-3.5 h-3.5"></i>
|
||
<span>In Uitvoering</span>
|
||
<span id="tabCount_in_progress" class="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||
0
|
||
</span>
|
||
</button>
|
||
|
||
<button onclick="switchTab('done')" id="tabBtn_done" class="flex items-center space-x-1.5 px-3.5 py-2 text-xs font-bold rounded-xl transition cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent flex-shrink-0">
|
||
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
|
||
<span>Recent Afgerond</span>
|
||
<span id="tabCount_done" class="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||
0
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Rechterkant: Zoekveld & Tickets Tellen -->
|
||
<div class="flex items-center space-x-2.5 w-full md:w-auto">
|
||
<div class="relative flex-1 md:w-72">
|
||
<i data-lucide="search" class="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
|
||
<input
|
||
type="text"
|
||
id="jiraSearchInput"
|
||
placeholder="Zoek ticket (FOC-101) of titel..."
|
||
oninput="applyFilters()"
|
||
class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl pl-8 pr-7 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 transition placeholder-slate-400"
|
||
>
|
||
<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.5 h-3.5"></i>
|
||
</button>
|
||
</div>
|
||
<div class="text-[11px] text-slate-400 whitespace-nowrap hidden sm:block">
|
||
<span id="renderedCount" class="font-bold text-slate-700 dark:text-slate-200">0</span> tickets
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tickets Container -->
|
||
<div id="ticketsContainer" class="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 ?>;
|
||
|
||
let currentTab = 'assigned'; // 'assigned', 'team', 'in_progress', 'done'
|
||
let currentProject = '';
|
||
let currentQueue = jiraData.default_queue_filter || '';
|
||
let activeUrgentOnly = false;
|
||
let expandedKeys = new Set();
|
||
const ticketStore = new Map();
|
||
|
||
// Bereken statussen en KPI statistieken
|
||
function calculateKpis() {
|
||
const assigned = jiraData.assigned_issues || [];
|
||
const team = jiraData.team_issues || [];
|
||
const all = [...assigned, ...team];
|
||
|
||
let inProgressCount = 0;
|
||
let highPriorityCount = 0;
|
||
let doneCount = 0;
|
||
|
||
all.forEach(iss => {
|
||
const s = (iss.status || '').toLowerCase();
|
||
const sc = (iss.status_category || '').toLowerCase();
|
||
const p = (iss.priority || '').toLowerCase();
|
||
|
||
if (s.includes('progress') || s.includes('bezig') || s.includes('review') || sc === 'indeterminate') {
|
||
inProgressCount++;
|
||
}
|
||
if (s.includes('done') || s.includes('afgerond') || s.includes('gesloten') || sc === 'done') {
|
||
doneCount++;
|
||
}
|
||
if (p === 'highest' || p === 'high') {
|
||
highPriorityCount++;
|
||
}
|
||
});
|
||
|
||
document.getElementById('statAssignedCount').innerText = assigned.length;
|
||
document.getElementById('statTeamCount').innerText = team.length;
|
||
document.getElementById('statInProgressCount').innerText = inProgressCount;
|
||
document.getElementById('statHighPriorityCount').innerText = highPriorityCount;
|
||
|
||
document.getElementById('tabCount_assigned').innerText = assigned.length;
|
||
document.getElementById('tabCount_team').innerText = team.length;
|
||
document.getElementById('tabCount_in_progress').innerText = inProgressCount;
|
||
document.getElementById('tabCount_done').innerText = doneCount;
|
||
}
|
||
|
||
// Uitklappen en inklappen van de filters & statistieken drawer
|
||
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) activeCount++;
|
||
if (document.getElementById('filterType')?.value) activeCount++;
|
||
if (document.getElementById('filterPriority')?.value) activeCount++;
|
||
if (document.getElementById('filterStatus')?.value) activeCount++;
|
||
|
||
if (activeCount > 0) {
|
||
badge.innerText = activeCount;
|
||
badge.classList.remove('hidden');
|
||
} else {
|
||
badge.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// Wissel tussen tabs
|
||
function switchTab(tab) {
|
||
currentTab = tab;
|
||
activeUrgentOnly = false;
|
||
|
||
// Update tab knoppen styling
|
||
const tabs = ['assigned', 'team', 'in_progress', 'done'];
|
||
tabs.forEach(t => {
|
||
const btn = document.getElementById(`tabBtn_${t}`);
|
||
if (!btn) return;
|
||
if (t === tab) {
|
||
btn.className = "flex items-center space-x-1.5 px-3.5 py-2 text-xs font-bold rounded-xl transition cursor-pointer bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800/60 flex-shrink-0";
|
||
} else {
|
||
btn.className = "flex items-center space-x-1.5 px-3.5 py-2 text-xs font-bold rounded-xl transition cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent flex-shrink-0";
|
||
}
|
||
});
|
||
|
||
updateQueueCounts();
|
||
applyFilters();
|
||
}
|
||
|
||
// Project selectie via pill tabs
|
||
function selectProject(proj) {
|
||
currentProject = proj;
|
||
const pSelect = document.getElementById('filterProject');
|
||
if (pSelect) {
|
||
pSelect.value = proj;
|
||
}
|
||
updateProjectPills();
|
||
updateQueueCounts();
|
||
applyFilters();
|
||
}
|
||
|
||
// Project selectie via dropdown
|
||
function onProjectSelectChange(proj) {
|
||
currentProject = proj;
|
||
updateProjectPills();
|
||
updateQueueCounts();
|
||
applyFilters();
|
||
}
|
||
|
||
function updateProjectPills() {
|
||
const pills = document.querySelectorAll('.project-pill');
|
||
const curNorm = (currentProject || '').trim().toLowerCase();
|
||
pills.forEach(p => {
|
||
const pKey = (p.getAttribute('data-project') || '').trim().toLowerCase();
|
||
if (pKey === curNorm) {
|
||
p.className = "project-pill flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-bold transition cursor-pointer bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 flex-shrink-0";
|
||
} else {
|
||
p.className = "project-pill flex items-center space-x-2 px-3.5 py-2 rounded-xl text-xs font-bold transition cursor-pointer bg-slate-100 dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700/80 border border-slate-200/80 dark:border-slate-700/80 flex-shrink-0";
|
||
}
|
||
});
|
||
}
|
||
|
||
// Hulpfunctie: Bepaal of een ticket tot een specifieke wachtrij behoort (DX, SD of aangepast)
|
||
function isIssueInQueue(iss, qCode) {
|
||
if (!qCode) return true;
|
||
const code = qCode.toUpperCase();
|
||
const ol = (iss.onderwerp_logging || '').toLowerCase().trim();
|
||
const sum = (iss.summary || '').toLowerCase();
|
||
const pKey = (iss.project_key || '').toUpperCase().trim();
|
||
|
||
const dxTerms = ['koppeling', 'sso', 'endpoint', 'rest', 'datadump', 'uwv', 'migratie', 'api', 'webhook', 'integrat'];
|
||
const matchesDx = dxTerms.some(t => ol.includes(t) || sum.includes(t));
|
||
|
||
if (code === 'DX') {
|
||
return matchesDx;
|
||
}
|
||
if (code === 'SD') {
|
||
// SD is regulier Servicedesk (alle tickets die niet tot DX routering behoren)
|
||
return !matchesDx;
|
||
}
|
||
|
||
// Aangepaste wachtrijfilter uit de database
|
||
const fObj = (jiraData.queue_filters || []).find(f => f.filter_code.toUpperCase() === code);
|
||
if (fObj) {
|
||
if (fObj.project_key && pKey && fObj.project_key.toUpperCase() !== pKey) {
|
||
return false;
|
||
}
|
||
const clause = (fObj.jql_clause || '').toLowerCase();
|
||
if (ol && (clause.includes(ol) || ol.includes(code.toLowerCase()))) {
|
||
return true;
|
||
}
|
||
if (clause.includes('koppeling') && matchesDx) return true;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Wachtrijfilter selectie
|
||
function selectQueueFilter(code) {
|
||
currentQueue = code;
|
||
updateQueuePills();
|
||
applyFilters();
|
||
}
|
||
|
||
function updateQueuePills() {
|
||
const pills = document.querySelectorAll('.queue-pill');
|
||
pills.forEach(p => {
|
||
const qCode = p.getAttribute('data-queue') || '';
|
||
if (qCode === currentQueue) {
|
||
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-slate-100 dark:bg-slate-800/80 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700/80 border border-slate-200/80 dark:border-slate-700/80 flex-shrink-0";
|
||
}
|
||
});
|
||
}
|
||
|
||
// Update de tellers op de DX / SD wachtrijfilter knoppen
|
||
function updateQueueCounts() {
|
||
const assigned = jiraData.assigned_issues || [];
|
||
const team = jiraData.team_issues || [];
|
||
const all = [...assigned, ...team];
|
||
|
||
let currentList = all;
|
||
if (currentTab === 'assigned') currentList = assigned;
|
||
else if (currentTab === 'team') currentList = team;
|
||
else if (currentTab === 'in_progress') {
|
||
currentList = all.filter(iss => {
|
||
const s = (iss.status || '').toLowerCase();
|
||
const sc = (iss.status_category || '').toLowerCase();
|
||
return s.includes('progress') || s.includes('bezig') || s.includes('review') || sc === 'indeterminate';
|
||
});
|
||
} else if (currentTab === 'done') {
|
||
currentList = all.filter(iss => {
|
||
const s = (iss.status || '').toLowerCase();
|
||
const sc = (iss.status_category || '').toLowerCase();
|
||
return s.includes('done') || s.includes('afgerond') || s.includes('gesloten') || sc === 'done';
|
||
});
|
||
}
|
||
|
||
if (currentProject) {
|
||
const pNorm = currentProject.trim().toLowerCase();
|
||
currentList = currentList.filter(iss => {
|
||
const pName = (iss.project || '').trim().toLowerCase();
|
||
const pKey = (iss.project_key || '').trim().toLowerCase();
|
||
return pName === pNorm || pKey === pNorm;
|
||
});
|
||
}
|
||
|
||
let dxCount = 0;
|
||
let sdCount = 0;
|
||
|
||
currentList.forEach(iss => {
|
||
if (isIssueInQueue(iss, 'DX')) {
|
||
dxCount++;
|
||
} else {
|
||
sdCount++;
|
||
}
|
||
});
|
||
|
||
const elAll = document.getElementById('queueCount_all');
|
||
const elDx = document.getElementById('queueCount_DX');
|
||
const elSd = document.getElementById('queueCount_SD');
|
||
|
||
if (elAll) elAll.innerText = currentList.length;
|
||
if (elDx) elDx.innerText = dxCount;
|
||
if (elSd) elSd.innerText = sdCount;
|
||
|
||
(jiraData.queue_filters || []).forEach(f => {
|
||
const el = document.getElementById(`queueCount_${f.filter_code}`);
|
||
if (el && f.filter_code !== 'DX' && f.filter_code !== 'SD') {
|
||
const cnt = currentList.filter(iss => isIssueInQueue(iss, f.filter_code)).length;
|
||
el.innerText = cnt;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Snelkoppeling KPI-kaart Urgent
|
||
function filterByUrgent() {
|
||
const pSelect = document.getElementById('filterPriority');
|
||
if (pSelect) {
|
||
pSelect.value = 'High';
|
||
}
|
||
applyFilters();
|
||
}
|
||
|
||
// Zoekopdracht wissen
|
||
function clearSearch() {
|
||
const input = document.getElementById('jiraSearchInput');
|
||
if (input) {
|
||
input.value = '';
|
||
}
|
||
document.getElementById('clearSearchBtn').classList.add('hidden');
|
||
applyFilters();
|
||
}
|
||
|
||
// Alle filters herstellen
|
||
function resetAllFilters() {
|
||
currentProject = '';
|
||
currentQueue = '';
|
||
const searchInput = document.getElementById('jiraSearchInput');
|
||
const projectSelect = document.getElementById('filterProject');
|
||
const typeSelect = document.getElementById('filterType');
|
||
const prioritySelect = document.getElementById('filterPriority');
|
||
const statusSelect = document.getElementById('filterStatus');
|
||
|
||
if (searchInput) searchInput.value = '';
|
||
if (projectSelect) projectSelect.value = '';
|
||
if (typeSelect) typeSelect.value = '';
|
||
if (prioritySelect) prioritySelect.value = '';
|
||
if (statusSelect) statusSelect.value = '';
|
||
|
||
document.getElementById('clearSearchBtn')?.classList.add('hidden');
|
||
updateProjectPills();
|
||
updateQueuePills();
|
||
updateQueueCounts();
|
||
applyFilters();
|
||
}
|
||
|
||
// Filter en render logic
|
||
function applyFilters() {
|
||
const searchVal = (document.getElementById('jiraSearchInput')?.value || '').trim().toLowerCase();
|
||
const projectVal = currentProject || (document.getElementById('filterProject')?.value || '');
|
||
const typeVal = (document.getElementById('filterType')?.value || '').trim().toLowerCase();
|
||
const priorityVal = (document.getElementById('filterPriority')?.value || '').trim();
|
||
const statusVal = (document.getElementById('filterStatus')?.value || '').trim().toLowerCase();
|
||
|
||
const clearBtn = document.getElementById('clearSearchBtn');
|
||
if (clearBtn) {
|
||
if (searchVal.length > 0) clearBtn.classList.remove('hidden');
|
||
else clearBtn.classList.add('hidden');
|
||
}
|
||
|
||
const assigned = jiraData.assigned_issues || [];
|
||
const team = jiraData.team_issues || [];
|
||
const all = [...assigned, ...team];
|
||
|
||
let sourceList = [];
|
||
if (currentTab === 'assigned') {
|
||
sourceList = assigned;
|
||
} else if (currentTab === 'team') {
|
||
sourceList = team;
|
||
} else if (currentTab === 'in_progress') {
|
||
sourceList = all.filter(iss => {
|
||
const s = (iss.status || '').toLowerCase();
|
||
const sc = (iss.status_category || '').toLowerCase();
|
||
return s.includes('progress') || s.includes('bezig') || s.includes('review') || sc === 'indeterminate';
|
||
});
|
||
} else if (currentTab === 'done') {
|
||
sourceList = all.filter(iss => {
|
||
const s = (iss.status || '').toLowerCase();
|
||
const sc = (iss.status_category || '').toLowerCase();
|
||
return s.includes('done') || s.includes('afgerond') || s.includes('gesloten') || sc === 'done';
|
||
});
|
||
}
|
||
|
||
// Pas zoek-, project-, wachtrij-, type-, prioriteit- en statusfilters toe
|
||
const filtered = sourceList.filter(iss => {
|
||
if (searchVal) {
|
||
const keyMatch = (iss.key || '').toLowerCase().includes(searchVal);
|
||
const sumMatch = (iss.summary || '').toLowerCase().includes(searchVal);
|
||
const prjMatch = (iss.project || '').toLowerCase().includes(searchVal);
|
||
const prjKeyMatch = (iss.project_key || '').toLowerCase().includes(searchVal);
|
||
const descMatch = (iss.description || '').toLowerCase().includes(searchVal);
|
||
const repMatch = (iss.reporter_name || '').toLowerCase().includes(searchVal);
|
||
const assMatch = (iss.assignee_name || '').toLowerCase().includes(searchVal);
|
||
const olMatch = (iss.onderwerp_logging || '').toLowerCase().includes(searchVal);
|
||
if (!keyMatch && !sumMatch && !prjMatch && !prjKeyMatch && !descMatch && !repMatch && !assMatch && !olMatch) return false;
|
||
}
|
||
|
||
if (projectVal) {
|
||
const pNorm = projectVal.trim().toLowerCase();
|
||
const pName = (iss.project || '').trim().toLowerCase();
|
||
const pKey = (iss.project_key || '').trim().toLowerCase();
|
||
if (pName !== pNorm && pKey !== pNorm) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (currentQueue) {
|
||
if (!isIssueInQueue(iss, currentQueue)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (typeVal && (iss.issue_type || '').toLowerCase() !== typeVal) {
|
||
return false;
|
||
}
|
||
|
||
if (priorityVal) {
|
||
const pVal = priorityVal.toLowerCase();
|
||
const ip = (iss.priority || '').toLowerCase();
|
||
if (pVal === 'high' || pVal === 'highest' || pVal === 'p1') {
|
||
if (!['highest', 'high', 'hoogste', 'hoog', 'urgent', 'p1'].includes(ip)) return false;
|
||
} else if (pVal === 'medium' || pVal === 'p2') {
|
||
if (!['medium', 'gemiddeld', 'normaal', 'p2'].includes(ip)) return false;
|
||
} else if (pVal === 'low' || pVal === 'lowest' || pVal === 'p3') {
|
||
if (!['low', 'lowest', 'laag', 'laagste', 'p3'].includes(ip)) return false;
|
||
} else if (ip !== pVal) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (statusVal && (iss.status || '').toLowerCase() !== statusVal) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
updateActiveFilterBadge();
|
||
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';
|
||
}
|
||
|
||
// 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') || 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('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') || 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>';
|
||
}
|
||
|
||
// 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 Tag -->
|
||
<span class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300">
|
||
<i data-lucide="folder" class="w-3 h-3 text-slate-400"></i>
|
||
<span>${escapeHtml(ticket.project || 'Project')}</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();
|
||
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'; ?>
|
||
|