Files
Fociz/timeline.php
T
2026-09-12 17:04:16 +02:00

1184 lines
54 KiB
PHP

<?php
/**
* Fociz Project Tijdlijn & Gantt Planning
* Interactieve tijdlijn per maand/dag met blauwe Vandaag-indicator,
* inline taakcreatie en 100% gedeelde projectdata met het Kanban-bord.
*/
$pageTitle = 'Fociz — Project Tijdlijn';
require_once __DIR__ . '/includes/header.php';
$userId = (int)($currentUser['id'] ?? 0);
$userRole = $currentUser['role'] ?? 'MEMBER';
$isAdmin = ($userRole === 'ADMIN');
?>
<style>
/* Tijdlijn styling */
.timeline-row:hover {
background-color: rgba(99, 102, 241, 0.04);
}
html.dark .timeline-row:hover {
background-color: rgba(99, 102, 241, 0.08);
}
.timeline-bar {
transition: box-shadow 0.15s ease, transform 0.1s ease;
user-select: none;
}
.timeline-bar:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.25);
z-index: 20;
}
.today-line {
pointer-events: none;
z-index: 30;
}
/* Custom scrollbar voor timeline grid */
#timelineGridWrapper::-webkit-scrollbar {
height: 8px;
}
#timelineGridWrapper::-webkit-scrollbar-track {
background: transparent;
}
#timelineGridWrapper::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.35);
border-radius: 4px;
}
html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.6);
}
</style>
<div id="timelineMainContainer" class="space-y-4 w-full">
<!-- 1. Bovenbalk: Projectkiezer, Weergave-schakelaar, Filters & Acties (ALTIJD vaste standaard breedte) -->
<div class="max-w-7xl mx-auto w-full">
<div class="fociz-card p-4 sm:p-5">
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
<!-- Linkerkant: Projectkiezer & Titel -->
<div class="flex items-center space-x-3.5">
<div class="w-10 h-10 rounded-2xl bg-gradient-to-tr from-violet-600 to-indigo-600 text-white flex items-center justify-center shadow-sm shadow-indigo-500/20 flex-shrink-0">
<i data-lucide="clock" class="w-5 h-5"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<select id="boardSelector" onchange="onBoardSelectChange(this.value)" class="bg-transparent font-black text-xl sm:text-2xl text-slate-900 dark:text-white cursor-pointer focus:outline-none border-b border-transparent hover:border-indigo-500 transition py-0.5">
<option value="">Laden...</option>
</select>
<span id="boardRoleBadge" class="hidden px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/30">
Beheerder
</span>
</div>
<p id="boardDescriptionText" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Gantt-planning, mijlpalen & datumbereiken
</p>
</div>
</div>
<!-- Weergave-schakelaar: Bord (Kanban) vs Tijdlijn (Gantt) -->
<div class="inline-flex p-1 bg-slate-100/90 dark:bg-[#0c0f17] border border-slate-200/90 dark:border-slate-800/90 rounded-xl text-xs font-semibold shadow-2xs self-start xl:self-center">
<a id="linkToBoard" href="/board.php" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200/60 dark:hover:bg-slate-800/60 transition">
<i data-lucide="kanban" class="w-3.5 h-3.5"></i>
<span>Bord</span>
</a>
<span class="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 text-indigo-600 dark:text-indigo-400 font-bold shadow-2xs cursor-default">
<i data-lucide="clock" class="w-3.5 h-3.5"></i>
<span>Tijdlijn</span>
</span>
</div>
<!-- Midden & Rechts: Filters & Actieknoppen (zoals in Jira Timeline) -->
<div class="flex flex-wrap items-center gap-2">
<!-- Zoekbalk: Tijdlijn doorzoeken -->
<div class="relative">
<i data-lucide="search" class="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2"></i>
<input
type="text"
id="timelineSearch"
placeholder="Tijdlijn doorzoeken..."
oninput="renderTimeline()"
class="pl-8 pr-3 py-2 text-xs bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl text-slate-800 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 w-36 sm:w-44 shadow-2xs"
>
</div>
<!-- Filter: Assignee / Collega -->
<div class="relative">
<select id="filterAssignee" onchange="renderTimeline()" class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium focus:ring-2 focus:ring-indigo-500/30 cursor-pointer shadow-2xs">
<option value="ALL">Alle collega's</option>
<option value="ME">Mijn taken</option>
<option value="UNASSIGNED">Niet toegewezen</option>
</select>
</div>
<!-- Filter: Statuscategorie / Kolom -->
<div class="relative">
<select id="filterLane" onchange="renderTimeline()" class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium focus:ring-2 focus:ring-indigo-500/30 cursor-pointer shadow-2xs">
<option value="ALL">Statuscategorie (Alle)</option>
</select>
</div>
<!-- Filter: Snelle filters (Ingepland / Urgent) -->
<div class="relative">
<select id="filterQuick" onchange="renderTimeline()" class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium focus:ring-2 focus:ring-indigo-500/30 cursor-pointer shadow-2xs">
<option value="ALL">Snelle filters</option>
<option value="SCHEDULED">Alleen ingepland</option>
<option value="UNSCHEDULED">Nog niet ingepland</option>
<option value="URGENT">P1 Urgent</option>
</select>
</div>
<!-- Knop Vandaag: direct scrollen naar huidige datum -->
<button
type="button"
onclick="scrollToToday()"
title="Direct scrollen naar vandaag"
class="px-3 py-2 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 font-bold text-xs rounded-xl transition flex items-center space-x-1.5 shadow-2xs cursor-pointer"
>
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span>Vandaag</span>
</button>
<!-- Hoofdactie: Nieuwe Taak / Opdracht toevoegen -->
<button
onclick="openCardModal(null)"
class="px-4 py-2 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-semibold text-xs rounded-xl transition flex items-center space-x-1.5 shadow-sm shadow-indigo-600/30 cursor-pointer"
>
<i data-lucide="plus" class="w-4 h-4 stroke-[2.5]"></i>
<span>Nieuwe Taak</span>
</button>
</div>
</div>
</div>
</div>
<!-- 2. Tijdlijn Split-View Container (Volledige breedte benutting zoals gewenst) -->
<div class="w-full transition-all duration-300">
<div class="fociz-card p-0 overflow-hidden shadow-md flex flex-col min-h-[600px]">
<div class="flex flex-1 overflow-hidden relative">
<!-- LINKERPANEEL: 'Werk' Takenlijst & Snelle Invoer -->
<div class="w-80 sm:w-96 border-r border-slate-200 dark:border-slate-800/80 flex flex-col flex-shrink-0 bg-white dark:bg-[#0c0f17]/90 z-20">
<!-- Werk Paneel Header met Lane Drop Targets -->
<div class="h-12 px-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between bg-slate-50/70 dark:bg-slate-900/40">
<div class="flex items-center space-x-2">
<span class="text-xs font-bold text-slate-800 dark:text-slate-200 uppercase tracking-wider">Werk</span>
<span id="workItemsCountBadge" class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">0</span>
</div>
<div class="text-[11px] text-slate-400">Sleep taak naar lane / datum</div>
</div>
<!-- Snelle Lane Drop Zone Balk (verschijnt subtiel of is altijd zichtbaar) -->
<div id="timelineLaneDropBar" class="px-2.5 py-1.5 bg-slate-100/70 dark:bg-slate-900/60 border-b border-slate-200/60 dark:border-slate-800/60 flex items-center gap-1.5 overflow-x-auto text-[11px]">
<!-- Dynamisch gevuld via renderTimelineLaneDropBar() -->
</div>
<!-- Inline Snelle Taakinvoer: ⚡ Wat moet er gedaan worden? (Exact zoals Jira) -->
<div class="p-2.5 border-b border-slate-200/60 dark:border-slate-800/60 bg-slate-50/40 dark:bg-[#111624]/40">
<form onsubmit="handleQuickCreateTask(event)" class="relative flex items-center">
<i data-lucide="zap" class="w-3.5 h-3.5 text-indigo-500 absolute left-3 top-1/2 -translate-y-1/2"></i>
<input
type="text"
id="quickTaskInput"
placeholder="Wat moet er gedaan worden? (Druk op Enter)"
class="w-full pl-8 pr-8 py-2 text-xs bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 shadow-2xs"
>
<button type="submit" title="Taak direct toevoegen" class="absolute right-2 p-1 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition">
<i data-lucide="corner-down-left" class="w-3.5 h-3.5"></i>
</button>
</form>
</div>
<!-- Werk Items Rijen Lijst -->
<div id="workItemsListContainer" class="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800/40 select-none">
<!-- Dynamisch gevuld via renderTimeline() -->
</div>
</div>
<!-- RECHTERPANEEL: Interactief Gantt Tijdlijn Rooster -->
<div id="timelineGridWrapper" class="flex-1 overflow-x-auto overflow-y-hidden flex flex-col relative bg-slate-50/30 dark:bg-[#080a10]">
<!-- Maanden / Weken Kalender Header -->
<div id="timelineCalendarHeader" class="h-12 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center sticky top-0 bg-slate-50/90 dark:bg-slate-900/90 backdrop-blur-sm z-10 select-none font-semibold text-xs text-slate-600 dark:text-slate-300">
<!-- Dynamisch gevuld via generateCalendarHeader() -->
</div>
<!-- Rijen Grid voor Taken en Balken -->
<div id="timelineRowsGrid" class="flex-1 relative divide-y divide-slate-100 dark:divide-slate-800/40 min-h-[480px]">
<!-- Verticale "Vandaag" Blauwe Lijn (loopt van top naar bottom over alle rijen) -->
<div id="todayIndicatorLine" class="today-line absolute top-0 bottom-0 border-l-2 border-blue-500 dark:border-blue-400 hidden">
<div class="w-2.5 h-2.5 rounded-full bg-blue-500 -ml-[5px] -mt-1.5 shadow-xs"></div>
</div>
<!-- Rijen per taak dynamically rendered -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Gedeelde MODAL 1: Taak Toevoegen of Bewerken (100% gesynchroniseerd met Kanban bord) -->
<div id="cardModal" onclick="if(event.target === this) closeCardModal()" 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="fociz-card p-6 w-full max-w-xl shadow-2xl space-y-5 my-8">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-2.5">
<div class="w-8 h-8 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
<i data-lucide="edit-3" class="w-4 h-4"></i>
</div>
<h3 id="cardModalTitle" class="text-base font-bold text-slate-900 dark:text-white">Taak / Opdracht Bewerken</h3>
</div>
<button onclick="closeCardModal()" 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-5 h-5"></i>
</button>
</div>
<form id="cardForm" onsubmit="saveCardForm(event)" class="space-y-4 text-xs">
<input type="hidden" id="cardId" value="">
<!-- Titel van de opdracht -->
<div>
<label class="block font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1">
Titel van de Taak *
</label>
<input
type="text"
id="cardTitle"
required
placeholder="Bijv. API koppeling testen, Klantvoorstel schrijven"
class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
</div>
<!-- Beschrijving & Details -->
<div>
<label class="block font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1">
Omschrijving & Briefing
</label>
<textarea
id="cardDescription"
rows="3"
placeholder="Voeg toelichting, links of instructies toe..."
class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
></textarea>
</div>
<!-- Grid: Kolom / Status, Toewijzing (Assignee), Prioriteit & Kleur -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- Kolom / Lane -->
<div>
<label class="block font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1">
Status / Kolom *
</label>
<select id="cardLaneSelect" 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-slate-900 dark:text-white focus:ring-2 focus:ring-indigo-500/40">
<!-- Dynamisch gevuld -->
</select>
</div>
<!-- Toewijzen aan Collega -->
<div>
<label class="block font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1">
Toewijzen aan Collega
</label>
<select id="cardAssigneeSelect" 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-slate-900 dark:text-white focus:ring-2 focus:ring-indigo-500/40">
<option value="">-- Niet toegewezen --</option>
</select>
</div>
<!-- Prioriteit -->
<div>
<label class="block font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1">
Prioriteit
</label>
<select id="cardPrioritySelect" 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-slate-900 dark:text-white focus:ring-2 focus:ring-indigo-500/40">
<option value="P1">P1 — Urgent</option>
<option value="P2" selected>P2 — Hoog</option>
<option value="P3">P3 — Normaal</option>
</select>
</div>
<!-- Post-it Kaartkleur -->
<div>
<label class="block font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1">
Kleurthema
</label>
<select id="cardColorSelect" 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-slate-900 dark:text-white focus:ring-2 focus:ring-indigo-500/40">
<option value="yellow">Geel (Klassiek Post-it)</option>
<option value="blue">Blauw</option>
<option value="green">Groen</option>
<option value="purple">Paars</option>
<option value="pink">Roze</option>
<option value="orange">Oranje</option>
<option value="slate">Neutraal Slate</option>
</select>
</div>
</div>
<!-- Tijdlijn & Agenda Planning Sectie -->
<div class="p-4 rounded-xl bg-slate-50 dark:bg-slate-950/70 border border-slate-200 dark:border-slate-800 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="calendar" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span class="font-bold text-slate-900 dark:text-white">Tijdlijn & Agenda Planning</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="cardScheduleToggle" onchange="toggleScheduleFields(this.checked)" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div id="cardScheduleFields" class="space-y-3 hidden pt-2 border-t border-slate-200/60 dark:border-slate-800/60">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Startdatum *</label>
<input type="date" id="cardStartDate" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Einddatum *</label>
<input type="date" id="cardEndDate" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none">
</div>
</div>
<!-- Outlook Agenda Synchronisatie -->
<div class="flex items-center justify-between pt-2 border-t border-slate-200/50 dark:border-slate-800/50">
<div class="flex items-center space-x-2">
<i data-lucide="calendar-check" class="w-3.5 h-3.5 text-sky-600 dark:text-sky-400"></i>
<div>
<span class="text-[11px] font-semibold text-slate-800 dark:text-slate-200">Outlook 365 Agenda Blokkering</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400 block">Afspraak aanmaken in de agenda van de collega</span>
</div>
</div>
<input type="checkbox" id="cardSyncOutlook" class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700 cursor-pointer">
</div>
</div>
</div>
<!-- Knoppen & Acties -->
<div class="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-slate-800/80">
<button
type="button"
id="btnDeleteCard"
onclick="deleteCurrentCard()"
class="hidden text-rose-600 hover:text-rose-700 dark:text-rose-400 text-xs font-semibold flex items-center space-x-1 cursor-pointer"
>
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
<span>Verwijderen</span>
</button>
<div class="flex items-center space-x-2 ml-auto">
<button type="button" onclick="closeCardModal()" class="px-4 py-2 rounded-xl text-xs font-semibold text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
Annuleren
</button>
<button type="submit" id="btnSaveCard" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition shadow-xs cursor-pointer">
Opslaan
</button>
</div>
</div>
</form>
</div>
</div>
<script>
// --- Globale Status voor Tijdlijn ---
let currentBoardId = null;
let currentBoardData = null;
let allBoards = [];
const currentUserId = <?= (int)$userId ?>;
const isUserAdmin = <?= $isAdmin ? 'true' : 'false' ?>;
// Kleuren voor de balken per post-it kleur
const TIMELINE_BAR_COLORS = {
yellow: 'bg-amber-500/90 text-amber-950 border-amber-400 dark:bg-amber-500/80 dark:text-amber-100 dark:border-amber-400/30',
blue: 'bg-sky-500/90 text-sky-950 border-sky-400 dark:bg-sky-500/80 dark:text-sky-100 dark:border-sky-400/30',
green: 'bg-emerald-500/90 text-emerald-950 border-emerald-400 dark:bg-emerald-500/80 dark:text-emerald-100 dark:border-emerald-400/30',
purple: 'bg-purple-500/90 text-purple-950 border-purple-400 dark:bg-purple-500/80 dark:text-purple-100 dark:border-purple-400/30',
pink: 'bg-pink-500/90 text-pink-950 border-pink-400 dark:bg-pink-500/80 dark:text-pink-100 dark:border-pink-400/30',
orange: 'bg-orange-500/90 text-orange-950 border-orange-400 dark:bg-orange-500/80 dark:text-orange-100 dark:border-orange-400/30',
slate: 'bg-slate-600/90 text-white border-slate-500 dark:bg-slate-700/80 dark:text-slate-100 dark:border-slate-600'
};
const LANE_COLORS = {
slate: 'bg-slate-500',
amber: 'bg-amber-500',
purple: 'bg-purple-500',
emerald: 'bg-emerald-500',
sky: 'bg-sky-500',
rose: 'bg-rose-500'
};
// Kalender instellingen: dagbreedte in pixels & datumbereik
const PX_PER_DAY = 14; // pixels per dag (voldoende compact en overzichtelijk)
let timelineStartDate = null;
let timelineEndDate = null;
let totalTimelineDays = 0;
// 1. Initialisatie
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('board_id')) {
currentBoardId = parseInt(urlParams.get('board_id'), 10);
}
setupTimelineCalendarRange();
await loadBoardsList();
});
// Configureert kalenderbereik: 2 maanden terug t/m 8 maanden vooruit
function setupTimelineCalendarRange() {
const today = new Date();
// 2 maanden terug op de 1e van de maand
timelineStartDate = new Date(today.getFullYear(), today.getMonth() - 2, 1);
// 8 maanden vooruit op de laatste van de maand
timelineEndDate = new Date(today.getFullYear(), today.getMonth() + 9, 0);
totalTimelineDays = Math.round((timelineEndDate - timelineStartDate) / (1000 * 60 * 60 * 24)) + 1;
}
// Haalt alle borden op
async function loadBoardsList() {
try {
const res = await fetch('/api/board.php?action=get_boards');
const data = await res.json();
if (data.success && data.boards) {
allBoards = data.boards;
populateBoardSelector();
if (allBoards.length > 0) {
const activeId = currentBoardId || allBoards[0].id;
await loadBoardData(activeId);
}
}
} catch (e) {
console.error('Fout bij laden borden:', e);
}
}
function populateBoardSelector() {
const sel = document.getElementById('boardSelector');
sel.innerHTML = '';
allBoards.forEach(b => {
const opt = document.createElement('option');
opt.value = b.id;
opt.innerText = b.title;
if (String(b.id) === String(currentBoardId)) opt.selected = true;
sel.appendChild(opt);
});
}
function onBoardSelectChange(bId) {
if (bId) {
loadBoardData(parseInt(bId, 10));
}
}
// 2. Data inladen voor het geselecteerde project
async function loadBoardData(boardId) {
currentBoardId = boardId;
document.getElementById('linkToBoard')?.setAttribute('href', `/board.php?board_id=${boardId}`);
try {
const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`);
const data = await res.json();
if (data.success && data.has_board) {
currentBoardData = data;
document.getElementById('boardDescriptionText').innerText = data.board.description || 'Geen toelichting';
// Beheerdersbadge
const badge = document.getElementById('boardRoleBadge');
if (data.is_board_admin) {
badge.classList.remove('hidden');
badge.innerText = 'Beheerder';
} else {
badge.classList.add('hidden');
}
// Vul filter dropdowns
populateLaneFilter();
populateModalDropdowns();
// Render tijdlijn
generateCalendarHeader();
renderTimeline();
// Automatisch scrollen naar vandaag na eerste render
setTimeout(scrollToToday, 200);
}
} catch (err) {
console.error('Fout bij ophalen tijdlijndata:', err);
}
}
function populateLaneFilter() {
const sel = document.getElementById('filterLane');
sel.innerHTML = '<option value="ALL">Statuscategorie (Alle)</option>';
if (currentBoardData && currentBoardData.lanes) {
currentBoardData.lanes.forEach(lane => {
const opt = document.createElement('option');
opt.value = lane.id;
opt.innerText = lane.title;
sel.appendChild(opt);
});
}
renderTimelineLaneDropBar();
}
// Render drop targets voor snelle statuswijziging in het werkpaneel
function renderTimelineLaneDropBar() {
const bar = document.getElementById('timelineLaneDropBar');
if (!bar) return;
bar.innerHTML = '';
const lanes = (currentBoardData && currentBoardData.lanes) ? currentBoardData.lanes : [];
if (lanes.length === 0) {
bar.classList.add('hidden');
return;
}
bar.classList.remove('hidden');
lanes.forEach(lane => {
const dotColor = LANE_COLORS[lane.color] || 'bg-slate-500';
const dropBadge = document.createElement('div');
dropBadge.className = 'timeline-lane-target px-2.5 py-1 rounded-lg border border-slate-200/80 dark:border-slate-800 bg-white/80 dark:bg-slate-800/70 hover:bg-indigo-50 dark:hover:bg-indigo-950/40 text-slate-700 dark:text-slate-200 font-semibold text-[11px] flex items-center space-x-1.5 cursor-pointer transition flex-shrink-0 select-none shadow-2xs';
dropBadge.setAttribute('data-lane-id', lane.id);
dropBadge.title = `Sleep een taak hierheen om te verplaatsen naar: ${lane.title}`;
dropBadge.innerHTML = `
<span class="w-2 h-2 rounded-full ${dotColor}"></span>
<span class="truncate max-w-[90px]">${escapeHtml(lane.title)}</span>
`;
dropBadge.ondragover = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
dropBadge.classList.add('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
};
dropBadge.ondragleave = () => {
dropBadge.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
};
dropBadge.ondrop = async (e) => {
e.preventDefault();
dropBadge.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
const cardIdStr = e.dataTransfer.getData('text/plain');
const cardId = parseInt(cardIdStr, 10);
if (cardId) {
const el = document.querySelector(`.timeline-row[data-card-id="${cardId}"]`);
if (el) el.classList.remove('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30', 'pointer-events-none');
}
document.querySelectorAll('.timeline-row.opacity-50').forEach(el => {
el.classList.remove('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30', 'pointer-events-none');
});
if (cardId && lane.id) {
await moveTimelineCardToLane(cardId, lane.id);
}
};
bar.appendChild(dropBadge);
});
}
function populateModalDropdowns() {
if (!currentBoardData) return;
// Status / Lane select in modal
const laneSel = document.getElementById('cardLaneSelect');
laneSel.innerHTML = '';
(currentBoardData.lanes || []).forEach(l => {
const opt = document.createElement('option');
opt.value = l.id;
opt.innerText = l.title;
laneSel.appendChild(opt);
});
// Assignee select in modal
const assSel = document.getElementById('cardAssigneeSelect');
assSel.innerHTML = '<option value="">-- Niet toegewezen --</option>';
(currentBoardData.team_members || []).forEach(tm => {
const opt = document.createElement('option');
opt.value = tm.id;
opt.innerText = `${tm.name} ${tm.has_outlook ? '(Outlook 365)' : ''}`;
assSel.appendChild(opt);
});
}
// 3. Kalender Header Genereren (Maanden & Weken zoals in Jira)
const MONTH_NAMES_NL = ['Januari', 'Februari', 'Maart', 'April', 'Mei', 'Juni', 'Juli', 'Augustus', 'September', 'Oktober', 'November', 'December'];
function generateCalendarHeader() {
const header = document.getElementById('timelineCalendarHeader');
header.innerHTML = '';
const totalWidthPx = totalTimelineDays * PX_PER_DAY;
header.style.width = `${totalWidthPx}px`;
let cur = new Date(timelineStartDate);
while (cur <= timelineEndDate) {
const year = cur.getFullYear();
const month = cur.getMonth();
const monthName = MONTH_NAMES_NL[month];
// Bepaal aantal dagen van deze maand binnen ons bereik
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthWidthPx = daysInMonth * PX_PER_DAY;
const monthEl = document.createElement('div');
monthEl.className = 'border-r border-slate-200 dark:border-slate-800/80 px-3 flex items-center justify-between text-xs font-bold tracking-tight text-slate-700 dark:text-slate-300 flex-shrink-0';
monthEl.style.width = `${monthWidthPx}px`;
monthEl.innerHTML = `
<span>${monthName} ${year}</span>
<span class="text-[10px] text-slate-400 font-normal hidden sm:inline">${daysInMonth}d</span>
`;
header.appendChild(monthEl);
// Ga naar 1e van volgende maand
cur = new Date(year, month + 1, 1);
}
}
// 4. Render Tijdlijn: Takenlijst links & Gantt rijen rechts
function renderTimeline() {
if (!currentBoardData) return;
const workContainer = document.getElementById('workItemsListContainer');
const rowsGrid = document.getElementById('timelineRowsGrid');
workContainer.innerHTML = '';
// Verwijder oude rijen uit rowsGrid (behalve de today line)
const existingRows = rowsGrid.querySelectorAll('.timeline-task-row');
existingRows.forEach(r => r.remove());
const totalWidthPx = totalTimelineDays * PX_PER_DAY;
rowsGrid.style.width = `${totalWidthPx}px`;
const filterAssignee = document.getElementById('filterAssignee').value;
const filterLane = document.getElementById('filterLane').value;
const filterQuick = document.getElementById('filterQuick').value;
const filterSearch = document.getElementById('timelineSearch').value.toLowerCase().trim();
const lanes = currentBoardData.lanes || [];
const cardsByLane = currentBoardData.cards_by_lane || {};
// Verzamel alle kaarten
let allCards = [];
lanes.forEach(lane => {
const cList = cardsByLane[lane.id] || [];
cList.forEach(c => {
allCards.push({ ...c, lane_title: lane.title, lane_color: lane.color });
});
});
// Filters toepassen
if (filterAssignee === 'ME') {
allCards = allCards.filter(c => c.assignee_id && parseInt(c.assignee_id, 10) === currentUserId);
} else if (filterAssignee === 'UNASSIGNED') {
allCards = allCards.filter(c => !c.assignee_id);
}
if (filterLane !== 'ALL') {
allCards = allCards.filter(c => String(c.lane_id) === String(filterLane));
}
if (filterQuick === 'SCHEDULED') {
allCards = allCards.filter(c => c.start_date && c.end_date);
} else if (filterQuick === 'UNSCHEDULED') {
allCards = allCards.filter(c => !c.start_date || !c.end_date);
} else if (filterQuick === 'URGENT') {
allCards = allCards.filter(c => c.priority === 'P1');
}
if (filterSearch) {
allCards = allCards.filter(c =>
(c.title && c.title.toLowerCase().includes(filterSearch)) ||
(c.description && c.description.toLowerCase().includes(filterSearch))
);
}
// Update teller
document.getElementById('workItemsCountBadge').innerText = allCards.length;
// Vandaag Indicator Lijn positioneren
updateTodayIndicator();
if (allCards.length === 0) {
workContainer.innerHTML = `
<div class="py-16 text-center text-xs text-slate-400 italic">
Geen taken gevonden die voldoen aan de filters.
</div>
`;
return;
}
// Render elke taak: linker werkitem + rechter tijdlijn rij
allCards.forEach(card => {
const laneDotColor = LANE_COLORS[card.lane_color] || 'bg-slate-500';
const hasSchedule = (card.start_date && card.end_date);
// 1. Linkerpaneel Item (draggable naar lane drop zones)
const leftItem = document.createElement('div');
leftItem.className = 'timeline-row h-12 px-3.5 flex items-center justify-between transition cursor-grab active:cursor-grabbing hover:bg-slate-100/70 dark:hover:bg-slate-800/50 group select-none';
leftItem.setAttribute('draggable', 'true');
leftItem.setAttribute('data-card-id', card.id);
leftItem.ondragstart = (e) => {
e.dataTransfer.setData('text/plain', String(card.id));
e.dataTransfer.effectAllowed = 'move';
leftItem.classList.add('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30');
};
leftItem.ondragend = () => {
leftItem.classList.remove('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30');
};
leftItem.onclick = (e) => {
// Voorkom trigger als er net gedragged werd
if (e.defaultPrevented) return;
openCardModal(card.id);
};
// Date badge
let dateBadge = '<span class="text-[10px] text-slate-400 italic">Niet gepland</span>';
if (hasSchedule) {
const sFormatted = formatDateShort(card.start_date);
const eFormatted = formatDateShort(card.end_date);
dateBadge = `<span class="text-[10px] font-semibold text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">${sFormatted} - ${eFormatted}</span>`;
}
leftItem.innerHTML = `
<div class="flex items-center space-x-2.5 min-w-0 pr-2">
<span class="w-2.5 h-2.5 rounded-full ${laneDotColor} flex-shrink-0" title="Status: ${escapeHtml(card.lane_title)}"></span>
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200 truncate" title="${escapeHtml(card.title)}">
${escapeHtml(card.title)}
</span>
</div>
<div class="flex items-center space-x-2 flex-shrink-0">
${dateBadge}
${card.assignee_name ? (
card.assignee_avatar
? `<img src="${escapeHtml(card.assignee_avatar)}" class="w-5 h-5 rounded-full object-cover flex-shrink-0 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-800/60" title="${escapeHtml(card.assignee_name)}">`
: `<div class="w-5 h-5 rounded-full bg-gradient-to-tr from-indigo-600 to-sky-500 text-white font-bold text-[9px] flex items-center justify-center flex-shrink-0" title="${escapeHtml(card.assignee_name)}">${card.assignee_name.charAt(0).toUpperCase()}</div>`
) : ''}
</div>
`;
workContainer.appendChild(leftItem);
// 2. Rechterpaneel Tijdlijn Rij
const rightRow = document.createElement('div');
rightRow.className = 'timeline-task-row timeline-row h-12 relative flex items-center';
rightRow.setAttribute('data-card-id', card.id);
// Klikken op een lege plek in de rij om direct in te plannen
rightRow.onclick = (e) => {
if (e.target.closest('.timeline-bar')) return;
handleGridRowClick(e, card.id);
};
// Render tijdlijnbalk indien ingepland
if (hasSchedule) {
const sDate = new Date(card.start_date + 'T00:00:00');
const eDate = new Date(card.end_date + 'T23:59:59');
const startOffsetDays = (sDate - timelineStartDate) / (1000 * 60 * 60 * 24);
const durationDays = Math.max(1, Math.round((eDate - sDate) / (1000 * 60 * 60 * 24)));
const leftPx = Math.max(0, startOffsetDays * PX_PER_DAY);
const widthPx = Math.max(PX_PER_DAY * 2, durationDays * PX_PER_DAY);
const barColorClass = TIMELINE_BAR_COLORS[card.color] || TIMELINE_BAR_COLORS.yellow;
const barEl = document.createElement('div');
barEl.className = `timeline-bar absolute h-7 rounded-lg border px-2.5 flex items-center text-xs font-bold truncate shadow-xs cursor-pointer ${barColorClass}`;
barEl.style.left = `${leftPx}px`;
barEl.style.width = `${widthPx}px`;
barEl.title = `${escapeHtml(card.title)} (${card.start_date} t/m ${card.end_date})`;
barEl.onclick = (ev) => {
ev.stopPropagation();
openCardModal(card.id);
};
barEl.innerHTML = `
<span class="truncate leading-none">${escapeHtml(card.title)}</span>
`;
// Maak de balk versleepbaar om datums direct aan te passen
enableBarDragging(barEl, card.id, card.start_date, card.end_date);
rightRow.appendChild(barEl);
}
rowsGrid.appendChild(rightRow);
});
if (typeof lucide !== 'undefined') lucide.createIcons();
}
// Positioneert de blauwe "Vandaag" lijn exact over de datum van vandaag
function updateTodayIndicator() {
const line = document.getElementById('todayIndicatorLine');
if (!line || !timelineStartDate) return;
const today = new Date();
today.setHours(12, 0, 0, 0);
if (today >= timelineStartDate && today <= timelineEndDate) {
const offsetDays = (today - timelineStartDate) / (1000 * 60 * 60 * 24);
const leftPx = offsetDays * PX_PER_DAY;
line.style.left = `${leftPx}px`;
line.classList.remove('hidden');
} else {
line.classList.add('hidden');
}
}
// Scrollt het tijdlijnrooster direct naar vandaag
function scrollToToday() {
const line = document.getElementById('todayIndicatorLine');
const wrapper = document.getElementById('timelineGridWrapper');
if (line && wrapper) {
const leftPx = parseFloat(line.style.left || '0');
const viewWidth = wrapper.clientWidth;
wrapper.scrollTo({
left: Math.max(0, leftPx - (viewWidth / 3)),
behavior: 'smooth'
});
}
}
// 5. Snelle Inline Taak Aanmaken: ⚡ Wat moet er gedaan worden?
async function handleQuickCreateTask(e) {
e.preventDefault();
const input = document.getElementById('quickTaskInput');
const title = input.value.trim();
if (!title || !currentBoardId) return;
// Bepaal default start- en einddatum (vandaag t/m over 7 dagen)
const today = new Date();
const nextWeek = new Date();
nextWeek.setDate(today.getDate() + 7);
const sStr = today.toISOString().split('T')[0];
const eStr = nextWeek.toISOString().split('T')[0];
try {
const res = await fetch('/api/board.php?action=quick_create_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
board_id: currentBoardId,
title: title,
start_date: sStr,
end_date: eStr
})
});
const data = await res.json();
if (data.success) {
input.value = '';
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet toevoegen');
}
} catch (err) {
console.error('Fout bij snelle taakinvoer:', err);
}
}
// 6. Klikken op een leeg gedeelte van een rij -> direct datums toewijzen
async function handleGridRowClick(e, cardId) {
const rowRect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rowRect.left;
const clickOffsetDays = Math.floor(clickX / PX_PER_DAY);
const clickedDate = new Date(timelineStartDate);
clickedDate.setDate(clickedDate.getDate() + clickOffsetDays);
const endDate = new Date(clickedDate);
endDate.setDate(endDate.getDate() + 5); // standaard 5 dagen
const sStr = clickedDate.toISOString().split('T')[0];
const eStr = endDate.toISOString().split('T')[0];
await updateCardDates(cardId, sStr, eStr);
}
// API call om start en einddatum direct bij te werken
async function updateCardDates(cardId, startDate, endDate) {
try {
const res = await fetch('/api/board.php?action=update_card_dates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
card_id: cardId,
start_date: startDate,
end_date: endDate
})
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
}
} catch (e) {
console.error('Fout bij bijwerken datums:', e);
}
}
// API call om een taak direct naar een andere lane / statuscategorie te verplaatsen
async function moveTimelineCardToLane(cardId, targetLaneId) {
try {
const res = await fetch('/api/board.php?action=move_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
card_id: cardId,
target_lane_id: targetLaneId,
target_position: 999
})
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verplaatsen');
}
} catch (e) {
console.error('Fout bij verplaatsen naar lane:', e);
}
}
// Verslepen van tijdlijnbalken
function enableBarDragging(barEl, cardId, originalStart, originalEnd) {
let isDragging = false;
let startMouseX = 0;
let initialLeftPx = 0;
barEl.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
startMouseX = e.clientX;
initialLeftPx = parseFloat(barEl.style.left);
barEl.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
const onMouseMove = (moveEvent) => {
if (!isDragging) return;
const deltaX = moveEvent.clientX - startMouseX;
barEl.style.left = `${Math.max(0, initialLeftPx + deltaX)}px`;
};
const onMouseUp = async (upEvent) => {
if (!isDragging) return;
isDragging = false;
barEl.style.cursor = 'pointer';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
const finalDeltaX = upEvent.clientX - startMouseX;
const deltaDays = Math.round(finalDeltaX / PX_PER_DAY);
if (deltaDays !== 0) {
const s = new Date(originalStart);
const e = new Date(originalEnd);
s.setDate(s.getDate() + deltaDays);
e.setDate(e.getDate() + deltaDays);
await updateCardDates(cardId, s.toISOString().split('T')[0], e.toISOString().split('T')[0]);
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
}
// 7. Taak Modal Handlers (100% gedeeld met Kanban)
function openCardModal(cardId = null) {
const modal = document.getElementById('cardModal');
const form = document.getElementById('cardForm');
form.reset();
const titleEl = document.getElementById('cardModalTitle');
const deleteBtn = document.getElementById('btnDeleteCard');
document.getElementById('cardId').value = cardId || '';
if (cardId) {
titleEl.innerText = 'Taak Bewerken';
deleteBtn.classList.remove('hidden');
// Zoek kaart op in data
let foundCard = null;
if (currentBoardData && currentBoardData.cards_by_lane) {
for (const lId in currentBoardData.cards_by_lane) {
const match = currentBoardData.cards_by_lane[lId].find(c => c.id === cardId);
if (match) { foundCard = match; break; }
}
}
if (foundCard) {
document.getElementById('cardTitle').value = foundCard.title || '';
document.getElementById('cardDescription').value = foundCard.description || '';
document.getElementById('cardLaneSelect').value = foundCard.lane_id || '';
document.getElementById('cardAssigneeSelect').value = foundCard.assignee_id || '';
document.getElementById('cardPrioritySelect').value = foundCard.priority || 'P2';
document.getElementById('cardColorSelect').value = foundCard.color || 'yellow';
// Tijdlijn/agenda
const hasSched = !!(foundCard.start_date && foundCard.end_date);
document.getElementById('cardScheduleToggle').checked = hasSched;
toggleScheduleFields(hasSched);
if (hasSched) {
document.getElementById('cardStartDate').value = foundCard.start_date || '';
document.getElementById('cardEndDate').value = foundCard.end_date || '';
document.getElementById('cardSyncOutlook').checked = !!foundCard.outlook_event_id;
}
}
} else {
titleEl.innerText = 'Nieuwe Taak Toevoegen';
deleteBtn.classList.add('hidden');
document.getElementById('cardScheduleToggle').checked = false;
toggleScheduleFields(false);
}
modal.classList.remove('hidden');
modal.classList.add('flex');
document.getElementById('cardTitle').focus();
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeCardModal() {
const modal = document.getElementById('cardModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function toggleScheduleFields(show) {
const fields = document.getElementById('cardScheduleFields');
if (show) {
fields.classList.remove('hidden');
if (!document.getElementById('cardStartDate').value) {
const today = new Date().toISOString().split('T')[0];
document.getElementById('cardStartDate').value = today;
document.getElementById('cardEndDate').value = today;
}
} else {
fields.classList.add('hidden');
}
}
async function saveCardForm(e) {
e.preventDefault();
const btn = document.getElementById('btnSaveCard');
btn.disabled = true;
btn.innerText = 'Opslaan...';
const cardId = parseInt(document.getElementById('cardId').value, 10) || 0;
const isScheduleEnabled = document.getElementById('cardScheduleToggle').checked;
const payload = {
board_id: currentBoardId,
card_id: cardId,
title: document.getElementById('cardTitle').value.trim(),
description: document.getElementById('cardDescription').value.trim(),
lane_id: parseInt(document.getElementById('cardLaneSelect').value, 10),
assignee_id: document.getElementById('cardAssigneeSelect').value || null,
priority: document.getElementById('cardPrioritySelect').value,
color: document.getElementById('cardColorSelect').value,
schedule: {
enabled: isScheduleEnabled,
type: 'DAILY_RANGE',
start_date: document.getElementById('cardStartDate').value,
end_date: document.getElementById('cardEndDate').value,
start_time: '09:00',
end_time: '17:00',
sync_outlook: document.getElementById('cardSyncOutlook').checked
}
};
try {
const res = await fetch('/api/board.php?action=save_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
closeCardModal();
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet opslaan');
}
} catch (err) {
alert('Netwerkfout bij opslaan taak');
} finally {
btn.disabled = false;
btn.innerText = 'Opslaan';
}
}
async function deleteCurrentCard() {
const cardId = parseInt(document.getElementById('cardId').value, 10);
if (!cardId) return;
if (!confirm('Weet je zeker dat je deze taak wilt verwijderen?')) return;
try {
const res = await fetch('/api/board.php?action=delete_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cardId })
});
const data = await res.json();
if (data.success) {
closeCardModal();
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verwijderen');
}
} catch (e) {
alert('Netwerkfout bij verwijderen taak');
}
}
// Datum formaat helper
function formatDateShort(dateStr) {
if (!dateStr) return '';
const parts = dateStr.split('-');
if (parts.length < 3) return dateStr;
const d = parseInt(parts[2], 10);
const mIdx = parseInt(parts[1], 10) - 1;
const months = ['jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec'];
return `${d} ${months[mIdx] || ''}`;
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeCardModal();
}
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>