Compare commits

..

2 Commits

Author SHA1 Message Date
NiekRabelink 29adb48dd9 Merge pull request 'feat: Add story points support and AI daily briefing feature' (#44) from dev into main
Reviewed-on: #44
2026-09-10 20:38:37 +00:00
NiekRabelink 5ce08443cc 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.
2026-09-10 22:38:06 +02:00
8 changed files with 780 additions and 69 deletions
+93
View File
@@ -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);
+114
View File
@@ -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);
+2
View File
@@ -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 {
+15
View File
@@ -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;
+83
View File
@@ -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'] ?? [],
];
}
+14
View File
@@ -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',
];
}
+31
View File
@@ -494,10 +494,15 @@ $boardsJson = json_encode($boards, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT
<div class="flex items-center space-x-2">
<span id="planTicketKeyBadge" class="font-mono text-xs font-bold text-indigo-600 dark:text-indigo-400">FOC-101</span>
<span id="planTicketProject" class="text-[11px] text-slate-400">• Fociz Platform</span>
<span id="planSpBadge" class="hidden ml-auto text-[10px] font-bold px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-500/10 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-500/20"></span>
</div>
<div id="planTicketSummaryText" class="text-xs font-semibold text-slate-800 dark:text-slate-200 mt-1 line-clamp-2">
Tweestapsverificatie implementeren
</div>
<div id="planSpHint" class="hidden mt-1.5 text-[11px] text-violet-600 dark:text-violet-400 flex items-center space-x-1">
<i data-lucide="zap" class="w-3 h-3"></i>
<span id="planSpHintText"></span>
</div>
</div>
<!-- Datum Kiezer -->
@@ -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() {
+361 -2
View File
@@ -464,6 +464,51 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div>
</div>
<!-- AI Dagbriefing Banner (async geladen, alleen vandaag) -->
<?php if ($isTodaySelected): ?>
<div id="aiBriefingBanner" class="fociz-card p-5 relative overflow-hidden border-l-4 border-l-indigo-500 hidden">
<!-- Skeleton laadindicator -->
<div id="aiBriefingLoading" class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-xl bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0 animate-pulse">
<i data-lucide="sparkles" class="w-4 h-4"></i>
</div>
<div class="flex-1 space-y-1.5">
<div class="h-3 bg-slate-200 dark:bg-slate-800 rounded-lg animate-pulse w-3/4"></div>
<div class="h-2.5 bg-slate-200 dark:bg-slate-800 rounded-lg animate-pulse w-1/2"></div>
</div>
</div>
<!-- Inhoud (gevuld via JS) -->
<div id="aiBriefingContent" class="hidden">
<div class="flex items-start space-x-3">
<div class="w-8 h-8 rounded-xl bg-indigo-50 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0 mt-0.5">
<i data-lucide="sparkles" class="w-4 h-4"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-[10px] font-black uppercase tracking-wider text-indigo-600 dark:text-indigo-400">AI Co-Pilot · Dagbriefing</span>
<span class="text-[10px] text-slate-400 font-mono"><?= date('H:i') ?></span>
</div>
<p id="aiBriefingGreeting" class="text-sm font-semibold text-slate-900 dark:text-white"></p>
<div id="aiBriefingDetails" class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
<span id="aiBriefingFocusTask" class="hidden flex items-center space-x-1">
<i data-lucide="zap" class="w-3 h-3 text-amber-500 flex-shrink-0"></i>
<span id="aiBriefingFocusText"></span>
</span>
<span id="aiBriefingTip" class="hidden flex items-center space-x-1">
<i data-lucide="lightbulb" class="w-3 h-3 text-emerald-500 flex-shrink-0"></i>
<span id="aiBriefingTipText"></span>
</span>
</div>
<div id="aiBriefingWarnings" class="mt-2 space-y-1 hidden"></div>
</div>
<button onclick="document.getElementById('aiBriefingBanner').classList.add('hidden')" class="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer flex-shrink-0" title="Sluiten">
<i data-lucide="x" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
</div>
<?php endif; ?>
<!-- Modular Customizable Widget Grid Container (Fociz v2.0) -->
<div class="relative w-full" id="focizWidgetGridContainer">
<!-- Blueprint Achtergrond Raster (zichtbaar zodra een widget wordt opgepakt) -->
@@ -698,7 +743,9 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<div class="fociz-card-interactive group flex items-center justify-between p-3.5 rounded-2xl border <?= $task['is_completed'] ? 'bg-slate-50/50 dark:bg-slate-950/25 border-slate-200/60 dark:border-slate-800/40 opacity-60' : 'bg-white/95 dark:bg-[#111624]/90 border-slate-200/80 dark:border-slate-800/80 hover:border-indigo-400 dark:hover:border-indigo-500/50 shadow-2xs hover:shadow-xs' ?> <?= $task['priority'] === 'P1' ? 'border-l-4 border-l-rose-500' : ($task['priority'] === 'P2' ? 'border-l-4 border-l-amber-500' : 'border-l-4 border-l-indigo-400 dark:border-l-indigo-500') ?> transition min-w-0" id="task-row-<?= $task['id'] ?>">
<div class="rounded-2xl border <?= $task['is_completed'] ? 'bg-slate-50/50 dark:bg-slate-950/25 border-slate-200/60 dark:border-slate-800/40 opacity-60' : 'bg-white/95 dark:bg-[#111624]/90 border-slate-200/80 dark:border-slate-800/80 shadow-2xs' ?> <?= $task['priority'] === 'P1' ? 'border-l-4 border-l-rose-500' : ($task['priority'] === 'P2' ? 'border-l-4 border-l-amber-500' : 'border-l-4 border-l-indigo-400 dark:border-l-indigo-500') ?> transition min-w-0" id="task-row-<?= $task['id'] ?>">
<!-- Hoofd taakrij -->
<div class="fociz-card-interactive group flex items-center justify-between p-3.5 <?= $task['is_completed'] ? '' : 'hover:border-indigo-400 dark:hover:border-indigo-500/50' ?>">
<div class="flex items-center space-x-3 truncate mr-2">
<button onclick="toggleTask(<?= $task['id'] ?>, <?= $task['is_completed'] ? 0 : 1 ?>)" class="w-5 h-5 rounded-lg border flex items-center justify-center transition <?= $task['is_completed'] ? 'bg-indigo-600 border-indigo-600 text-white font-bold' : 'border-slate-300 dark:border-slate-700 hover:border-indigo-500 bg-white dark:bg-slate-900' ?>">
<?php if ($task['is_completed']): ?>
@@ -757,6 +804,11 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</button>
<?php endif; ?>
<!-- Subtaak toggle knop -->
<button onclick="toggleSubtaskPanel(<?= $task['id'] ?>)" title="Subtaken / Checklist" class="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-500/10 opacity-0 group-hover:opacity-100 transition cursor-pointer" id="subtask-btn-<?= $task['id'] ?>">
<i data-lucide="list-checks" class="w-3.5 h-3.5"></i>
</button>
<button onclick="openEditTaskModal(<?= htmlspecialchars(json_encode([
'id' => (int)$task['id'],
'title' => $task['title'],
@@ -775,6 +827,27 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</button>
</div>
</div>
<!-- Subtaken panel (verborgen by default, uitklapbaar) -->
<div id="subtask-panel-<?= $task['id'] ?>" class="hidden border-t border-slate-100 dark:border-slate-800/60 px-4 pb-3 pt-2.5 space-y-2 bg-slate-50/50 dark:bg-slate-950/20 rounded-b-2xl">
<!-- Subtaak lijst (dynamisch gevuld) -->
<div id="subtask-list-<?= $task['id'] ?>" class="space-y-1.5"></div>
<!-- Nieuw subtaak invoer -->
<div class="flex items-center space-x-2 mt-1.5">
<input
type="text"
id="subtask-input-<?= $task['id'] ?>"
placeholder="Subtaak toevoegen…"
class="flex-1 bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onkeydown="if(event.key==='Enter'){addSubtask(<?= $task['id'] ?>);}"
>
<button onclick="addSubtask(<?= $task['id'] ?>)" class="flex items-center space-x-1 px-2.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-[10px] font-bold rounded-xl transition cursor-pointer">
<i data-lucide="plus" class="w-3 h-3"></i>
<span>Toevoegen</span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
@@ -910,7 +983,67 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</span>
</div>
<!-- 4. De urentijdlijn rijen -->
<!-- 4. Capaciteitsbar: Geplande minuten vs. werkdagcapaciteit -->
<?php
$workdayCapacityMinutes = max(1, ($endHour - $startHour) * 60);
$lunchMinutes = 0;
if (!empty($lunchStart) && !empty($lunchEnd)) {
$lunchMinutes = (int)((strtotime("2000-01-01 {$lunchEnd}") - strtotime("2000-01-01 {$lunchStart}")) / 60);
}
$effectiveCapacity = max(1, $workdayCapacityMinutes - $lunchMinutes);
$meetingMinutes = 0;
foreach ($displayedOutlookEvents as $ev) {
if (!empty($ev['start']) && !empty($ev['end'])) {
$meetingMinutes += max(0, (int)((strtotime("2000-01-01 {$ev['end']}") - strtotime("2000-01-01 {$ev['start']}")) / 60));
}
}
$usedMinutes = $totalPlannedMinutes + $meetingMinutes;
$capPercent = min(100, round(($usedMinutes / $effectiveCapacity) * 100));
$taskOnlyPercent = min(100, round(($totalPlannedMinutes / $effectiveCapacity) * 100));
$meetingPercent = min(100 - $taskOnlyPercent, round(($meetingMinutes / $effectiveCapacity) * 100));
$capColor = $capPercent > 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';
?>
<div class="space-y-1.5">
<div class="flex items-center justify-between text-[10px]">
<div class="flex items-center space-x-2">
<span class="font-bold text-slate-600 dark:text-slate-400 uppercase tracking-wider">Dagcapaciteit</span>
<span class="font-mono font-black <?= $capLabelColor ?>"><?= $capPercent ?>% · <?= $capLabel ?></span>
</div>
<div class="flex items-center space-x-3 font-mono text-slate-500 dark:text-slate-400">
<?php if ($meetingMinutes > 0): ?>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-sm bg-sky-400 dark:bg-sky-500 inline-block"></span>
<span><?= $meetingMinutes ?>m meetings</span>
</span>
<?php endif; ?>
<?php if ($totalPlannedMinutes > 0): ?>
<span class="flex items-center space-x-1">
<span class="w-2 h-2 rounded-sm bg-indigo-500 inline-block"></span>
<span><?= $totalPlannedMinutes ?>m taken</span>
</span>
<?php endif; ?>
<span class="text-emerald-600 dark:text-emerald-400 font-semibold"><?= $freeLabel ?> vrij</span>
</div>
</div>
<div class="h-2.5 w-full bg-slate-100 dark:bg-slate-900/60 rounded-full overflow-hidden flex">
<?php if ($meetingPercent > 0): ?>
<div class="h-full bg-sky-400 dark:bg-sky-500 transition-all duration-500 rounded-l-full" style="width: <?= $meetingPercent ?>%"></div>
<?php endif; ?>
<?php if ($taskOnlyPercent > 0): ?>
<div class="h-full <?= $capColor ?> transition-all duration-500 <?= $meetingPercent === 0 ? 'rounded-l-full' : '' ?> <?= ($meetingPercent + $taskOnlyPercent) >= 100 ? 'rounded-r-full' : '' ?>" style="width: <?= $taskOnlyPercent ?>%"></div>
<?php endif; ?>
</div>
</div>
<!-- 5. De urentijdlijn rijen -->
<div class="space-y-2.5">
<?php foreach ($hours as $hour):
$hourInt = (int)explode(':', $hour)[0];
@@ -1794,6 +1927,83 @@ if ($isAjaxRequest) {
let focizWidgets = <?= json_encode(array_values($userWidgetLayout), JSON_UNESCAPED_UNICODE) ?>;
const defaultWidgetLayout = <?= json_encode(array_values($availableWidgets), JSON_UNESCAPED_UNICODE) ?>;
// --- AI Dagbriefing (laden bij pagina-open, alleen voor vandaag) ---
<?php if ($isTodaySelected): ?>
const _aiBriefingDate = '<?= $selectedDate ?>';
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 =>
`<div class="flex items-center space-x-1.5 text-[11px] text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/50 rounded-lg px-2.5 py-1">
<i data-lucide="alert-triangle" class="w-3 h-3 flex-shrink-0"></i>
<span>${w}</span>
</div>`
).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);
});
<?php endif; ?>
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 = '<p class="text-[11px] text-slate-400 italic">Nog geen subtaken. Voeg hieronder toe.</p>';
return;
}
const doneCount = subtasks.filter(s => s.is_completed == 1).length;
const totalCount = subtasks.length;
list.innerHTML = `
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] font-bold text-slate-500 dark:text-slate-400">${doneCount}/${totalCount} afgerond</span>
<div class="flex-1 mx-2 h-1 bg-slate-200 dark:bg-slate-800 rounded-full overflow-hidden">
<div class="h-full bg-indigo-500 transition-all duration-300" style="width: ${totalCount > 0 ? Math.round((doneCount/totalCount)*100) : 0}%"></div>
</div>
</div>
${subtasks.map(s => `
<div class="flex items-center space-x-2 group/sub py-1" id="subtask-item-${s.id}">
<button onclick="toggleSubtask(${s.id}, ${s.is_completed ? 0 : 1}, ${taskId})"
class="w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 transition ${s.is_completed ? 'bg-indigo-600 border-indigo-600 text-white' : 'border-slate-300 dark:border-slate-700 hover:border-indigo-500 bg-white dark:bg-slate-900'}">
${s.is_completed ? '<svg xmlns="http://www.w3.org/2000/svg" class="w-2.5 h-2.5 stroke-[3]" viewBox="0 0 24 24" fill="none" stroke="currentColor"><polyline points="20 6 9 17 4 12"/></svg>' : ''}
</button>
<span class="text-xs flex-1 ${s.is_completed ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-700 dark:text-slate-300'}">${escapeHtml(s.title)}</span>
<button onclick="deleteSubtask(${s.id}, ${taskId})"
class="p-0.5 text-slate-300 hover:text-red-500 dark:text-slate-700 dark:hover:text-red-400 opacity-0 group-hover/sub:opacity-100 transition cursor-pointer rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>
`).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// --- 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;