Merge pull request 'Dev' (#71) from dev into main

Reviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
2026-09-12 15:15:39 +00:00
9 changed files with 1732 additions and 66 deletions
+161 -1
View File
@@ -146,7 +146,167 @@ try {
exit; exit;
} }
// 4. Omzetten naar Roadmap! // 3c. Kaart bewerken (Inhoud, Categorie en/of Kleur)
if ($action === 'update_card') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$cardId = (int)($data['card_id'] ?? 0);
$content = trim($data['content'] ?? '');
$category = trim($data['category'] ?? '');
if (!$cardId || empty($content)) {
echo json_encode(['error' => 'Kaart ID en inhoud zijn verplicht']);
exit;
}
$colorMap = [
'Ideeën' => 'amber',
'Knelpunten' => 'rose',
'Oplossingen' => 'teal',
'Quick Wins' => 'indigo'
];
$color = $colorMap[$category] ?? 'amber';
if (!empty($category)) {
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ?, category = ?, color = ? WHERE id = ?");
$stmt->execute([$content, $category, $color, $cardId]);
} else {
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ? WHERE id = ?");
$stmt->execute([$content, $cardId]);
}
echo json_encode(['success' => true, 'card_id' => $cardId, 'content' => $content, 'category' => $category, 'color' => $color]);
exit;
}
// 3d. Kaart toewijzen aan collega en inplannen in agenda (Vrij blok)
if ($action === 'assign_and_schedule') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$cardId = (int)($data['card_id'] ?? 0);
$assigneeId = !empty($data['assignee_id']) ? (int)$data['assignee_id'] : null;
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : null;
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : null;
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : null;
$durationMinutes = (int)($data['duration_minutes'] ?? 30);
$syncOutlook = !empty($data['sync_outlook']);
if (!$cardId) {
echo json_encode(['error' => 'Kaart ID ontbreekt']);
exit;
}
$stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?");
$stmtCard->execute([$cardId]);
$card = $stmtCard->fetch();
if (!$card) {
echo json_encode(['error' => 'Kaart niet gevonden']);
exit;
}
// Bereken eindtijd indien nodig
if ($startTime && !$endTime && $durationMinutes > 0) {
$tParts = explode(':', $startTime);
$startMins = ((int)$tParts[0] * 60) + (int)($tParts[1] ?? 0);
$endMins = $startMins + $durationMinutes;
$endTime = sprintf('%02d:%02d', floor($endMins / 60), $endMins % 60);
}
$createdTaskId = null;
$outlookSyncMsg = null;
// Als er een datum & tijd is gekozen én een collega (of huidige gebruiker)
if ($assigneeId && $planDate && $startTime) {
// Zoek of maak daily plan
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$assigneeId, $planDate]);
$planId = (int)$db->lastInsertId();
$taskTitle = "Brainstorm taak: " . mb_substr($card['content'], 0, 75);
$taskDesc = "Afkomstig van Brainstormsessie. Inhoud:\n" . $card['content'];
$source = 'BRAINSTORM';
$externalId = null;
$externalUrl = null;
// Indien Outlook sync gewenst is
if ($syncOutlook) {
require_once __DIR__ . '/../includes/integrations/microsoft.php';
try {
$msResult = microsoft_create_event($assigneeId, [
'subject' => $taskTitle,
'description' => $taskDesc,
'date' => $planDate,
'start_time' => $startTime,
'end_time' => $endTime,
'duration_minutes' => $durationMinutes,
]);
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
$source = 'OUTLOOK';
$externalId = $msResult['event_id'];
$externalUrl = $msResult['web_link'] ?? null;
$outlookSyncMsg = 'Ingepland in Outlook 365 agenda.';
}
} catch (Throwable $t) {
// stil doorgaan
}
}
// Maak of update taak
if (!empty($card['scheduled_task_id'])) {
$stmtUpdateTask = $db->prepare("
UPDATE tasks
SET user_id = ?, daily_plan_id = ?, title = ?, description = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ?
");
$stmtUpdateTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $card['scheduled_task_id']]);
$createdTaskId = (int)$card['scheduled_task_id'];
} else {
$stmtInsertTask = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
VALUES (?, ?, ?, ?, 'P2', ?, ?, ?, 'TODO', ?, ?, ?)
");
$stmtInsertTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $source, $externalId, $externalUrl]);
$createdTaskId = (int)$db->lastInsertId();
}
}
// Update brainstorm kaart
$stmtUpdateCard = $db->prepare("
UPDATE brainstorm_cards
SET assignee_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?, scheduled_task_id = ?
WHERE id = ?
");
$stmtUpdateCard->execute([$assigneeId, $planDate, $startTime, $endTime, $createdTaskId, $cardId]);
// Haal assignee naam & avatar op
$assigneeName = null;
$assigneeAvatar = null;
if ($assigneeId) {
$stmtUser = $db->prepare("SELECT name, avatar_url FROM users WHERE id = ?");
$stmtUser->execute([$assigneeId]);
$usr = $stmtUser->fetch();
if ($usr) {
$assigneeName = $usr['name'];
$assigneeAvatar = $usr['avatar_url'];
}
}
echo json_encode([
'success' => true,
'card_id' => $cardId,
'assignee_id' => $assigneeId,
'assignee_name' => $assigneeName,
'assignee_avatar' => $assigneeAvatar,
'scheduled_date' => $planDate,
'scheduled_start_time' => $startTime,
'scheduled_end_time' => $endTime,
'scheduled_task_id' => $createdTaskId,
'outlook_message' => $outlookSyncMsg
]);
exit;
}
if ($action === 'convert_to_roadmap') { if ($action === 'convert_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$cardIds = $data['card_ids'] ?? []; $cardIds = $data['card_ids'] ?? [];
+7 -6
View File
@@ -695,6 +695,7 @@ try {
$duration = max(5, (int)($data['duration_minutes'] ?? 30)); $duration = max(5, (int)($data['duration_minutes'] ?? 30));
$excludeTaskId = (int)($data['task_id'] ?? 0); $excludeTaskId = (int)($data['task_id'] ?? 0);
$startFromNextWorkday = !empty($data['start_from_next_workday']); $startFromNextWorkday = !empty($data['start_from_next_workday']);
$targetUserId = !empty($data['target_user_id']) ? (int)$data['target_user_id'] : (int)$user['id'];
$workdaySettings = get_workday_settings(); $workdaySettings = get_workday_settings();
$workdayStartStr = $workdaySettings['workday_start'] ?? '08:30'; $workdayStartStr = $workdaySettings['workday_start'] ?? '08:30';
@@ -757,7 +758,7 @@ try {
$rangeEndObj->modify('+21 days'); // ruim venster incl. weekenden $rangeEndObj->modify('+21 days'); // ruim venster incl. weekenden
$rangeEndDate = $rangeEndObj->format('Y-m-d'); $rangeEndDate = $rangeEndObj->format('Y-m-d');
// 1. Batch SQL: haal alle bestaande taken van de gebruiker in 1 geoptimaliseerde query op // 1. Batch SQL: haal alle bestaande taken van de doelcollega in 1 geoptimaliseerde query op
$tasksByDate = []; $tasksByDate = [];
$stmtTasks = $db->prepare(" $stmtTasks = $db->prepare("
SELECT t.id, dp.plan_date, t.time_block_start, t.time_block_end, t.duration_minutes SELECT t.id, dp.plan_date, t.time_block_start, t.time_block_end, t.duration_minutes
@@ -769,7 +770,7 @@ try {
AND t.time_block_start != '' AND t.time_block_start != ''
AND t.status != 'CANCELLED' AND t.status != 'CANCELLED'
"); ");
$stmtTasks->execute([$user['id'], $rangeStartDate, $rangeEndDate]); $stmtTasks->execute([$targetUserId, $rangeStartDate, $rangeEndDate]);
while ($t = $stmtTasks->fetch()) { while ($t = $stmtTasks->fetch()) {
if ($excludeTaskId > 0 && (int)$t['id'] === $excludeTaskId) { if ($excludeTaskId > 0 && (int)$t['id'] === $excludeTaskId) {
continue; continue;
@@ -777,11 +778,11 @@ try {
$tasksByDate[$t['plan_date']][] = $t; $tasksByDate[$t['plan_date']][] = $t;
} }
// 2. Batch Microsoft Graph: haal alle Outlook events in 1 API call op voor het gehele venster // 2. Batch Microsoft Graph: haal alle Outlook events in 1 API call op voor het gehele venster van de doelcollega
$outlookByDate = []; $outlookByDate = [];
if (function_exists('microsoft_fetch_events_range')) { if (function_exists('microsoft_fetch_events_range')) {
try { try {
$allOutlookEvts = microsoft_fetch_events_range($user['id'], $rangeStartDate, $rangeEndDate, false); $allOutlookEvts = microsoft_fetch_events_range($targetUserId, $rangeStartDate, $rangeEndDate, false);
foreach ($allOutlookEvts as $oe) { foreach ($allOutlookEvts as $oe) {
if (!empty($oe['date'])) { if (!empty($oe['date'])) {
$outlookByDate[$oe['date']][] = $oe; $outlookByDate[$oe['date']][] = $oe;
@@ -790,11 +791,11 @@ try {
} catch (Throwable $e) {} } catch (Throwable $e) {}
} }
// 3. Batch Calendar Blocks: haal verlof en geblokkeerde vakantiedagen op voor dit venster // 3. Batch Calendar Blocks: haal verlof en geblokkeerde vakantiedagen op voor dit venster van de doelcollega
$blockedDates = []; $blockedDates = [];
try { try {
$stmtBlk = $db->prepare("SELECT start_date, end_date FROM calendar_blocks WHERE user_id = ? AND start_date <= ? AND end_date >= ?"); $stmtBlk = $db->prepare("SELECT start_date, end_date FROM calendar_blocks WHERE user_id = ? AND start_date <= ? AND end_date >= ?");
$stmtBlk->execute([$user['id'], $rangeEndDate, $rangeStartDate]); $stmtBlk->execute([$targetUserId, $rangeEndDate, $rangeStartDate]);
while ($b = $stmtBlk->fetch()) { while ($b = $stmtBlk->fetch()) {
$bStart = strtotime($b['start_date']); $bStart = strtotime($b['start_date']);
$bEnd = strtotime($b['end_date']); $bEnd = strtotime($b['end_date']);
+20
View File
@@ -57,6 +57,26 @@ try {
$fields[] = "quarter = ?"; $fields[] = "quarter = ?";
$params[] = $data['quarter']; $params[] = $data['quarter'];
} }
if (isset($data['title']) && trim($data['title']) !== '') {
$fields[] = "title = ?";
$params[] = trim($data['title']);
}
if (isset($data['description'])) {
$fields[] = "description = ?";
$params[] = trim($data['description']);
}
if (isset($data['type'])) {
$fields[] = "type = ?";
$params[] = $data['type'];
}
if (isset($data['priority'])) {
$fields[] = "priority = ?";
$params[] = $data['priority'];
}
if (array_key_exists('due_date', $data)) {
$fields[] = "due_date = ?";
$params[] = !empty($data['due_date']) ? $data['due_date'] : null;
}
if ($progress !== null) { if ($progress !== null) {
$fields[] = "progress = ?"; $fields[] = "progress = ?";
$params[] = max(0, min(100, $progress)); $params[] = max(0, min(100, $progress));
+252 -11
View File
@@ -21,6 +21,47 @@ $isAdmin = ($userRole === 'ADMIN');
max-width: 100% !important; max-width: 100% !important;
width: 100% !important; width: 100% !important;
} }
/* --- Realistische Post-it Styling met Opkrullende Rand Links Onder --- */
.postit-note {
position: relative;
border-radius: 4px 18px 4px 18px;
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12), 0 2px 6px -1px rgba(0, 0, 0, 0.08);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.04));
}
.postit-note:hover {
transform: translateY(-3px) scale(1.01);
box-shadow: 0 12px 20px -4px rgba(0, 0, 0, 0.16), 0 6px 10px -2px rgba(0, 0, 0, 0.08);
}
.postit-note::after {
content: '';
position: absolute;
bottom: -1px;
left: -1px;
width: 20px;
height: 20px;
background: linear-gradient(135deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.06) 45%, transparent 50%);
border-bottom-left-radius: 4px;
box-shadow: -2px 2px 4px rgba(0,0,0,0.12);
pointer-events: none;
z-index: 5;
}
.postit-note::before {
content: '';
position: absolute;
top: 0;
left: 15%;
right: 15%;
height: 4px;
background: rgba(255, 255, 255, 0.4);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
pointer-events: none;
}
.dark .postit-note::before {
background: rgba(255, 255, 255, 0.1);
}
</style> </style>
<div id="boardPageContainer" class="space-y-4 w-full"> <div id="boardPageContainer" class="space-y-4 w-full">
@@ -277,6 +318,25 @@ $isAdmin = ($userRole === 'ADMIN');
</div> </div>
</div> </div>
<!-- Knop Vrij Blok Zoeken voor Assignee -->
<div class="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between gap-2">
<div class="text-[11px]">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3 h-3 text-amber-500"></i>
<span>Vrij agendablok zoeken</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden van de toegewezen collega</span>
</div>
<button type="button" id="btnBoardFindSlot" onclick="findFreeSlotForBoardAssignee()" class="px-2.5 py-1 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-[10px] shadow-xs transition flex items-center space-x-1 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3 h-3"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="boardFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-[11px] text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-emerald-500 flex-shrink-0"></i>
<span id="boardFoundSlotText"></span>
</div>
<div id="scheduleEndDateRow" class="hidden"> <div id="scheduleEndDateRow" class="hidden">
<label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Einddatum herhaling</label> <label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Einddatum herhaling</label>
<input type="date" id="cardModalEndDate" value="<?= date('Y-m-d', strtotime('+7 days')) ?>" 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-800 dark:text-slate-200 focus:outline-none"> <input type="date" id="cardModalEndDate" value="<?= date('Y-m-d', strtotime('+7 days')) ?>" 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-800 dark:text-slate-200 focus:outline-none">
@@ -425,7 +485,39 @@ $isAdmin = ($userRole === 'ADMIN');
<button onclick="submitCreateBoard()" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition">Bord Aanmaken</button> <button onclick="submitCreateBoard()" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition">Bord Aanmaken</button>
</div> </div>
</div> </div>
<!-- Floating Rechtermuisknop Contextmenu voor Bord Kaarten -->
<div id="boardCardContextMenu" class="hidden fixed z-[999] bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl py-1.5 w-64 text-xs font-medium text-slate-700 dark:text-slate-200 select-none animate-in fade-in zoom-in-95 duration-100">
<div class="px-3.5 py-1.5 border-b border-slate-100 dark:border-slate-800/80 mb-1">
<span id="boardCtxTitle" class="text-[11px] font-bold text-slate-900 dark:text-white truncate block"></span>
<span id="boardCtxMeta" class="text-[10px] text-slate-400 truncate block"></span>
</div> </div>
<button onclick="triggerBoardCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Taak bewerken</span>
</button>
<button onclick="triggerBoardCtxQuickSlot()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="zap" class="w-4 h-4 text-amber-500"></i>
<span>⚡ Zoek direct vrij blok</span>
</button>
<button onclick="triggerBoardCtxQuickMove(-1)" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="arrow-left" class="w-4 h-4 text-slate-500"></i>
<span>Verplaats naar vorige kolom</span>
</button>
<button onclick="triggerBoardCtxQuickMove(1)" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="arrow-right" class="w-4 h-4 text-slate-500"></i>
<span>Verplaats naar volgende kolom</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerBoardCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Taak verwijderen</span>
</button>
</div> </div>
<script> <script>
@@ -508,17 +600,19 @@ function onBoardSelectChange(bId) {
} }
// 2. Data van specifiek bord inladen // 2. Data van specifiek bord inladen
async function loadBoardData(boardId) { async function loadBoardData(boardId, showLoader = true) {
currentBoardId = boardId; currentBoardId = boardId;
document.getElementById('linkToTimeline')?.setAttribute('href', `/timeline.php?board_id=${boardId}`); document.getElementById('linkToTimeline')?.setAttribute('href', `/timeline.php?board_id=${boardId}`);
const container = document.getElementById('boardLanesContainer'); const container = document.getElementById('boardLanesContainer');
container.innerHTML = ` if (showLoader) {
<div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm"> container.innerHTML = `
<i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i> <div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm">
<span>Bord laden...</span> <i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i>
</div> <span>Bord laden...</span>
`; </div>
if (typeof lucide !== 'undefined') lucide.createIcons(); `;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try { try {
const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`); const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`);
@@ -567,9 +661,13 @@ function onCardDragStart(e, cardId) {
function onCardDragEnd(e) { function onCardDragEnd(e) {
if (draggedCardId) { if (draggedCardId) {
const el = document.getElementById(`card-${draggedCardId}`); const el = document.getElementById(`card-${draggedCardId}`);
if (el) el.classList.remove('opacity-40'); if (el) el.classList.remove('opacity-40', 'pointer-events-none');
draggedCardId = null; draggedCardId = null;
} }
// Verwijder eventuele achtergebleven opacity op alle kaarten
document.querySelectorAll('[id^="card-"].opacity-40').forEach(el => {
el.classList.remove('opacity-40', 'pointer-events-none');
});
setTimeout(() => { setTimeout(() => {
isDraggingCard = false; isDraggingCard = false;
}, 150); }, 150);
@@ -599,6 +697,23 @@ async function onLaneDrop(e, targetLaneId) {
const rawId = draggedCardId || e.dataTransfer.getData('text/plain'); const rawId = draggedCardId || e.dataTransfer.getData('text/plain');
const cardId = parseInt(rawId, 10); const cardId = parseInt(rawId, 10);
// Direct re-activeren van het gesleepte element
const activeEl = cardId ? document.getElementById(`card-${cardId}`) : null;
if (activeEl) {
activeEl.classList.remove('opacity-40', 'pointer-events-none');
// Optimistic DOM append als dropZone en element bestaan
const emptyNotice = dropZone.querySelector('.pointer-events-none');
if (emptyNotice && emptyNotice.innerText.includes('Geen taken')) {
emptyNotice.remove();
}
dropZone.appendChild(activeEl);
}
draggedCardId = null;
setTimeout(() => {
isDraggingCard = false;
}, 150);
if (!cardId) return; if (!cardId) return;
try { try {
@@ -611,7 +726,7 @@ async function onLaneDrop(e, targetLaneId) {
target_position: 999 target_position: 999
}) })
}); });
await loadBoardData(currentBoardId); await loadBoardData(currentBoardId, false);
} catch (err) { } catch (err) {
console.error('Fout bij verplaatsen taak:', err); console.error('Fout bij verplaatsen taak:', err);
} }
@@ -715,7 +830,8 @@ function renderCards() {
ondragstart="onCardDragStart(event, ${card.id})" ondragstart="onCardDragStart(event, ${card.id})"
ondragend="onCardDragEnd(event)" ondragend="onCardDragEnd(event)"
onclick="onCardClick(event, ${card.id})" onclick="onCardClick(event, ${card.id})"
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5" oncontextmenu="openBoardCardContextMenu(event, ${card.id}); return false;"
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5 postit-note"
> >
<div class="flex items-start justify-between gap-1.5 mb-1.5"> <div class="flex items-start justify-between gap-1.5 mb-1.5">
<h4 class="text-xs font-bold leading-snug line-clamp-2 break-words"> <h4 class="text-xs font-bold leading-snug line-clamp-2 break-words">
@@ -1030,6 +1146,131 @@ async function quickMoveCard(cardId, direction) {
} }
} }
// Rechtermuisknop Context Menu Logica voor Bord
let activeBoardCtxCardId = null;
function openBoardCardContextMenu(e, cardId) {
e.preventDefault();
e.stopPropagation();
activeBoardCtxCardId = cardId;
const menu = document.getElementById('boardCardContextMenu');
if (!menu) return;
menu.classList.remove('hidden');
const x = Math.min(e.clientX, window.innerWidth - 240);
const y = Math.min(e.clientY, window.innerHeight - 240);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeBoardCardContextMenu() {
const menu = document.getElementById('boardCardContextMenu');
if (menu) menu.classList.add('hidden');
activeBoardCtxCardId = null;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#boardCardContextMenu')) {
closeBoardCardContextMenu();
}
});
function triggerBoardCtxEdit() {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
openCardModal(cid);
}
async function triggerBoardCtxQuickSlot() {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
openCardModal(cid);
setTimeout(() => {
findFreeSlotForBoardAssignee();
}, 150);
}
function triggerBoardCtxQuickMove(dir) {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
quickMoveCard(cid, dir);
}
async function triggerBoardCtxDelete() {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
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: cid })
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verwijderen');
}
} catch (e) {
alert('Netwerkfout bij verwijderen taak');
}
}
// Zoek vrij agendablok voor de in de modal geselecteerde assignee
async function findFreeSlotForBoardAssignee() {
const sel = document.getElementById('cardModalAssignee');
const targetUserId = sel.value ? parseInt(sel.value, 10) : currentUserId;
const schedCheckbox = document.getElementById('cardModalScheduleEnabled');
if (!schedCheckbox.checked) {
schedCheckbox.checked = true;
toggleScheduleFields();
}
const btn = document.getElementById('btnBoardFindSlot');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i><span>Zoeken...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: 60,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('cardModalStartDate').value = slot.date;
document.getElementById('cardModalEndDate').value = slot.date;
document.getElementById('cardModalStartTime').value = slot.start;
document.getElementById('cardModalEndTime').value = slot.end;
const notice = document.getElementById('boardFoundSlotNotice');
const noticeText = document.getElementById('boardFoundSlotText');
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden voor deze collega.');
}
} catch (err) {
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
// 8. Beheer Modal: Lanes & Leden Autorisatie // 8. Beheer Modal: Lanes & Leden Autorisatie
function openManageBoardModal() { function openManageBoardModal() {
if (!currentBoardData) return; if (!currentBoardData) return;
+563 -46
View File
@@ -25,51 +25,112 @@ if (!$session) {
exit; exit;
} }
// Haal alle kaarten op van deze sessie // Haal alle kaarten op van deze sessie inclusief toewijzing
$stmtCards = $db->prepare(" $stmtCards = $db->prepare("
SELECT * FROM brainstorm_cards SELECT c.*, u.name as assignee_name, u.avatar_url as assignee_avatar
WHERE session_id = ? FROM brainstorm_cards c
ORDER BY votes DESC, created_at ASC LEFT JOIN users u ON c.assignee_id = u.id
WHERE c.session_id = ?
ORDER BY c.votes DESC, c.created_at ASC
"); ");
$stmtCards->execute([$sessionId]); $stmtCards->execute([$sessionId]);
$cards = $stmtCards->fetchAll(); $cards = $stmtCards->fetchAll();
// Haal alle actieve teamleden op voor toewijzing & agendablokkades
$teamMembersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
$teamMembers = $teamMembersStmt->fetchAll();
$outlookUsers = [];
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
foreach ($intStmt->fetchAll() as $row) {
$outlookUsers[(int)$row['user_id']] = true;
}
foreach ($teamMembers as &$tm) {
$tm['has_outlook'] = !empty($outlookUsers[(int)$tm['id']]);
}
unset($tm);
$categories = [ $categories = [
'Ideeën' => [ 'Ideeën' => [
'title' => 'Ideeën & Concepten', 'title' => 'Ideeën & Concepten',
'icon' => 'lightbulb', 'icon' => 'lightbulb',
'color' => 'amber', 'color' => 'amber',
'border' => 'border-amber-200/80 dark:border-amber-800/40', 'border' => 'border-amber-300 dark:border-amber-600/40',
'bg' => 'bg-amber-50/60 dark:bg-amber-950/20', 'bg' => 'bg-[#fef9c3] dark:bg-[#713f12]/30 text-amber-950 dark:text-amber-100',
'badge' => 'bg-amber-500/15 text-amber-800 dark:text-amber-300' 'badge' => 'bg-amber-500/20 text-amber-900 dark:text-amber-200'
], ],
'Knelpunten' => [ 'Knelpunten' => [
'title' => 'Knelpunten & Uitdagingen', 'title' => 'Knelpunten & Uitdagingen',
'icon' => 'alert-triangle', 'icon' => 'alert-triangle',
'color' => 'rose', 'color' => 'rose',
'border' => 'border-rose-200/80 dark:border-rose-800/40', 'border' => 'border-rose-300 dark:border-rose-600/40',
'bg' => 'bg-rose-50/60 dark:bg-rose-950/20', 'bg' => 'bg-[#ffe4e6] dark:bg-[#881337]/30 text-rose-950 dark:text-rose-100',
'badge' => 'bg-rose-500/15 text-rose-800 dark:text-rose-300' 'badge' => 'bg-rose-500/20 text-rose-900 dark:text-rose-200'
], ],
'Oplossingen' => [ 'Oplossingen' => [
'title' => 'Oplossingen & Features', 'title' => 'Oplossingen & Features',
'icon' => 'rocket', 'icon' => 'rocket',
'color' => 'teal', 'color' => 'teal',
'border' => 'border-teal-200/80 dark:border-teal-800/40', 'border' => 'border-teal-300 dark:border-teal-600/40',
'bg' => 'bg-teal-50/60 dark:bg-teal-950/20', 'bg' => 'bg-[#ccfbf1] dark:bg-[#134e4a]/30 text-teal-950 dark:text-teal-100',
'badge' => 'bg-teal-500/15 text-teal-800 dark:text-teal-300' 'badge' => 'bg-teal-500/20 text-teal-900 dark:text-teal-200'
], ],
'Quick Wins' => [ 'Quick Wins' => [
'title' => 'Quick Wins (Direct)', 'title' => 'Quick Wins (Direct)',
'icon' => 'zap', 'icon' => 'zap',
'color' => 'indigo', 'color' => 'indigo',
'border' => 'border-indigo-200/80 dark:border-indigo-800/40', 'border' => 'border-indigo-300 dark:border-indigo-600/40',
'bg' => 'bg-indigo-50/60 dark:bg-indigo-950/20', 'bg' => 'bg-[#e0e7ff] dark:bg-[#312e81]/30 text-indigo-950 dark:text-indigo-100',
'badge' => 'bg-indigo-500/15 text-indigo-800 dark:text-indigo-300' 'badge' => 'bg-indigo-500/20 text-indigo-900 dark:text-indigo-200'
], ],
]; ];
?> ?>
<style>
/* --- Realistische Post-it Styling met Opkrullende Rand Links Onder --- */
.postit-note {
position: relative;
border-radius: 4px 18px 4px 18px;
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12), 0 2px 6px -1px rgba(0, 0, 0, 0.08);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.04));
}
.postit-note:hover {
transform: translateY(-3px) scale(1.01);
box-shadow: 0 12px 20px -4px rgba(0, 0, 0, 0.16), 0 6px 10px -2px rgba(0, 0, 0, 0.08);
}
/* Opkrullend hoekje links onder */
.postit-note::after {
content: '';
position: absolute;
bottom: -1px;
left: -1px;
width: 22px;
height: 22px;
background: linear-gradient(135deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.06) 45%, transparent 50%);
border-bottom-left-radius: 4px;
box-shadow: -2px 2px 4px rgba(0,0,0,0.12);
pointer-events: none;
z-index: 5;
}
/* Subtiele tape / plakrand bovenaan */
.postit-note::before {
content: '';
position: absolute;
top: 0;
left: 15%;
right: 15%;
height: 4px;
background: rgba(255, 255, 255, 0.4);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
pointer-events: none;
}
.dark .postit-note::before {
background: rgba(255, 255, 255, 0.1);
}
</style>
<div class="space-y-6 max-w-7xl mx-auto"> <div class="space-y-6 max-w-7xl mx-auto">
<!-- Top Bar --> <!-- Top Bar -->
<div class="fociz-card p-5 flex flex-col lg:flex-row lg:items-center justify-between gap-4"> <div class="fociz-card p-5 flex flex-col lg:flex-row lg:items-center justify-between gap-4">
@@ -146,44 +207,89 @@ $categories = [
Nog geen kaarten in deze categorie. Nog geen kaarten in deze categorie.
</div> </div>
<?php else: ?> <?php else: ?>
<?php foreach ($catCards as $card): ?> <?php foreach ($catCards as $card):
$hasAssignee = !empty($card['assignee_id']) && !empty($card['assignee_name']);
$hasSchedule = !empty($card['scheduled_date']) && !empty($card['scheduled_start_time']);
$cardJson = htmlspecialchars(json_encode([
'id' => (int)$card['id'],
'content' => $card['content'],
'category' => $card['category'],
'color' => $card['color'],
'assignee_id' => $card['assignee_id'] ? (int)$card['assignee_id'] : null,
'assignee_name' => $card['assignee_name'] ?? null,
'scheduled_date' => $card['scheduled_date'] ?? null,
'scheduled_start_time' => $card['scheduled_start_time'] ?? null,
'scheduled_end_time' => $card['scheduled_end_time'] ?? null,
]), ENT_QUOTES, 'UTF-8');
?>
<div <div
id="card-<?= $card['id'] ?>" id="card-<?= $card['id'] ?>"
draggable="true" draggable="true"
ondragstart="handleCanvasCardDragStart(event, <?= $card['id'] ?>)" ondragstart="handleCanvasCardDragStart(event, <?= $card['id'] ?>)"
ondragend="handleCanvasCardDragEnd(event)" ondragend="handleCanvasCardDragEnd(event)"
class="brainstorm-card group relative rounded-2xl p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> backdrop-blur-sm shadow-sm hover:shadow-md transition-all duration-150 cursor-grab active:cursor-grabbing hover:-translate-y-0.5 flex flex-col justify-between" oncontextmenu="openCardContextMenu(event, <?= $cardJson ?>); return false;"
class="brainstorm-card postit-note group relative p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> transition-all duration-150 cursor-grab active:cursor-grabbing hover:-translate-y-1 flex flex-col justify-between"
> >
<!-- Top: Checkbox voor selectie + Roadmap badge --> <!-- Top: Checkbox voor selectie + Roadmap badge -->
<div class="flex items-center justify-between gap-2 mb-2.5"> <div class="flex items-center justify-between gap-2 mb-2">
<label class="inline-flex items-center space-x-2 cursor-pointer select-none"> <label class="inline-flex items-center space-x-2 cursor-pointer select-none">
<input type="checkbox" value="<?= $card['id'] ?>" data-content="<?= htmlspecialchars($card['content'], ENT_QUOTES) ?>" class="card-checkbox w-4 h-4 rounded bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-indigo-600 focus:ring-0"> <input type="checkbox" value="<?= $card['id'] ?>" data-content="<?= htmlspecialchars($card['content'], ENT_QUOTES) ?>" class="card-checkbox w-4 h-4 rounded bg-white/80 dark:bg-slate-900/80 border-slate-400/50 text-indigo-600 focus:ring-0">
<span class="text-[11px] text-slate-500 dark:text-slate-400 font-medium">Selecteer</span> <span class="text-[11px] opacity-70 font-medium">Selecteer</span>
</label> </label>
<?php if (!empty($card['converted_to_roadmap_item_id'])): ?> <div class="flex items-center space-x-1">
<span class="px-2 py-0.5 rounded-md bg-teal-50 dark:bg-teal-500/15 text-teal-700 dark:text-teal-400 font-bold text-[10px] flex items-center space-x-1 border border-teal-200 dark:border-teal-500/20"> <?php if (!empty($card['converted_to_roadmap_item_id'])): ?>
<i data-lucide="check" class="w-3 h-3"></i> <span class="px-2 py-0.5 rounded-md bg-teal-600/15 text-teal-800 dark:text-teal-300 font-bold text-[10px] flex items-center space-x-1 border border-teal-500/30">
<span>Op Roadmap</span> <i data-lucide="check" class="w-3 h-3"></i>
</span> <span>Roadmap</span>
<?php endif; ?> </span>
<?php endif; ?>
<button type="button" onclick="openCardContextMenu(event, <?= $cardJson ?>)" class="p-1 rounded text-slate-500 hover:text-slate-800 dark:hover:text-white transition cursor-pointer" title="Opties & Inplannen (Rechtermuisknop)">
<i data-lucide="more-horizontal" class="w-3.5 h-3.5"></i>
</button>
</div>
</div> </div>
<!-- Inhoud Post-it met verfijnde typografie en uitlijning --> <!-- Inhoud Post-it met verfijnde typografie en uitlijning -->
<div class="text-left py-1"> <div class="text-left py-1" onclick="openEditCardModal(<?= $cardJson ?>)">
<p class="text-xs text-slate-800 dark:text-slate-100 font-medium leading-relaxed whitespace-pre-wrap break-words text-left"> <p class="text-xs font-semibold leading-relaxed whitespace-pre-wrap break-words text-left cursor-pointer" title="Klik om te bewerken">
<?= htmlspecialchars($card['content']) ?> <?= htmlspecialchars($card['content']) ?>
</p> </p>
</div> </div>
<!-- Assignee & Agenda Tijdblok Badge (indien gekoppeld) -->
<?php if ($hasAssignee || $hasSchedule): ?>
<div class="mt-2.5 pt-2 border-t border-black/10 dark:border-white/10 flex items-center justify-between gap-1 text-[10px]">
<?php if ($hasAssignee): ?>
<div class="flex items-center space-x-1.5 truncate">
<?php if (!empty($card['assignee_avatar'])): ?>
<img src="<?= htmlspecialchars($card['assignee_avatar']) ?>" class="w-4 h-4 rounded-full object-cover border border-black/10">
<?php else: ?>
<div class="w-4 h-4 rounded-full bg-indigo-600 text-white font-bold text-[8px] flex items-center justify-center">
<?= strtoupper(substr($card['assignee_name'], 0, 1)) ?>
</div>
<?php endif; ?>
<span class="font-bold truncate max-w-[100px]"><?= htmlspecialchars($card['assignee_name']) ?></span>
</div>
<?php endif; ?>
<?php if ($hasSchedule): ?>
<span class="inline-flex items-center space-x-1 font-bold px-1.5 py-0.5 rounded bg-black/10 dark:bg-white/10 text-[9px]" title="Ingepland op <?= htmlspecialchars($card['scheduled_date']) ?>">
<i data-lucide="clock" class="w-2.5 h-2.5"></i>
<span><?= htmlspecialchars(substr($card['scheduled_start_time'], 0, 5)) ?></span>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Footer van kaart --> <!-- Footer van kaart -->
<div class="mt-3 pt-2.5 border-t border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between text-[10px] text-slate-500 dark:text-slate-400"> <div class="mt-2.5 pt-2 border-t border-black/10 dark:border-white/10 flex items-center justify-between text-[10px] opacity-80">
<div class="flex items-center space-x-1.5 min-w-0"> <div class="flex items-center space-x-1.5 min-w-0">
<span class="truncate font-semibold text-slate-700 dark:text-slate-300"><?= htmlspecialchars($card['author_name'] ?? 'Collega') ?></span> <span class="truncate font-medium"><?= htmlspecialchars($card['author_name'] ?? 'Collega') ?></span>
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?> <?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
<button onclick="openAiBreakdownModal(<?= $card['id'] ?>, <?= htmlspecialchars(json_encode($card['content']), ENT_QUOTES) ?>)" class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-lg bg-violet-100/90 hover:bg-violet-200 dark:bg-violet-950/60 dark:hover:bg-violet-900/60 text-violet-700 dark:text-violet-300 font-bold text-[10px] transition cursor-pointer border border-violet-200/80 dark:border-violet-800/60 shadow-2xs flex-shrink-0" title="Splits dit idee uit naar behapbare taken via Google Gemini Pro"> <button onclick="openAiBreakdownModal(<?= $card['id'] ?>, <?= htmlspecialchars(json_encode($card['content']), ENT_QUOTES) ?>)" class="inline-flex items-center space-x-1 px-1.5 py-0.5 rounded bg-black/10 hover:bg-black/15 dark:bg-white/10 dark:hover:bg-white/15 font-bold text-[9px] transition cursor-pointer" title="Splits dit idee uit naar taken via AI">
<i data-lucide="sparkles" class="w-3 h-3 text-violet-500"></i> <i data-lucide="sparkles" class="w-2.5 h-2.5 text-violet-600 dark:text-violet-400"></i>
<span>AI</span> <span>AI</span>
</button> </button>
<?php endif; ?> <?php endif; ?>
@@ -191,13 +297,13 @@ $categories = [
<div class="flex items-center space-x-1.5 flex-shrink-0"> <div class="flex items-center space-x-1.5 flex-shrink-0">
<!-- Upvote button --> <!-- Upvote button -->
<button onclick="voteCard(<?= $card['id'] ?>)" class="flex items-center space-x-1 px-2 py-1 rounded-lg bg-white/80 hover:bg-white dark:bg-slate-800/80 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 hover:text-amber-500 dark:hover:text-amber-400 border border-slate-200/80 dark:border-transparent transition shadow-xs cursor-pointer"> <button onclick="voteCard(<?= $card['id'] ?>)" class="flex items-center space-x-1 px-2 py-0.5 rounded bg-black/10 hover:bg-black/15 dark:bg-white/10 dark:hover:bg-white/15 transition shadow-2xs cursor-pointer">
<i data-lucide="thumbs-up" class="w-3 h-3"></i> <i data-lucide="thumbs-up" class="w-3 h-3"></i>
<span id="votes-<?= $card['id'] ?>" class="font-bold"><?= $card['votes'] ?></span> <span id="votes-<?= $card['id'] ?>" class="font-bold"><?= $card['votes'] ?></span>
</button> </button>
<!-- Delete button --> <!-- Delete button -->
<button onclick="deleteCard(<?= $card['id'] ?>)" class="p-1 rounded text-slate-400 hover:text-rose-600 dark:text-slate-500 dark:hover:text-rose-400 transition opacity-0 group-hover:opacity-100 cursor-pointer"> <button onclick="deleteCard(<?= $card['id'] ?>)" class="p-1 rounded text-rose-600 hover:text-rose-700 opacity-0 group-hover:opacity-100 transition cursor-pointer" title="Verwijderen">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> <i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button> </button>
</div> </div>
@@ -217,23 +323,66 @@ $categories = [
</div> </div>
</div> </div>
<!-- Modal: Nieuwe Kaart Toevoegen --> <!-- Floating Rechtermuisknop Contextmenu -->
<div id="addCardModal" onclick="if(event.target === this) closeAddCardModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4"> <div id="cardContextMenu" class="hidden fixed z-[999] bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl py-1.5 w-64 text-xs font-medium text-slate-700 dark:text-slate-200 select-none animate-in fade-in zoom-in-95 duration-100">
<div class="px-3.5 py-1.5 border-b border-slate-100 dark:border-slate-800/80 mb-1">
<span id="ctxMenuCardTitle" class="text-[11px] font-bold text-slate-900 dark:text-white truncate block"></span>
<span id="ctxMenuCardMeta" class="text-[10px] text-slate-400 truncate block"></span>
</div>
<button onclick="triggerCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Kaart bewerken</span>
</button>
<button onclick="triggerCtxAssign()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="user-check" class="w-4 h-4 text-emerald-500"></i>
<span>Toewijzen & Agenda inplannen</span>
</button>
<button onclick="triggerCtxQuickSlot()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="zap" class="w-4 h-4 text-amber-500"></i>
<span>⚡ Zoek direct vrij blok</span>
</button>
<button onclick="triggerCtxRoadmap()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="map" class="w-4 h-4 text-sky-500"></i>
<span>Zet om naar Roadmap</span>
</button>
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
<button onclick="triggerCtxBreakdown()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-violet-50 dark:hover:bg-violet-950/50 hover:text-violet-600 dark:hover:text-violet-400 transition cursor-pointer">
<i data-lucide="sparkles" class="w-4 h-4 text-violet-500"></i>
<span>AI Splits uit in taken</span>
</button>
<?php endif; ?>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Kaart verwijderen</span>
</button>
</div>
<!-- Modal: Kaart Bewerken -->
<div id="editCardModal" onclick="if(event.target === this) closeEditCardModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 shadow-2xl max-w-md w-full space-y-4"> <div class="fociz-card p-6 shadow-2xl max-w-md w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800"> <div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2"> <h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="sticky-note" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i> <i data-lucide="edit-3" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Brainstormkaart Toevoegen</span> <span>Brainstormkaart Bewerken</span>
</h3> </h3>
<button onclick="closeAddCardModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer"> <button onclick="closeEditCardModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i> <i data-lucide="x" class="w-4 h-4"></i>
</button> </button>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<input type="hidden" id="editCardId" value="">
<div> <div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Categorie</label> <label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Categorie</label>
<select id="modalCardCategory" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs"> <select id="editCardCategory" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="Ideeën">💡 Ideeën & Concepten</option> <option value="Ideeën">💡 Ideeën & Concepten</option>
<option value="Knelpunten">⚠️ Knelpunten & Uitdagingen</option> <option value="Knelpunten">⚠️ Knelpunten & Uitdagingen</option>
<option value="Oplossingen">🚀 Oplossingen & Features</option> <option value="Oplossingen">🚀 Oplossingen & Features</option>
@@ -242,15 +391,125 @@ $categories = [
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Idee / Beschrijving</label> <label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Inhoud Post-it</label>
<textarea id="modalCardContent" rows="4" placeholder="Schrijf hier uw inzicht, idee of knelpunt..." class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea> <textarea id="editCardContent" rows="4" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
</div> </div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80"> <div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button onclick="submitNewCard()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs"> <button onclick="submitEditCard()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Plaats op Canvas Wijzigingen Opslaan
</button> </button>
<button onclick="closeAddCardModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer"> <button onclick="closeEditCardModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Toewijzen aan Collega & Inplannen in Agenda (Vrij Blok) -->
<div id="assignScheduleModal" onclick="if(event.target === this) closeAssignScheduleModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-xl bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Toewijzen & Agenda Inplanning</h3>
<p class="text-[11px] text-slate-400">Plan deze brainstormkaart in als taak in het vrije blok van een collega</p>
</div>
</div>
<button onclick="closeAssignScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<div class="space-y-4">
<input type="hidden" id="assignCardId" value="">
<div class="p-3 bg-slate-50 dark:bg-slate-900/60 rounded-xl border border-slate-200 dark:border-slate-800 text-xs">
<span class="text-slate-400 text-[10px] uppercase font-bold tracking-wider block mb-1">Kaart</span>
<p id="assignCardContentPreview" class="text-slate-800 dark:text-slate-200 font-medium line-clamp-2"></p>
</div>
<!-- Collega selecteren -->
<div>
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider mb-1.5">
Toewijzen aan collega *
</label>
<select id="assignColleagueSelect" onchange="onAssignColleagueChange()" 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 cursor-pointer">
<option value="">-- Kies een collega --</option>
<?php foreach ($teamMembers as $tm): ?>
<option value="<?= $tm['id'] ?>" data-has-outlook="<?= $tm['has_outlook'] ? '1' : '0' ?>" data-name="<?= htmlspecialchars($tm['name']) ?>">
<?= htmlspecialchars($tm['name']) ?> <?= $tm['has_outlook'] ? '📅 (Outlook 365)' : '' ?>
</option>
<?php endforeach; ?>
</select>
<p id="assignOutlookNotice" class="hidden mt-1 text-[11px] text-sky-600 dark:text-sky-400 font-medium flex items-center space-x-1">
<i data-lucide="calendar-check" class="w-3 h-3"></i>
<span>Deze collega heeft Microsoft Outlook 365 actief. De afspraak kan direct in Outlook gezet worden!</span>
</p>
</div>
<!-- Slim Zoeken: Knop Eerstvolgend Vrij Blok -->
<div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between gap-3">
<div class="text-xs">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-500"></i>
<span>Automatisch vrij blok vinden</span>
</span>
<span class="text-[11px] text-amber-700 dark:text-amber-400">Zoekt binnen de gezette werktijden en Outlook agenda</span>
</div>
<button type="button" id="btnFindSlot" onclick="findFreeSlotForColleague()" class="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-xs shadow-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3.5 h-3.5"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="foundSlotNotice" class="hidden p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-xs text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 flex-shrink-0"></i>
<span id="foundSlotText"></span>
</div>
<!-- Datum & Tijdblok -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
<input type="date" id="assignPlanDate" value="<?= date('Y-m-d') ?>" 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">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
<input type="time" id="assignStartTime" value="09:00" 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">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Duur</label>
<select id="assignDurationMinutes" 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">
<option value="15">15 minuten</option>
<option value="30" selected>30 minuten</option>
<option value="45">45 minuten</option>
<option value="60">1 uur (60m)</option>
<option value="90">1,5 uur (90m)</option>
<option value="120">2 uur (120m)</option>
</select>
</div>
</div>
<!-- Vinkje Outlook -->
<label class="flex items-center space-x-2 cursor-pointer pt-1">
<input type="checkbox" id="assignSyncOutlook" checked class="rounded text-indigo-600 focus:ring-indigo-500">
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200">
Direct als agenda-afspraak plaatsen in Outlook 365 van deze collega
</span>
</label>
<!-- Buttons -->
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button type="button" onclick="submitAssignAndSchedule()" id="btnSubmitAssign" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
<i data-lucide="check" class="w-4 h-4"></i>
<span>Inplannen & Toewijzen</span>
</button>
<button type="button" onclick="closeAssignScheduleModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren Annuleren
</button> </button>
</div> </div>
@@ -564,6 +823,260 @@ async function deleteCard(cardId) {
document.getElementById(`card-${cardId}`)?.remove(); document.getElementById(`card-${cardId}`)?.remove();
} }
// --- Rechtermuisknop Contextmenu Logica ---
let _activeCtxCard = null;
function openCardContextMenu(e, card) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
_activeCtxCard = card;
const menu = document.getElementById('cardContextMenu');
const titleEl = document.getElementById('ctxMenuCardTitle');
const metaEl = document.getElementById('ctxMenuCardMeta');
const firstLine = card.content.split('\n')[0] || 'Brainstormkaart';
titleEl.innerText = firstLine;
metaEl.innerText = `${card.category} ${card.assignee_name ? '• ' + card.assignee_name : ''}`;
// Positioneer het menu op de muiscursor
const mouseX = e ? e.clientX : window.innerWidth / 2;
const mouseY = e ? e.clientY : window.innerHeight / 2;
menu.classList.remove('hidden');
menu.style.left = `${Math.min(mouseX, window.innerWidth - 270)}px`;
menu.style.top = `${Math.min(mouseY, window.innerHeight - 300)}px`;
if (window.lucide) lucide.createIcons();
}
function closeCardContextMenu() {
const menu = document.getElementById('cardContextMenu');
if (menu) menu.classList.add('hidden');
}
// Contextmenu acties
function triggerCtxEdit() {
const card = _activeCtxCard;
closeCardContextMenu();
if (card) openEditCardModal(card);
}
function triggerCtxAssign() {
const card = _activeCtxCard;
closeCardContextMenu();
if (card) openAssignScheduleModal(card);
}
function triggerCtxQuickSlot() {
const card = _activeCtxCard;
closeCardContextMenu();
if (card) {
openAssignScheduleModal(card);
setTimeout(() => findFreeSlotForColleague(), 100);
}
}
function triggerCtxRoadmap() {
const card = _activeCtxCard;
closeCardContextMenu();
if (card) {
const chk = document.querySelector(`.card-checkbox[value="${card.id}"]`);
if (chk) chk.checked = true;
openConvertModal();
}
}
function triggerCtxBreakdown() {
const card = _activeCtxCard;
closeCardContextMenu();
if (card) openAiBreakdownModal(card.id, card.content);
}
function triggerCtxDelete() {
const card = _activeCtxCard;
closeCardContextMenu();
if (card) deleteCard(card.id);
}
// Sluit contextmenu bij klik erbuiten
document.addEventListener('click', (e) => {
if (!e.target.closest('#cardContextMenu')) {
closeCardContextMenu();
}
});
// --- Kaart Bewerken Modal Logica ---
function openEditCardModal(card) {
document.getElementById('editCardId').value = card.id;
document.getElementById('editCardCategory').value = card.category || 'Ideeën';
document.getElementById('editCardContent').value = card.content || '';
document.getElementById('editCardModal').classList.remove('hidden');
document.getElementById('editCardContent').focus();
}
function closeEditCardModal() {
document.getElementById('editCardModal').classList.add('hidden');
}
async function submitEditCard() {
const cardId = parseInt(document.getElementById('editCardId').value, 10);
const category = document.getElementById('editCardCategory').value;
const content = document.getElementById('editCardContent').value.trim();
if (!cardId || !content) return;
try {
const res = await fetch('/api/brainstorm.php?action=update_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cardId, category, content })
});
const data = await res.json();
if (data.success) {
closeEditCardModal();
location.reload();
} else {
alert(data.error || 'Kon kaart niet bijwerken');
}
} catch(err) {
alert('Netwerkfout bij bijwerken: ' + err.message);
}
}
// --- Toewijzen & Agenda Inplannen Modal Logica ---
function openAssignScheduleModal(card) {
document.getElementById('assignCardId').value = card.id;
document.getElementById('assignCardContentPreview').innerText = card.content;
const sel = document.getElementById('assignColleagueSelect');
if (card.assignee_id) {
sel.value = card.assignee_id;
} else {
sel.value = '';
}
onAssignColleagueChange();
if (card.scheduled_date) {
document.getElementById('assignPlanDate').value = card.scheduled_date;
} else {
document.getElementById('assignPlanDate').value = new Date().toISOString().slice(0, 10);
}
if (card.scheduled_start_time) {
document.getElementById('assignStartTime').value = card.scheduled_start_time.slice(0, 5);
} else {
document.getElementById('assignStartTime').value = '09:00';
}
document.getElementById('foundSlotNotice').classList.add('hidden');
document.getElementById('assignScheduleModal').classList.remove('hidden');
if (window.lucide) lucide.createIcons();
}
function closeAssignScheduleModal() {
document.getElementById('assignScheduleModal').classList.add('hidden');
}
function onAssignColleagueChange() {
const sel = document.getElementById('assignColleagueSelect');
const opt = sel.options[sel.selectedIndex];
const notice = document.getElementById('assignOutlookNotice');
if (opt && opt.getAttribute('data-has-outlook') === '1') {
notice.classList.remove('hidden');
} else {
notice.classList.add('hidden');
}
}
// Zoek eerstvolgend vrij blok van de geselecteerde collega
async function findFreeSlotForColleague() {
const sel = document.getElementById('assignColleagueSelect');
const targetUserId = sel.value ? parseInt(sel.value, 10) : <?= $userId ?>;
const duration = parseInt(document.getElementById('assignDurationMinutes').value, 10) || 30;
const btn = document.getElementById('btnFindSlot');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Zoeken...</span>';
if (window.lucide) lucide.createIcons();
try {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: duration,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('assignPlanDate').value = slot.date;
document.getElementById('assignStartTime').value = slot.start;
const notice = document.getElementById('foundSlotNotice');
const noticeText = document.getElementById('foundSlotText');
noticeText.innerHTML = `Vrij blok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong> (${duration} min)`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden.');
}
} catch(err) {
alert('Netwerkfout bij zoeken van vrij blok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (window.lucide) lucide.createIcons();
}
}
async function submitAssignAndSchedule() {
const cardId = parseInt(document.getElementById('assignCardId').value, 10);
const assigneeId = document.getElementById('assignColleagueSelect').value ? parseInt(document.getElementById('assignColleagueSelect').value, 10) : null;
const planDate = document.getElementById('assignPlanDate').value;
const startTime = document.getElementById('assignStartTime').value;
const durationMinutes = parseInt(document.getElementById('assignDurationMinutes').value, 10) || 30;
const syncOutlook = document.getElementById('assignSyncOutlook').checked;
const btn = document.getElementById('btnSubmitAssign');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Opslaan...</span>';
if (window.lucide) lucide.createIcons();
try {
const res = await fetch('/api/brainstorm.php?action=assign_and_schedule', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
card_id: cardId,
assignee_id: assigneeId,
plan_date: planDate,
start_time: startTime,
duration_minutes: durationMinutes,
sync_outlook: syncOutlook
})
});
const data = await res.json();
if (data.success) {
closeAssignScheduleModal();
location.reload();
} else {
alert(data.error || 'Kon toewijzing niet opslaan');
}
} catch(err) {
alert('Netwerkfout bij opslaan: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (window.lucide) lucide.createIcons();
}
}
// --- Drag & Drop tussen Brainstorm Categorie Lanes --- // --- Drag & Drop tussen Brainstorm Categorie Lanes ---
let draggedCanvasCardId = null; let draggedCanvasCardId = null;
@@ -628,6 +1141,10 @@ async function handleCanvasLaneDrop(e, targetCategory) {
const cardEl = document.getElementById(`card-${cardId}`); const cardEl = document.getElementById(`card-${cardId}`);
if (!cardEl) return; if (!cardEl) return;
// Directe reactivatie van de kaart
cardEl.classList.remove('opacity-40', 'scale-[0.98]', 'pointer-events-none');
document.querySelectorAll('.opacity-40.scale-\\[0\\.98\\]').forEach(el => el.classList.remove('opacity-40', 'scale-[0.98]', 'pointer-events-none'));
const sourceLane = cardEl.closest('.brainstorm-lane'); const sourceLane = cardEl.closest('.brainstorm-lane');
const sourceCategory = sourceLane ? sourceLane.getAttribute('data-category') : null; const sourceCategory = sourceLane ? sourceLane.getAttribute('data-category') : null;
+7
View File
@@ -175,6 +175,13 @@ function ensure_tables_exist(PDO $pdo): void {
// Centrale Systeeminstellingen (AI, Werkdagen, Sessiebeveiliging) // Centrale Systeeminstellingen (AI, Werkdagen, Sessiebeveiliging)
"CREATE TABLE IF NOT EXISTS `system_settings` (`setting_key` VARCHAR(64) PRIMARY KEY, `setting_value` LONGTEXT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS `system_settings` (`setting_key` VARCHAR(64) PRIMARY KEY, `setting_value` LONGTEXT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"ALTER TABLE `system_settings` MODIFY COLUMN `setting_value` LONGTEXT NULL", "ALTER TABLE `system_settings` MODIFY COLUMN `setting_value` LONGTEXT NULL",
// Brainstorm kaarten toewijzen aan collega's en agenda-inplanning
"ALTER TABLE brainstorm_cards ADD COLUMN assignee_id INT NULL AFTER color",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_date DATE NULL AFTER assignee_id",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_start_time VARCHAR(10) NULL AFTER scheduled_date",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
]; ];
foreach ($colUpgrades as $sql) { foreach ($colUpgrades as $sql) {
try { try {
+6
View File
@@ -101,12 +101,18 @@ CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
`content` TEXT NOT NULL, `content` TEXT NOT NULL,
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën', `category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
`color` VARCHAR(20) NOT NULL DEFAULT 'amber', `color` VARCHAR(20) NOT NULL DEFAULT 'amber',
`assignee_id` INT NULL,
`scheduled_date` DATE NULL,
`scheduled_start_time` VARCHAR(10) NULL,
`scheduled_end_time` VARCHAR(10) NULL,
`scheduled_task_id` INT NULL,
`votes` INT NOT NULL DEFAULT 0, `votes` INT NOT NULL DEFAULT 0,
`author_name` VARCHAR(120) NULL, `author_name` VARCHAR(120) NULL,
`converted_to_roadmap_item_id` INT NULL, `converted_to_roadmap_item_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`session_id`), INDEX (`session_id`),
INDEX (`assignee_id`),
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+539 -2
View File
@@ -38,6 +38,21 @@ try {
} }
$quarters = ['Q1', 'Q2', 'Q3', 'Q4']; $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
$currentUserId = (int)($currentUser['id'] ?? 0);
// Haal alle actieve teamleden op voor toewijzing & agendablokkades
$teamMembersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
$teamMembers = $teamMembersStmt->fetchAll();
$outlookUsers = [];
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
foreach ($intStmt->fetchAll() as $row) {
$outlookUsers[(int)$row['user_id']] = true;
}
foreach ($teamMembers as &$tm) {
$tm['has_outlook'] = !empty($outlookUsers[(int)$tm['id']]);
}
unset($tm);
?> ?>
<div class="space-y-6 max-w-7xl mx-auto"> <div class="space-y-6 max-w-7xl mx-auto">
@@ -121,8 +136,17 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
draggable="true" draggable="true"
ondragstart="handleRoadmapItemDragStart(event, <?= $item['id'] ?>)" ondragstart="handleRoadmapItemDragStart(event, <?= $item['id'] ?>)"
ondragend="handleRoadmapItemDragEnd(event)" ondragend="handleRoadmapItemDragEnd(event)"
oncontextmenu="openRoadmapContextMenu(event, <?= $item['id'] ?>); return false;"
data-title="<?= htmlspecialchars($item['title'], ENT_QUOTES) ?>"
data-description="<?= htmlspecialchars($item['description'] ?? '', ENT_QUOTES) ?>"
data-quarter="<?= htmlspecialchars($item['quarter']) ?>"
data-type="<?= htmlspecialchars($item['type']) ?>"
data-priority="<?= htmlspecialchars($item['priority']) ?>"
data-status="<?= htmlspecialchars($item['status']) ?>"
data-due-date="<?= htmlspecialchars($item['due_date'] ?? '') ?>"
data-jira-key="<?= htmlspecialchars($item['jira_issue_key'] ?? '') ?>"
class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?> cursor-grab active:cursor-grabbing transition-all duration-150" class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?> cursor-grab active:cursor-grabbing transition-all duration-150"
title="Sleep naar een ander kwartaal (Q1, Q2, Q3, Q4) bovenaan" title="Sleep naar een ander kwartaal (Q1, Q2, Q3, Q4) of klik met rechtermuisknop"
> >
<!-- Top header van item --> <!-- Top header van item -->
@@ -293,10 +317,241 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer"> <button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren Annuleren
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal: Roadmap Item Bewerken -->
<div id="editRoadmapModal" onclick="if(event.target === this) closeEditRoadmapModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Roadmap Item Bewerken</span>
</h3>
<button onclick="closeEditRoadmapModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<input type="hidden" id="editRoadmapId">
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="editRoadmapTitle" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving</label>
<textarea id="editRoadmapDescription" rows="3" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
<select id="editRoadmapQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3">Q3</option>
<option value="Q4">Q4</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select id="editRoadmapType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="FEATURE">Feature</option>
<option value="EPIC">Epic</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
<select id="editRoadmapPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="CRITICAL">Kritiek</option>
<option value="HIGH">Hoog</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Status</label>
<select id="editRoadmapStatus" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="PLANNED">Gepland</option>
<option value="IN_PROGRESS">In Uitvoering</option>
<option value="REVIEW">In Review</option>
<option value="COMPLETED">Voltooid</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key</label>
<input type="text" id="editRoadmapJiraKey" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="editRoadmapDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button id="btnSaveEditRoadmap" onclick="submitEditRoadmapItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Opslaan
</button>
<button onclick="closeEditRoadmapModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Roadmap Item Toewijzen & Inplannen in Vrij Blok -->
<div id="roadmapScheduleModal" onclick="if(event.target === this) closeRoadmapScheduleModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Inplannen in Agenda van Collega</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Plant dit item direct in een vrij tijdblok van de collega</p>
</div>
</div>
<button onclick="closeRoadmapScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<input type="hidden" id="roadmapScheduleItemId">
<div class="space-y-4">
<!-- Item titel preview -->
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
<span class="text-[10px] text-slate-400 font-bold uppercase tracking-wider block mb-0.5">Roadmap Item</span>
<span id="roadmapScheduleTitlePreview" class="font-bold text-slate-800 dark:text-slate-200"></span>
</div>
<!-- Collega Selector -->
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Toewijzen aan collega</label>
<select id="roadmapAssigneeSelect" onchange="onRoadmapAssigneeChange()" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<?php foreach ($teamMembers as $tm): ?>
<option value="<?= $tm['id'] ?>" data-has-outlook="<?= $tm['has_outlook'] ? '1' : '0' ?>" <?= $tm['id'] === $currentUserId ? 'selected' : '' ?>>
<?= htmlspecialchars($tm['name']) ?> (<?= $tm['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>) <?= $tm['has_outlook'] ? '— 📅 Outlook Actief' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Outlook melding -->
<div id="roadmapAssignOutlookNotice" class="p-2 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center space-x-2 text-[11px] text-sky-700 dark:text-sky-300">
<i data-lucide="calendar-check" class="w-4 h-4 text-sky-500 flex-shrink-0"></i>
<span>Deze collega heeft Microsoft Outlook gekoppeld. Er wordt gekeken naar echte vrije blokken.</span>
</div>
<!-- Vrij Blok Zoeker -->
<div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-500"></i>
<span>Automatisch vrij blok vinden</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden (09:00 - 17:00)</span>
</div>
<button type="button" id="btnRoadmapFindSlot" onclick="findFreeSlotForRoadmapAssignee()" class="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-xs shadow-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3.5 h-3.5"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="roadmapFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-xs text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 flex-shrink-0"></i>
<span id="roadmapFoundSlotText"></span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
<input type="date" id="roadmapPlanDate" value="<?= date('Y-m-d') ?>" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
<input type="time" id="roadmapStartTime" value="10:00" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Duur</label>
<select id="roadmapDurationMinutes" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
<option value="30">30 minuten</option>
<option value="45">45 minuten</option>
<option value="60" selected>1 uur</option>
<option value="90">1.5 uur</option>
<option value="120">2 uur</option>
</select>
</div>
<div class="flex items-center pt-5">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="roadmapSyncOutlook" checked class="rounded text-indigo-600 focus:ring-indigo-500">
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200">Sync met Outlook 365</span>
</label>
</div>
</div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button id="btnSubmitRoadmapSchedule" onclick="submitRoadmapSchedule()" class="flex-1 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4"></i>
<span>Inplannen in Agenda</span>
</button>
<button onclick="closeRoadmapScheduleModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Rechtermuisknop Contextmenu voor Roadmap Items -->
<div id="roadmapContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
<button onclick="triggerRoadmapCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Item bewerken</span>
</button>
<button onclick="triggerRoadmapCtxSchedule()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="calendar-plus" class="w-4 h-4 text-amber-500"></i>
<span>👤 Toewijzen & vrij blok inplannen</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<div class="px-3.5 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Verplaats naar Kwartaal</div>
<div class="grid grid-cols-4 gap-1 px-3 py-1">
<button onclick="triggerRoadmapCtxMoveQuarter('Q1')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q1</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q2')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q2</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q3')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q3</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q4')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q4</button>
</div>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerRoadmapCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Item verwijderen</span>
</button>
</div>
<script> <script>
async function updateRoadmapStatus(id, status) { async function updateRoadmapStatus(id, status) {
await fetch('/api/roadmap.php?action=update_item', { await fetch('/api/roadmap.php?action=update_item', {
@@ -405,6 +660,14 @@ async function handleRoadmapQuarterDrop(e, targetQuarter) {
const itemId = draggedRoadmapItemId || parseInt(e.dataTransfer.getData('text/plain'), 10); const itemId = draggedRoadmapItemId || parseInt(e.dataTransfer.getData('text/plain'), 10);
draggedRoadmapItemId = null; draggedRoadmapItemId = null;
if (itemId) {
const el = document.getElementById(`roadmap-item-${itemId}`);
if (el) el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
}
document.querySelectorAll('[id^="roadmap-item-"].opacity-40').forEach(el => {
el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
});
if (!itemId || !targetQuarter) return; if (!itemId || !targetQuarter) return;
try { try {
@@ -425,10 +688,284 @@ async function handleRoadmapQuarterDrop(e, targetQuarter) {
} }
} }
// --- Rechtermuisknop Contextmenu & Acties voor Roadmap Items ---
let activeRoadmapCtxItemId = null;
function openRoadmapContextMenu(e, itemId) {
e.preventDefault();
e.stopPropagation();
activeRoadmapCtxItemId = itemId;
const menu = document.getElementById('roadmapContextMenu');
if (!menu) return;
menu.classList.remove('hidden');
const x = Math.min(e.clientX, window.innerWidth - 240);
const y = Math.min(e.clientY, window.innerHeight - 240);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeRoadmapContextMenu() {
const menu = document.getElementById('roadmapContextMenu');
if (menu) menu.classList.add('hidden');
activeRoadmapCtxItemId = null;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#roadmapContextMenu')) {
closeRoadmapContextMenu();
}
});
function triggerRoadmapCtxEdit() {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
const el = document.getElementById(`roadmap-item-${itemId}`);
if (!el) return;
document.getElementById('editRoadmapId').value = itemId;
document.getElementById('editRoadmapTitle').value = el.getAttribute('data-title') || '';
document.getElementById('editRoadmapDescription').value = el.getAttribute('data-description') || '';
document.getElementById('editRoadmapQuarter').value = el.getAttribute('data-quarter') || 'Q3';
document.getElementById('editRoadmapType').value = el.getAttribute('data-type') || 'FEATURE';
document.getElementById('editRoadmapPriority').value = el.getAttribute('data-priority') || 'HIGH';
document.getElementById('editRoadmapStatus').value = el.getAttribute('data-status') || 'PLANNED';
document.getElementById('editRoadmapDueDate').value = el.getAttribute('data-due-date') || '';
document.getElementById('editRoadmapJiraKey').value = el.getAttribute('data-jira-key') || '';
const modal = document.getElementById('editRoadmapModal');
modal.classList.remove('hidden');
if (typeof lucide !== 'undefined') lucide.createIcons();
document.getElementById('editRoadmapTitle')?.focus();
}
function closeEditRoadmapModal() {
document.getElementById('editRoadmapModal')?.classList.add('hidden');
}
async function submitEditRoadmapItem() {
const id = parseInt(document.getElementById('editRoadmapId').value, 10);
const title = document.getElementById('editRoadmapTitle').value.trim();
const description = document.getElementById('editRoadmapDescription').value.trim();
const quarter = document.getElementById('editRoadmapQuarter').value;
const type = document.getElementById('editRoadmapType').value;
const priority = document.getElementById('editRoadmapPriority').value;
const status = document.getElementById('editRoadmapStatus').value;
const due_date = document.getElementById('editRoadmapDueDate').value;
const jira_key = document.getElementById('editRoadmapJiraKey').value.trim();
if (!title) {
alert('Titel is verplicht');
return;
}
const btn = document.getElementById('btnSaveEditRoadmap');
btn.disabled = true;
btn.innerText = 'Opslaan...';
try {
const res = await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
id,
title,
description,
quarter,
type,
priority,
status,
due_date,
jira_key
})
});
const data = await res.json();
if (data.success) {
closeEditRoadmapModal();
location.reload();
} else {
alert(data.error || 'Kon item niet opslaan');
}
} catch(e) {
alert('Netwerkfout bij opslaan');
} finally {
btn.disabled = false;
btn.innerText = 'Opslaan';
}
}
function triggerRoadmapCtxSchedule() {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
const el = document.getElementById(`roadmap-item-${itemId}`);
if (!el) return;
document.getElementById('roadmapScheduleItemId').value = itemId;
document.getElementById('roadmapScheduleTitlePreview').innerText = el.getAttribute('data-title') || 'Roadmap item';
document.getElementById('roadmapFoundSlotNotice').classList.add('hidden');
onRoadmapAssigneeChange();
const modal = document.getElementById('roadmapScheduleModal');
modal.classList.remove('hidden');
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
findFreeSlotForRoadmapAssignee();
}, 150);
}
function closeRoadmapScheduleModal() {
document.getElementById('roadmapScheduleModal')?.classList.add('hidden');
}
function onRoadmapAssigneeChange() {
const sel = document.getElementById('roadmapAssigneeSelect');
const opt = sel.options[sel.selectedIndex];
const notice = document.getElementById('roadmapAssignOutlookNotice');
if (opt && opt.getAttribute('data-has-outlook') === '1') {
notice.classList.remove('hidden');
} else {
notice.classList.add('hidden');
}
}
async function findFreeSlotForRoadmapAssignee() {
const sel = document.getElementById('roadmapAssigneeSelect');
const targetUserId = sel.value ? parseInt(sel.value, 10) : <?= $currentUserId ?>;
const duration = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
const btn = document.getElementById('btnRoadmapFindSlot');
const origHtml = btn.innerHTML;
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 {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: duration,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('roadmapPlanDate').value = slot.date;
document.getElementById('roadmapStartTime').value = slot.start;
const notice = document.getElementById('roadmapFoundSlotNotice');
const noticeText = document.getElementById('roadmapFoundSlotText');
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden.');
}
} catch(err) {
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
async function submitRoadmapSchedule() {
const itemId = parseInt(document.getElementById('roadmapScheduleItemId').value, 10);
const assigneeId = document.getElementById('roadmapAssigneeSelect').value ? parseInt(document.getElementById('roadmapAssigneeSelect').value, 10) : null;
const planDate = document.getElementById('roadmapPlanDate').value;
const startTime = document.getElementById('roadmapStartTime').value;
const durationMinutes = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
const syncOutlook = document.getElementById('roadmapSyncOutlook').checked;
const el = document.getElementById(`roadmap-item-${itemId}`);
const title = el ? el.getAttribute('data-title') : 'Roadmap Item';
const btn = document.getElementById('btnSubmitRoadmapSchedule');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Inplannen...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
// Maak agenda taak aan via api/planner.php
const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
date: planDate,
title: title,
start_time: startTime,
duration: durationMinutes,
type: 'PROJECT',
priority: 'MEDIUM',
sync_to_microsoft: syncOutlook,
target_user_id: assigneeId
})
});
const data = await res.json();
if (data.success) {
closeRoadmapScheduleModal();
let msg = `Roadmap item is succesvol ingepland op ${planDate} van ${startTime} tot ${data.task?.end_time || ''}!`;
if (data.outlook_message) {
msg += `\n${data.outlook_message}`;
}
alert(msg);
} else {
alert(data.error || 'Kon afspraak niet inplannen');
}
} catch(err) {
alert('Netwerkfout bij inplannen: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
async function triggerRoadmapCtxMoveQuarter(targetQuarter) {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
try {
const res = await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
});
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert(data.error || 'Kon item niet verplaatsen');
}
} catch (err) {
alert('Netwerkfout bij verplaatsen item');
}
}
function triggerRoadmapCtxDelete() {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
deleteRoadmapItem(itemId);
}
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
const modal = document.getElementById('newItemModal'); const modal = document.getElementById('newItemModal');
if (modal) modal.classList.add('hidden'); if (modal && !modal.classList.contains('hidden')) modal.classList.add('hidden');
closeEditRoadmapModal();
closeRoadmapScheduleModal();
closeRoadmapContextMenu();
} }
}); });
</script> </script>
+177
View File
@@ -353,6 +353,25 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
</div> </div>
</div> </div>
<!-- Knop Vrij Blok Zoeken voor Assignee -->
<div class="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between gap-2">
<div class="text-[11px]">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3 h-3 text-amber-500"></i>
<span>Vrij agendablok zoeken</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden van de toegewezen collega</span>
</div>
<button type="button" id="btnTimelineFindSlot" onclick="findFreeSlotForTimelineAssignee()" class="px-2.5 py-1 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-[10px] shadow-xs transition flex items-center space-x-1 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3 h-3"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="timelineFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-[11px] text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-emerald-500 flex-shrink-0"></i>
<span id="timelineFoundSlotText"></span>
</div>
<!-- Outlook Agenda Synchronisatie --> <!-- 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 justify-between pt-2 border-t border-slate-200/50 dark:border-slate-800/50">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -393,6 +412,26 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
</div> </div>
</div> </div>
<!-- Rechtermuisknop Contextmenu voor Tijdlijn Items -->
<div id="timelineContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
<button onclick="triggerTimelineCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Taak bewerken</span>
</button>
<button onclick="triggerTimelineCtxQuickSlot()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="zap" class="w-4 h-4 text-amber-500"></i>
<span>⚡ Zoek direct vrij blok</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerTimelineCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Taak verwijderen</span>
</button>
</div>
<script> <script>
// --- Globale Status voor Tijdlijn --- // --- Globale Status voor Tijdlijn ---
let currentBoardId = null; let currentBoardId = null;
@@ -573,6 +612,13 @@ function renderTimelineLaneDropBar() {
dropBadge.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60'); dropBadge.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
const cardIdStr = e.dataTransfer.getData('text/plain'); const cardIdStr = e.dataTransfer.getData('text/plain');
const cardId = parseInt(cardIdStr, 10); 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) { if (cardId && lane.id) {
await moveTimelineCardToLane(cardId, lane.id); await moveTimelineCardToLane(cardId, lane.id);
} }
@@ -726,6 +772,11 @@ function renderTimeline() {
leftItem.setAttribute('draggable', 'true'); leftItem.setAttribute('draggable', 'true');
leftItem.setAttribute('data-card-id', card.id); leftItem.setAttribute('data-card-id', card.id);
leftItem.oncontextmenu = (e) => {
openTimelineContextMenu(e, card.id);
return false;
};
leftItem.ondragstart = (e) => { leftItem.ondragstart = (e) => {
e.dataTransfer.setData('text/plain', String(card.id)); e.dataTransfer.setData('text/plain', String(card.id));
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -772,6 +823,11 @@ function renderTimeline() {
rightRow.className = 'timeline-task-row timeline-row h-12 relative flex items-center'; rightRow.className = 'timeline-task-row timeline-row h-12 relative flex items-center';
rightRow.setAttribute('data-card-id', card.id); rightRow.setAttribute('data-card-id', card.id);
rightRow.oncontextmenu = (e) => {
openTimelineContextMenu(e, card.id);
return false;
};
// Klikken op een lege plek in de rij om direct in te plannen // Klikken op een lege plek in de rij om direct in te plannen
rightRow.onclick = (e) => { rightRow.onclick = (e) => {
if (e.target.closest('.timeline-bar')) return; if (e.target.closest('.timeline-bar')) return;
@@ -796,6 +852,10 @@ function renderTimeline() {
barEl.style.left = `${leftPx}px`; barEl.style.left = `${leftPx}px`;
barEl.style.width = `${widthPx}px`; barEl.style.width = `${widthPx}px`;
barEl.title = `${escapeHtml(card.title)} (${card.start_date} t/m ${card.end_date})`; barEl.title = `${escapeHtml(card.title)} (${card.start_date} t/m ${card.end_date})`;
barEl.oncontextmenu = (ev) => {
openTimelineContextMenu(ev, card.id);
return false;
};
barEl.onclick = (ev) => { barEl.onclick = (ev) => {
ev.stopPropagation(); ev.stopPropagation();
openCardModal(card.id); openCardModal(card.id);
@@ -1165,9 +1225,126 @@ function escapeHtml(str) {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
// --- Rechtermuisknop Context Menu voor Tijdlijn ---
let activeTimelineCtxCardId = null;
function openTimelineContextMenu(e, cardId) {
e.preventDefault();
e.stopPropagation();
activeTimelineCtxCardId = cardId;
const menu = document.getElementById('timelineContextMenu');
if (!menu) return;
menu.classList.remove('hidden');
const x = Math.min(e.clientX, window.innerWidth - 240);
const y = Math.min(e.clientY, window.innerHeight - 240);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeTimelineContextMenu() {
const menu = document.getElementById('timelineContextMenu');
if (menu) menu.classList.add('hidden');
activeTimelineCtxCardId = null;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#timelineContextMenu')) {
closeTimelineContextMenu();
}
});
function triggerTimelineCtxEdit() {
if (!activeTimelineCtxCardId) return;
const cid = activeTimelineCtxCardId;
closeTimelineContextMenu();
openCardModal(cid);
}
function triggerTimelineCtxQuickSlot() {
if (!activeTimelineCtxCardId) return;
const cid = activeTimelineCtxCardId;
closeTimelineContextMenu();
openCardModal(cid);
setTimeout(() => {
findFreeSlotForTimelineAssignee();
}, 150);
}
async function triggerTimelineCtxDelete() {
if (!activeTimelineCtxCardId) return;
const cid = activeTimelineCtxCardId;
closeTimelineContextMenu();
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: cid })
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verwijderen');
}
} catch(e) {
alert('Netwerkfout bij verwijderen');
}
}
// Zoek vrij agendablok voor de in de tijdlijn-modal geselecteerde assignee
async function findFreeSlotForTimelineAssignee() {
const sel = document.getElementById('cardAssigneeSelect');
const targetUserId = sel.value ? parseInt(sel.value, 10) : currentUserId;
const schedToggle = document.getElementById('cardScheduleToggle');
if (!schedToggle.checked) {
schedToggle.checked = true;
toggleScheduleFields(true);
}
const btn = document.getElementById('btnTimelineFindSlot');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i><span>Zoeken...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: 60,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('cardStartDate').value = slot.date;
document.getElementById('cardEndDate').value = slot.date;
const notice = document.getElementById('timelineFoundSlotNotice');
const noticeText = document.getElementById('timelineFoundSlotText');
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden voor deze collega.');
}
} catch (err) {
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCardModal(); closeCardModal();
closeTimelineContextMenu();
} }
}); });
</script> </script>