From 5ce08443cc7f18d280f416998b2472d179ec507d Mon Sep 17 00:00:00 2001 From: Niek Rabelink Date: Thu, 10 Sep 2026 22:38:06 +0200 Subject: [PATCH] feat: Add story points support and AI daily briefing feature - Enhanced JIRA integration to fetch story points from multiple custom fields. - Updated task display to show story points and their estimated duration. - Introduced AI daily briefing banner with dynamic content loading. - Implemented subtasks functionality with add, toggle, and delete capabilities. - Improved task management UI with better accessibility and interaction. --- api/ai.php | 93 ++++++ api/planner.php | 114 ++++++++ config/database.php | 2 + config/schema.sql | 15 + includes/ai.php | 83 ++++++ includes/integrations/jira.php | 14 + jira.php | 31 ++ planner.php | 497 ++++++++++++++++++++++++++++----- 8 files changed, 780 insertions(+), 69 deletions(-) diff --git a/api/ai.php b/api/ai.php index 3bd5237..3cafde4 100644 --- a/api/ai.php +++ b/api/ai.php @@ -205,6 +205,99 @@ try { exit; } + // 5. Persoonlijke AI Dagbriefing genereren + if ($action === 'daily_briefing') { + $data = json_decode(file_get_contents('php://input'), true); + $date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d'); + $today = date('Y-m-d'); + + // Haal openstaande taken van vandaag op + $stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?"); + $stmtPlan->execute([$user['id'], $date]); + $planId = $stmtPlan->fetchColumn(); + + $isToday = ($date === $today); + $taskFilter = $planId + ? "t.daily_plan_id = " . (int)$planId . ($isToday ? " OR t.daily_plan_id IS NULL" : "") + : ($isToday ? "t.daily_plan_id IS NULL" : "1=0"); + + $tasks = $db->query(" + SELECT id, title, priority, time_block_start, duration_minutes + FROM tasks + WHERE user_id = {$user['id']} AND is_completed = 0 AND ({$taskFilter}) + ORDER BY priority ASC, time_block_start ASC + LIMIT 15 + ")->fetchAll(); + + // Haal gisteren niet-voltooide taken op + $yesterday = date('Y-m-d', strtotime('-1 day', strtotime($date))); + $stmtYest = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?"); + $stmtYest->execute([$user['id'], $yesterday]); + $yesterdayPlanId = $stmtYest->fetchColumn(); + $unfinishedYest = []; + if ($yesterdayPlanId) { + $stmtUnf = $db->prepare(" + SELECT title FROM tasks + WHERE user_id = ? AND daily_plan_id = ? AND is_completed = 0 + LIMIT 5 + "); + $stmtUnf->execute([$user['id'], $yesterdayPlanId]); + $unfinishedYest = $stmtUnf->fetchAll(); + } + + // Haal Outlook meetings op als beschikbaar + $meetings = []; + try { + require_once __DIR__ . '/../includes/integrations/microsoft.php'; + $meetings = microsoft_fetch_today_events($user['id'], $date); + } catch (Throwable $e) { + // meetings niet beschikbaar, geen probleem + } + + // Haal urgente Jira tickets op als beschikbaar + $jiraIssues = []; + try { + require_once __DIR__ . '/../includes/integrations/jira.php'; + $jiraData = jira_get_dashboard_data($user['id'], false); + $jiraIssues = array_merge( + $jiraData['assigned_issues'] ?? [], + $jiraData['team_issues'] ?? [] + ); + } catch (Throwable $e) { + // Jira niet beschikbaar, geen probleem + } + + $aiSettings = get_ai_settings(); + if (empty($aiSettings['ai_enabled']) || empty($aiSettings['has_key'])) { + echo json_encode(['error' => 'AI Co-Pilot is niet geconfigureerd']); + exit; + } + + $aiRes = fociz_ai_daily_briefing( + $user['name'] ?? 'Jij', + $date, + $tasks, + $meetings, + $jiraIssues, + $unfinishedYest + ); + + if (!$aiRes['success']) { + echo json_encode(['error' => $aiRes['error']]); + exit; + } + + echo json_encode([ + 'success' => true, + 'greeting' => $aiRes['greeting'], + 'focus_task' => $aiRes['focus_task'], + 'tip' => $aiRes['tip'], + 'warnings' => $aiRes['warnings'], + 'date' => $date, + ]); + exit; + } + echo json_encode(['error' => 'Onbekende actie']); } catch (Exception $e) { http_response_code(500); diff --git a/api/planner.php b/api/planner.php index 61f5bc1..30accc8 100644 --- a/api/planner.php +++ b/api/planner.php @@ -486,6 +486,120 @@ try { exit; } + // --- V2.0: Subtaken & Checklists --- + + // 7. Subtaak toevoegen aan een taak + if ($action === 'add_subtask') { + $data = json_decode(file_get_contents('php://input'), true); + $taskId = (int)($data['task_id'] ?? 0); + $title = trim($data['title'] ?? ''); + + if (!$taskId || empty($title)) { + echo json_encode(['error' => 'Taak-ID en titel zijn verplicht']); + exit; + } + + // Controleer of de taak van deze gebruiker is + $stmtCheck = $db->prepare("SELECT id FROM tasks WHERE id = ? AND user_id = ?"); + $stmtCheck->execute([$taskId, $user['id']]); + if (!$stmtCheck->fetchColumn()) { + echo json_encode(['error' => 'Taak niet gevonden']); + exit; + } + + // Bereken positie (achteraan) + $stmtPos = $db->prepare("SELECT COALESCE(MAX(position), -1) + 1 FROM task_subtasks WHERE task_id = ?"); + $stmtPos->execute([$taskId]); + $position = (int)$stmtPos->fetchColumn(); + + $stmt = $db->prepare(" + INSERT INTO task_subtasks (task_id, user_id, title, is_completed, position) + VALUES (?, ?, ?, 0, ?) + "); + $stmt->execute([$taskId, $user['id'], substr($title, 0, 255), $position]); + $subtaskId = (int)$db->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'subtask' => [ + 'id' => $subtaskId, + 'task_id' => $taskId, + 'title' => $title, + 'is_completed' => 0, + 'position' => $position, + ] + ]); + exit; + } + + // 8. Subtaak afvinken / openzetten + if ($action === 'toggle_subtask') { + $data = json_decode(file_get_contents('php://input'), true); + $subtaskId = (int)($data['subtask_id'] ?? 0); + $isCompleted = !empty($data['is_completed']) ? 1 : 0; + + if (!$subtaskId) { + echo json_encode(['error' => 'Subtaak-ID ontbreekt']); + exit; + } + + $stmt = $db->prepare(" + UPDATE task_subtasks SET is_completed = ? + WHERE id = ? AND user_id = ? + "); + $stmt->execute([$isCompleted, $subtaskId, $user['id']]); + + echo json_encode(['success' => true, 'is_completed' => $isCompleted]); + exit; + } + + // 9. Subtaak verwijderen + if ($action === 'delete_subtask') { + $data = json_decode(file_get_contents('php://input'), true); + $subtaskId = (int)($data['subtask_id'] ?? 0); + + if (!$subtaskId) { + echo json_encode(['error' => 'Subtaak-ID ontbreekt']); + exit; + } + + $stmt = $db->prepare("DELETE FROM task_subtasks WHERE id = ? AND user_id = ?"); + $stmt->execute([$subtaskId, $user['id']]); + + echo json_encode(['success' => true]); + exit; + } + + // 10. Subtaken ophalen voor een taak + if ($action === 'get_subtasks') { + $taskId = (int)($_GET['task_id'] ?? 0); + + if (!$taskId) { + echo json_encode(['error' => 'Taak-ID ontbreekt']); + exit; + } + + // Controleer eigenaarschap + $stmtCheck = $db->prepare("SELECT id FROM tasks WHERE id = ? AND user_id = ?"); + $stmtCheck->execute([$taskId, $user['id']]); + if (!$stmtCheck->fetchColumn()) { + echo json_encode(['error' => 'Taak niet gevonden']); + exit; + } + + $stmt = $db->prepare(" + SELECT id, task_id, title, is_completed, position + FROM task_subtasks + WHERE task_id = ? AND user_id = ? + ORDER BY position ASC, created_at ASC + "); + $stmt->execute([$taskId, $user['id']]); + $subtasks = $stmt->fetchAll(); + + echo json_encode(['success' => true, 'subtasks' => $subtasks]); + exit; + } + echo json_encode(['error' => 'Onbekende actie']); } catch (Exception $e) { http_response_code(500); diff --git a/config/database.php b/config/database.php index 3c72702..7b62a46 100644 --- a/config/database.php +++ b/config/database.php @@ -154,6 +154,8 @@ function ensure_tables_exist(PDO $pdo): void { "ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)", "CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + // V2.0: Subtaken & Checklists + "CREATE TABLE IF NOT EXISTS `task_subtasks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `task_id` INT NOT NULL, `user_id` INT NOT NULL, `title` VARCHAR(255) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`task_id`, `position`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; foreach ($colUpgrades as $sql) { try { diff --git a/config/schema.sql b/config/schema.sql index dd49ea9..d16c1e3 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -242,3 +242,18 @@ CREATE TABLE IF NOT EXISTS `app_cache` ( `expires_at` INT UNSIGNED NOT NULL, INDEX `idx_app_cache_expires` (`expires_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Fociz V2.0: Subtaken & Checklists per taak +CREATE TABLE IF NOT EXISTS `task_subtasks` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `task_id` INT NOT NULL, + `user_id` INT NOT NULL, + `title` VARCHAR(255) NOT NULL, + `is_completed` TINYINT(1) NOT NULL DEFAULT 0, + `position` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (`task_id`, `position`), + CONSTRAINT `fk_subtasks_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/includes/ai.php b/includes/ai.php index 46c0cb1..b26d148 100644 --- a/includes/ai.php +++ b/includes/ai.php @@ -195,3 +195,86 @@ function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySett return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']]; } +/** + * Genereert een persoonlijke AI dagbriefing op basis van taken, afspraken en Jira tickets + * + * @param string $userName Naam van de gebruiker + * @param string $date Datum (Y-m-d) + * @param array $tasks Openstaande taken van vandaag + * @param array $meetings Outlook meetings van vandaag + * @param array $jiraIssues Jira tickets (P1/blokkades) + * @param array $unfinishedYest Gisteren niet voltooide taken + * @return array ['success' => bool, 'briefing' => string, 'focus_tip' => string, 'warnings' => array] + */ +function fociz_ai_daily_briefing( + string $userName, + string $date, + array $tasks, + array $meetings, + array $jiraIssues = [], + array $unfinishedYest = [] +): array { + $dutchDays = ['', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag']; + $dayName = $dutchDays[(int)date('N', strtotime($date))] ?? 'vandaag'; + $dateLabel = $dayName . ' ' . date('j', strtotime($date)) . ' ' . date('F', strtotime($date)); + + $taskSummary = array_map(fn($t) => [ + 'title' => $t['title'], + 'priority' => $t['priority'] ?? 'P2', + 'time' => $t['time_block_start'] ?? null, + 'duration' => (int)($t['duration_minutes'] ?? 30), + ], array_slice($tasks, 0, 10)); + + $meetingSummary = array_map(fn($e) => [ + 'subject' => $e['subject'] ?? 'Afspraak', + 'start' => $e['start'] ?? null, + 'end' => $e['end'] ?? null, + ], array_slice($meetings, 0, 8)); + + $jiraSummary = array_map(fn($j) => [ + 'key' => $j['key'] ?? '', + 'title' => $j['summary'] ?? '', + 'priority' => $j['priority'] ?? 'Medium', + 'status' => $j['status'] ?? 'Open', + ], array_slice(array_filter($jiraIssues, fn($j) => in_array($j['priority'] ?? '', ['Highest', 'High'])), 0, 5)); + + $yesterdaySummary = array_map(fn($t) => $t['title'], array_slice($unfinishedYest, 0, 5)); + + $systemPrompt = "Je bent de persoonlijke AI Co-Pilot van {$userName} in Fociz. " + . "Schrijf een korte, energieke en persoonlijke dagbriefing in het Nederlands voor {$dateLabel}. " + . "Wees direct, concreet en vriendelijk. Gebruik geen markdown headers, geen bullet symbolen zoals *, -, •. " + . "Geef 1-2 zinnen dagopening + 1 zin over de grootste prioriteit + 1 praktisch focustip. " + . "Maximaal 60 woorden totaal. " + . "Retourneer een JSON-object met:\n" + . "- 'greeting': openingszin (max 20 woorden)\n" + . "- 'focus_task': de meest urgente taak of meeting vandaag (max 15 woorden)\n" + . "- 'tip': één concrete focustip voor vandaag (max 20 woorden)\n" + . "- 'warnings': array van strings, max 2 items — alleen als er iets opvalt (gisteren niet afgerond, urgent Jira, overvolle dag)"; + + $userPrompt = json_encode([ + 'date' => $dateLabel, + 'tasks_today' => $taskSummary, + 'meetings_today' => $meetingSummary, + 'urgent_jira' => $jiraSummary, + 'unfinished_yesterday' => $yesterdaySummary, + 'total_tasks' => count($tasks), + 'total_meetings' => count($meetings), + ], JSON_UNESCAPED_UNICODE); + + $aiRes = fociz_ai_chat([ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300]); + + if (!$aiRes['success'] || empty($aiRes['json'])) { + return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen dagbriefing genereren']; + } + + return [ + 'success' => true, + 'greeting' => $aiRes['json']['greeting'] ?? "Goedemorgen {$userName}, een nieuwe dag!", + 'focus_task'=> $aiRes['json']['focus_task'] ?? '', + 'tip' => $aiRes['json']['tip'] ?? '', + 'warnings' => $aiRes['json']['warnings'] ?? [], + ]; +} diff --git a/includes/integrations/jira.php b/includes/integrations/jira.php index 4b93189..1739bce 100644 --- a/includes/integrations/jira.php +++ b/includes/integrations/jira.php @@ -236,6 +236,9 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri 'description', 'labels', 'components', + 'story_points', + 'customfield_10016', // Story Points (Jira Cloud standaard) + 'customfield_10028', // Story Points (alternatief veld sommige instanties) ]; // 1. Moderne Atlassian Jira Cloud endpoint (/rest/api/3/search/jql) conform CHANGE-2046 via POST @@ -389,6 +392,16 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri elseif (str_contains($sumLower, 'koppeling')) $onderwerpLogging = 'Koppelingen'; } + // Detectie van story points (customfield_10016 is Jira Cloud standaard, met fallback naar alternatieven) + $storyPoints = null; + if (isset($fields['customfield_10016']) && is_numeric($fields['customfield_10016'])) { + $storyPoints = (float)$fields['customfield_10016']; + } elseif (isset($fields['customfield_10028']) && is_numeric($fields['customfield_10028'])) { + $storyPoints = (float)$fields['customfield_10028']; + } elseif (isset($fields['story_points']) && is_numeric($fields['story_points'])) { + $storyPoints = (float)$fields['story_points']; + } + $issues[] = [ 'key' => $key, 'summary' => $summary, @@ -409,6 +422,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri 'created' => $created, 'updated' => $updated, 'url' => "{$cleanDomain}/browse/{$key}", + 'story_points' => $storyPoints, 'source' => 'JIRA_LIVE', ]; } diff --git a/jira.php b/jira.php index 9eb8bc0..6d735ff 100644 --- a/jira.php +++ b/jira.php @@ -494,10 +494,15 @@ $boardsJson = json_encode($boards, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT
FOC-101 • Fociz Platform +
Tweestapsverificatie implementeren
+ @@ -1408,6 +1413,31 @@ function openPlanModal(item) { document.getElementById('planTicketProject').innerText = '• ' + (ticket.project || 'Project'); document.getElementById('planTicketSummaryText').innerText = ticket.summary; + // Story Points → Duur conversie (1 SP = 30 minuten, configurabel) + const spBadge = document.getElementById('planSpBadge'); + const spHint = document.getElementById('planSpHint'); + const spHintText = document.getElementById('planSpHintText'); + const sp = parseFloat(ticket.story_points); + if (!isNaN(sp) && sp > 0) { + const spMinutes = Math.round(sp * 30); // 1 SP = 30 minuten + const closestDuration = [30, 45, 60, 90, 120].reduce((prev, curr) => + Math.abs(curr - spMinutes) < Math.abs(prev - spMinutes) ? curr : prev + ); + const durationSelect = document.getElementById('planDurationInput'); + if (durationSelect) { + const opt = durationSelect.querySelector(`option[value="${closestDuration}"]`); + if (opt) opt.selected = true; + } + spBadge.textContent = `${sp} SP`; + spBadge.classList.remove('hidden'); + spHintText.textContent = `${sp} story ${sp === 1 ? 'point' : 'points'} ≈ ${spMinutes} min → automatisch ${closestDuration} min ingesteld`; + spHint.classList.remove('hidden'); + spHint.style.display = 'flex'; + } else { + spBadge.classList.add('hidden'); + spHint.classList.add('hidden'); + } + // Suggesteer prioriteit op basis van Jira prio let focizPrio = 'P2'; const jp = (ticket.priority || '').toLowerCase(); @@ -1422,6 +1452,7 @@ function openPlanModal(item) { const modal = document.getElementById('planTaskModal'); modal.classList.remove('hidden'); modal.classList.add('flex'); + if (typeof lucide !== 'undefined') lucide.createIcons(); } function closePlanModal() { diff --git a/planner.php b/planner.php index 89dfa58..da7d95d 100644 --- a/planner.php +++ b/planner.php @@ -464,6 +464,51 @@ for ($h = $startHour; $h <= $endHour; $h++) { + + + + +
@@ -698,81 +743,109 @@ for ($h = $startHour; $h <= $endHour; $h++) {
-
-
- + +
+
+ + + JIRA + + + + + MAIL + + + + + OUTLOOK + + + + + +
+ +
+ + + + + + + + + + + + +
+
+
+ +
+ + + + + + - -
-
- - - JIRA - - - - - MAIL - - - - - OUTLOOK - - - - - -
+ + -
- - - - - - - - - - - - -
+ + +
-
- - - - - - - - - - - +
@@ -910,7 +983,67 @@ for ($h = $startHour; $h <= $endHour; $h++) { - + + 100 ? 'bg-rose-500' : ($capPercent >= 80 ? 'bg-amber-500' : 'bg-indigo-500'); + $capLabel = $capPercent > 100 ? 'Overbelast' : ($capPercent >= 80 ? 'Bijna vol' : 'Goed ingepland'); + $capLabelColor = $capPercent > 100 ? 'text-rose-600 dark:text-rose-400' : ($capPercent >= 80 ? 'text-amber-600 dark:text-amber-400' : 'text-indigo-600 dark:text-indigo-400'); + $freeMinutes = max(0, $effectiveCapacity - $usedMinutes); + $freeH = intdiv($freeMinutes, 60); + $freeM = $freeMinutes % 60; + $freeLabel = $freeH > 0 ? "{$freeH}u " : ''; + $freeLabel .= $freeM > 0 ? "{$freeM}m" : ''; + $freeLabel = $freeLabel ?: '0m'; + ?> +
+
+
+ Dagcapaciteit + % · +
+
+ 0): ?> + + + m meetings + + + 0): ?> + + + m taken + + + vrij +
+
+
+ 0): ?> +
+ + 0): ?> +
+ +
+
+ +
; const defaultWidgetLayout = ; +// --- AI Dagbriefing (laden bij pagina-open, alleen voor vandaag) --- + +const _aiBriefingDate = ''; +let _aiBriefingLoaded = false; + +async function loadDailyBriefing() { + if (_aiBriefingLoaded) return; + const banner = document.getElementById('aiBriefingBanner'); + if (!banner) return; + + // Toon banner met skeleton + banner.classList.remove('hidden'); + + try { + const resp = await fetch('/api/ai.php?action=daily_briefing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date: _aiBriefingDate }) + }); + const data = await resp.json(); + + if (!data.success || data.error) { + // Geen AI key of fout: banner verbergen, stil falen + banner.classList.add('hidden'); + return; + } + + // Vul content + document.getElementById('aiBriefingGreeting').textContent = data.greeting || ''; + + const focusEl = document.getElementById('aiBriefingFocusTask'); + const focusTextEl = document.getElementById('aiBriefingFocusText'); + if (data.focus_task) { + focusTextEl.textContent = data.focus_task; + focusEl.classList.remove('hidden'); + focusEl.style.display = 'flex'; + } + + const tipEl = document.getElementById('aiBriefingTip'); + const tipTextEl = document.getElementById('aiBriefingTipText'); + if (data.tip) { + tipTextEl.textContent = data.tip; + tipEl.classList.remove('hidden'); + tipEl.style.display = 'flex'; + } + + if (data.warnings && data.warnings.length > 0) { + const warnContainer = document.getElementById('aiBriefingWarnings'); + warnContainer.innerHTML = data.warnings.map(w => + `
+ + ${w} +
` + ).join(''); + warnContainer.classList.remove('hidden'); + } + + // Wissel skeleton ↔ content + document.getElementById('aiBriefingLoading').classList.add('hidden'); + document.getElementById('aiBriefingContent').classList.remove('hidden'); + + _aiBriefingLoaded = true; + if (typeof lucide !== 'undefined') lucide.createIcons(); + + } catch (err) { + // Netwerk- of serverfout: banner verbergen + banner.classList.add('hidden'); + } +} + +// Laad de briefing 1,5 seconden na paginalading (niet blokkeren) +window.addEventListener('DOMContentLoaded', () => { + setTimeout(loadDailyBriefing, 1500); +}); + + + function openWidgetManagerModal() { renderWidgetManagerList(); const modal = document.getElementById('widgetManagerModal'); @@ -3381,6 +3591,155 @@ async function deleteTask(taskId) { switchPlannerDate(activePlannerDate, null, false); } +// --- V2.0: Subtaken & Checklists --- +const _subtaskCache = {}; // task_id -> subtasks array + +async function toggleSubtaskPanel(taskId) { + const panel = document.getElementById(`subtask-panel-${taskId}`); + if (!panel) return; + + const isHidden = panel.classList.contains('hidden'); + if (isHidden) { + panel.classList.remove('hidden'); + // Laad subtaken als nog niet geladen + if (!_subtaskCache[taskId]) { + await loadSubtasks(taskId); + } else { + renderSubtasks(taskId); + } + // Focus invoerveld + const inp = document.getElementById(`subtask-input-${taskId}`); + if (inp) setTimeout(() => inp.focus(), 50); + } else { + panel.classList.add('hidden'); + } + + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +async function loadSubtasks(taskId) { + try { + const resp = await fetch(`/api/planner.php?action=get_subtasks&task_id=${taskId}`); + const data = await resp.json(); + if (data.success) { + _subtaskCache[taskId] = data.subtasks || []; + renderSubtasks(taskId); + } + } catch (e) { + console.error('Subtaken laden mislukt:', e); + } +} + +function renderSubtasks(taskId) { + const list = document.getElementById(`subtask-list-${taskId}`); + if (!list) return; + + const subtasks = _subtaskCache[taskId] || []; + if (subtasks.length === 0) { + list.innerHTML = '

Nog geen subtaken. Voeg hieronder toe.

'; + return; + } + + const doneCount = subtasks.filter(s => s.is_completed == 1).length; + const totalCount = subtasks.length; + + list.innerHTML = ` +
+ ${doneCount}/${totalCount} afgerond +
+
+
+
+ ${subtasks.map(s => ` +
+ + ${escapeHtml(s.title)} + +
+ `).join('')} + `; +} + +async function addSubtask(taskId) { + const inp = document.getElementById(`subtask-input-${taskId}`); + if (!inp) return; + const title = inp.value.trim(); + if (!title) return; + + inp.disabled = true; + try { + const resp = await fetch('/api/planner.php?action=add_subtask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId, title }) + }); + const data = await resp.json(); + if (data.success && data.subtask) { + if (!_subtaskCache[taskId]) _subtaskCache[taskId] = []; + _subtaskCache[taskId].push(data.subtask); + renderSubtasks(taskId); + inp.value = ''; + } else { + alert(data.error || 'Kan subtaak niet toevoegen'); + } + } catch (e) { + alert('Netwerkfout bij toevoegen subtaak'); + } finally { + inp.disabled = false; + inp.focus(); + } +} + +async function toggleSubtask(subtaskId, newState, taskId) { + try { + await fetch('/api/planner.php?action=toggle_subtask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subtask_id: subtaskId, is_completed: newState }) + }); + // Update cache + if (_subtaskCache[taskId]) { + const sub = _subtaskCache[taskId].find(s => s.id == subtaskId); + if (sub) sub.is_completed = newState; + } + renderSubtasks(taskId); + } catch (e) { + console.error('Subtaak toggle mislukt:', e); + } +} + +async function deleteSubtask(subtaskId, taskId) { + try { + await fetch('/api/planner.php?action=delete_subtask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subtask_id: subtaskId }) + }); + if (_subtaskCache[taskId]) { + _subtaskCache[taskId] = _subtaskCache[taskId].filter(s => s.id != subtaskId); + } + renderSubtasks(taskId); + } catch (e) { + console.error('Subtaak verwijderen mislukt:', e); + } +} + +// Helper: XSS-safe escaping voor subtaak titels in JS +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // --- AI Co-Pilot Slimme Dagplanning (Release v1.5) --- async function aiSmartSchedule(btn) { if (!confirm('Wil je AI Co-Pilot jouw openstaande taken voor deze dag slim laten inplannen rondom je Outlook meetings en lunch?')) return; -- 2.52.0