feat: Add story points support and AI daily briefing feature #44
+93
@@ -205,6 +205,99 @@ try {
|
|||||||
exit;
|
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']);
|
echo json_encode(['error' => 'Onbekende actie']);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
+114
@@ -486,6 +486,120 @@ try {
|
|||||||
exit;
|
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']);
|
echo json_encode(['error' => 'Onbekende actie']);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
|
"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 `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",
|
"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) {
|
foreach ($colUpgrades as $sql) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -242,3 +242,18 @@ CREATE TABLE IF NOT EXISTS `app_cache` (
|
|||||||
`expires_at` INT UNSIGNED NOT NULL,
|
`expires_at` INT UNSIGNED NOT NULL,
|
||||||
INDEX `idx_app_cache_expires` (`expires_at`)
|
INDEX `idx_app_cache_expires` (`expires_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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;
|
||||||
|
|||||||
@@ -195,3 +195,86 @@ function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySett
|
|||||||
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
|
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'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
|
|||||||
'description',
|
'description',
|
||||||
'labels',
|
'labels',
|
||||||
'components',
|
'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
|
// 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';
|
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[] = [
|
$issues[] = [
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
@@ -409,6 +422,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
|
|||||||
'created' => $created,
|
'created' => $created,
|
||||||
'updated' => $updated,
|
'updated' => $updated,
|
||||||
'url' => "{$cleanDomain}/browse/{$key}",
|
'url' => "{$cleanDomain}/browse/{$key}",
|
||||||
|
'story_points' => $storyPoints,
|
||||||
'source' => 'JIRA_LIVE',
|
'source' => 'JIRA_LIVE',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
<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="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="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>
|
||||||
<div id="planTicketSummaryText" class="text-xs font-semibold text-slate-800 dark:text-slate-200 mt-1 line-clamp-2">
|
<div id="planTicketSummaryText" class="text-xs font-semibold text-slate-800 dark:text-slate-200 mt-1 line-clamp-2">
|
||||||
Tweestapsverificatie implementeren
|
Tweestapsverificatie implementeren
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Datum Kiezer -->
|
<!-- Datum Kiezer -->
|
||||||
@@ -1408,6 +1413,31 @@ function openPlanModal(item) {
|
|||||||
document.getElementById('planTicketProject').innerText = '• ' + (ticket.project || 'Project');
|
document.getElementById('planTicketProject').innerText = '• ' + (ticket.project || 'Project');
|
||||||
document.getElementById('planTicketSummaryText').innerText = ticket.summary;
|
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
|
// Suggesteer prioriteit op basis van Jira prio
|
||||||
let focizPrio = 'P2';
|
let focizPrio = 'P2';
|
||||||
const jp = (ticket.priority || '').toLowerCase();
|
const jp = (ticket.priority || '').toLowerCase();
|
||||||
@@ -1422,6 +1452,7 @@ function openPlanModal(item) {
|
|||||||
const modal = document.getElementById('planTaskModal');
|
const modal = document.getElementById('planTaskModal');
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
modal.classList.add('flex');
|
modal.classList.add('flex');
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePlanModal() {
|
function closePlanModal() {
|
||||||
|
|||||||
+361
-2
@@ -464,6 +464,51 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Modular Customizable Widget Grid Container (Fociz v2.0) -->
|
||||||
<div class="relative w-full" id="focizWidgetGridContainer">
|
<div class="relative w-full" id="focizWidgetGridContainer">
|
||||||
<!-- Blueprint Achtergrond Raster (zichtbaar zodra een widget wordt opgepakt) -->
|
<!-- Blueprint Achtergrond Raster (zichtbaar zodra een widget wordt opgepakt) -->
|
||||||
@@ -698,7 +743,9 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($tasks as $task): ?>
|
<?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">
|
<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' ?>">
|
<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']): ?>
|
<?php if ($task['is_completed']): ?>
|
||||||
@@ -757,6 +804,11 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
<?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([
|
<button onclick="openEditTaskModal(<?= htmlspecialchars(json_encode([
|
||||||
'id' => (int)$task['id'],
|
'id' => (int)$task['id'],
|
||||||
'title' => $task['title'],
|
'title' => $task['title'],
|
||||||
@@ -775,6 +827,27 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,7 +983,67 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="space-y-2.5">
|
||||||
<?php foreach ($hours as $hour):
|
<?php foreach ($hours as $hour):
|
||||||
$hourInt = (int)explode(':', $hour)[0];
|
$hourInt = (int)explode(':', $hour)[0];
|
||||||
@@ -1794,6 +1927,83 @@ if ($isAjaxRequest) {
|
|||||||
let focizWidgets = <?= json_encode(array_values($userWidgetLayout), JSON_UNESCAPED_UNICODE) ?>;
|
let focizWidgets = <?= json_encode(array_values($userWidgetLayout), JSON_UNESCAPED_UNICODE) ?>;
|
||||||
const defaultWidgetLayout = <?= json_encode(array_values($availableWidgets), 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() {
|
function openWidgetManagerModal() {
|
||||||
renderWidgetManagerList();
|
renderWidgetManagerList();
|
||||||
const modal = document.getElementById('widgetManagerModal');
|
const modal = document.getElementById('widgetManagerModal');
|
||||||
@@ -3381,6 +3591,155 @@ async function deleteTask(taskId) {
|
|||||||
switchPlannerDate(activePlannerDate, null, false);
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
// --- AI Co-Pilot Slimme Dagplanning (Release v1.5) ---
|
// --- AI Co-Pilot Slimme Dagplanning (Release v1.5) ---
|
||||||
async function aiSmartSchedule(btn) {
|
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;
|
if (!confirm('Wil je AI Co-Pilot jouw openstaande taken voor deze dag slim laten inplannen rondom je Outlook meetings en lunch?')) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user