Compare commits
72 Commits
fd59cd69c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c55a94618f | |||
| 3b51d2da96 | |||
| dd7295812f | |||
| 5ea25758bd | |||
| ef189273b5 | |||
| 39a41fa207 | |||
| 78369a44c2 | |||
| 999f93cc1a | |||
| 07988b3e1d | |||
| 1d8560f50c | |||
| 0c466972c2 | |||
| fb9034034a | |||
| dc6b55e4f9 | |||
| ef116be9b8 | |||
| 65d5a4d4a3 | |||
| a4824bb440 | |||
| e45f565365 | |||
| 3dc09a11a7 | |||
| 86041b665d | |||
| f18f7d5794 | |||
| 45600040ea | |||
| 660f196c60 | |||
| 32974cdb31 | |||
| 8ddae4d883 | |||
| d4e4b69e02 | |||
| 65f81d620c | |||
| 0b5ca54db9 | |||
| 20d8f93f77 | |||
| bda7b311e3 | |||
| 85850f47ad | |||
| 45a883319a | |||
| 460fa967ee | |||
| e9f54cbe72 | |||
| 210ecc7b18 | |||
| 2d89e19114 | |||
| 6bc351aaa4 | |||
| 1ad383d27b | |||
| 8498d82e10 | |||
| a038d25489 | |||
| 97d6085c9d | |||
| 4a9ba4d76a | |||
| b800279cd2 | |||
| e08b304c38 | |||
| 687b929170 | |||
| 830fdf1330 | |||
| 2750fd0681 | |||
| d65463dab8 | |||
| 8cff08f253 | |||
| 843b75334e | |||
| 9cd457971b | |||
| 1358b6a7e9 | |||
| aa23ccdd73 | |||
| e3af7c7aa5 | |||
| cb8246dba9 | |||
| f992ca1ec9 | |||
| 5c4c53922b | |||
| 7a85f612b1 | |||
| ee12d54350 | |||
| e8a6667abd | |||
| 7e7f071f04 | |||
| 3595d58763 | |||
| bf0d2f3bca | |||
| 24fbfb9d82 | |||
| 6a4981744f | |||
| 5a31f0948a | |||
| 740b5343c4 | |||
| cd6071902d | |||
| 32d27cd457 | |||
| 31caf540a3 | |||
| 4eaae64eb6 | |||
| 6987cab4f8 | |||
| 89b53f6428 |
+63
-8
@@ -72,6 +72,7 @@ try {
|
|||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
$sessionId = (int)($data['session_id'] ?? 0);
|
$sessionId = (int)($data['session_id'] ?? 0);
|
||||||
$count = min(6, max(2, (int)($data['count'] ?? 4)));
|
$count = min(6, max(2, (int)($data['count'] ?? 4)));
|
||||||
|
$fullBoard = !empty($data['full_board']); // Genereren over alle 4 categorieën o.b.v. briefing
|
||||||
|
|
||||||
if (!$sessionId) {
|
if (!$sessionId) {
|
||||||
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
||||||
@@ -87,22 +88,76 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id']);
|
$authorName = 'AI Co-Pilot';
|
||||||
|
$createdCards = [];
|
||||||
|
|
||||||
if (!$aiRes['success']) {
|
$colorMap = [
|
||||||
echo json_encode(['error' => $aiRes['error']]);
|
'Ideeën' => 'amber',
|
||||||
|
'Knelpunten' => 'rose',
|
||||||
|
'Oplossingen' => 'teal',
|
||||||
|
'Quick Wins' => 'indigo',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($fullBoard || !empty($session['description'])) {
|
||||||
|
// Gebruik de volledige board generator die alle 4 categorieën vult aan de hand van de briefing
|
||||||
|
$aiRes = fociz_ai_generate_session_board(
|
||||||
|
$session['title'],
|
||||||
|
$session['theme'] ?? 'Algemeen',
|
||||||
|
$session['description'] ?? '',
|
||||||
|
$user['id']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($aiRes['success'] && !empty($aiRes['cards'])) {
|
||||||
|
$insertStmt = $db->prepare("
|
||||||
|
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($aiRes['cards'] as $cardItem) {
|
||||||
|
$cText = trim($cardItem['content'] ?? '');
|
||||||
|
$cCat = trim($cardItem['category'] ?? 'Ideeën');
|
||||||
|
if (!isset($colorMap[$cCat])) {
|
||||||
|
$cCat = 'Ideeën';
|
||||||
|
}
|
||||||
|
$cColor = $colorMap[$cCat];
|
||||||
|
|
||||||
|
if (!empty($cText)) {
|
||||||
|
$insertStmt->execute([$sessionId, $cText, $cCat, $cColor, $authorName]);
|
||||||
|
$createdCards[] = [
|
||||||
|
'id' => (int)$db->lastInsertId(),
|
||||||
|
'content' => $cText,
|
||||||
|
'category' => $cCat,
|
||||||
|
'color' => $cColor,
|
||||||
|
'author_name' => $authorName,
|
||||||
|
'votes' => 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'full_board']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: reguliere generatie
|
||||||
|
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
|
||||||
|
|
||||||
|
$ideas = $aiRes['ideas'] ?? [];
|
||||||
|
if (empty($ideas)) {
|
||||||
|
$ideas = [
|
||||||
|
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
|
||||||
|
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||||
|
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||||
|
"Feedback- en validatielus inrichten met eindgebruikers",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$insertStmt = $db->prepare("
|
$insertStmt = $db->prepare("
|
||||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||||
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
||||||
");
|
");
|
||||||
|
|
||||||
$createdCards = [];
|
foreach ($ideas as $ideaText) {
|
||||||
$authorName = 'AI Co-Pilot';
|
|
||||||
|
|
||||||
foreach ($aiRes['ideas'] as $ideaText) {
|
|
||||||
$ideaText = trim($ideaText);
|
$ideaText = trim($ideaText);
|
||||||
if (!empty($ideaText)) {
|
if (!empty($ideaText)) {
|
||||||
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
||||||
@@ -117,7 +172,7 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'cards' => $createdCards]);
|
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'ideas']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -391,7 +391,7 @@ try {
|
|||||||
|
|
||||||
$resCreate = microsoft_create_event($assigneeId, $eventPayload);
|
$resCreate = microsoft_create_event($assigneeId, $eventPayload);
|
||||||
if (!empty($resCreate['success'])) {
|
if (!empty($resCreate['success'])) {
|
||||||
$outlookEventId = $resCreate['id'] ?? null;
|
$outlookEventId = $resCreate['event_id'] ?? ($resCreate['id'] ?? null);
|
||||||
$outlookSyncedAt = date('Y-m-d H:i:s');
|
$outlookSyncedAt = date('Y-m-d H:i:s');
|
||||||
$outlookSyncMsg = 'Taak succesvol ingepland in de Outlook agenda van de collega!';
|
$outlookSyncMsg = 'Taak succesvol ingepland in de Outlook agenda van de collega!';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -368,6 +368,53 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Brainstormsessie verwijderen (inclusief bijbehorende kaarten)
|
||||||
|
if ($action === 'delete_session') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||||
|
$sessionId = (int)($data['session_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$sessionId) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Geen geldig sessie ID opgegeven.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtCheck = $db->prepare("SELECT id, title, created_by_id FROM brainstorm_sessions WHERE id = ?");
|
||||||
|
$stmtCheck->execute([$sessionId]);
|
||||||
|
$session = $stmtCheck->fetch();
|
||||||
|
|
||||||
|
if (!$session) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Brainstormsessie niet gevonden.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isCreator = ((int)$session['created_by_id'] === (int)$user['id']);
|
||||||
|
$isAdmin = ($user['role'] === 'ADMIN');
|
||||||
|
|
||||||
|
if (!$isCreator && !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Alleen de maker van deze sessie of een beheerder kan deze verwijderen.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Verwijder kaarten behorend bij deze sessie
|
||||||
|
$stmtDelCards = $db->prepare("DELETE FROM brainstorm_cards WHERE session_id = ?");
|
||||||
|
$stmtDelCards->execute([$sessionId]);
|
||||||
|
|
||||||
|
// Verwijder de sessie zelf
|
||||||
|
$stmtDelSession = $db->prepare("DELETE FROM brainstorm_sessions WHERE id = ?");
|
||||||
|
$stmtDelSession->execute([$sessionId]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Brainstormsessie en alle bijbehorende kaarten zijn definitief verwijderd.']);
|
||||||
|
exit;
|
||||||
|
} catch (Throwable $t) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
throw $t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode(['error' => 'Onbekende actie']);
|
echo json_encode(['error' => 'Onbekende actie']);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
|||||||
+46
-4
@@ -112,10 +112,50 @@ try {
|
|||||||
$planId = (int)$db->lastInsertId();
|
$planId = (int)$db->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bereken eindtijd indien starttijd bekend is
|
||||||
|
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||||
|
try {
|
||||||
|
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||||
|
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||||
|
$timeBlockEnd = $dtEnd->format('H:i');
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = 'JIRA';
|
||||||
|
$externalId = $key;
|
||||||
|
$externalUrl = $url;
|
||||||
|
$outlookSyncMsg = null;
|
||||||
|
|
||||||
|
// Automatisch synchroniseren naar Microsoft Outlook indien gekoppeld
|
||||||
|
if (function_exists('microsoft_create_event')) {
|
||||||
|
try {
|
||||||
|
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||||
|
$msResult = microsoft_create_event($user['id'], [
|
||||||
|
'subject' => $title,
|
||||||
|
'description' => "Jira ticket {$key} gepland via Fociz ({$priority})\n\n{$url}",
|
||||||
|
'date' => $planDate,
|
||||||
|
'start_time' => $syncStartTime,
|
||||||
|
'end_time' => $timeBlockEnd,
|
||||||
|
'duration_minutes' => $durationMinutes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||||
|
$source = 'OUTLOOK';
|
||||||
|
$externalId = $msResult['event_id'];
|
||||||
|
$externalUrl = $msResult['web_link'] ?? ($url ?: 'https://outlook.office.com');
|
||||||
|
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||||
|
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||||
|
$outlookSyncMsg = 'Gesynchroniseerd naar Microsoft Outlook.';
|
||||||
|
}
|
||||||
|
} catch (Throwable $t) {
|
||||||
|
error_log("Fout bij auto-sync Jira naar Outlook: " . $t->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Voeg taak toe
|
// Voeg taak toe
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', 'JIRA', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
|
||||||
");
|
");
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
$user['id'],
|
$user['id'],
|
||||||
@@ -125,15 +165,17 @@ try {
|
|||||||
$timeBlockStart,
|
$timeBlockStart,
|
||||||
$timeBlockEnd,
|
$timeBlockEnd,
|
||||||
$durationMinutes,
|
$durationMinutes,
|
||||||
$key,
|
$source,
|
||||||
$url
|
$externalId,
|
||||||
|
$externalUrl
|
||||||
]);
|
]);
|
||||||
$taskId = (int)$db->lastInsertId();
|
$taskId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'task_id' => $taskId,
|
'task_id' => $taskId,
|
||||||
'message' => "Ticket {$key} is succesvol ingepland voor " . date('d-m-Y', strtotime($planDate)) . "!",
|
'outlook_synced' => ($source === 'OUTLOOK'),
|
||||||
|
'message' => "Ticket {$key} is succesvol ingepland voor " . date('d-m-Y', strtotime($planDate)) . "!" . ($outlookSyncMsg ? " ({$outlookSyncMsg})" : ""),
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-8
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/../includes/auth.php';
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||||
|
require_once __DIR__ . '/../includes/boards.php';
|
||||||
|
|
||||||
$user = require_auth();
|
$user = require_auth();
|
||||||
$userId = (int)$user['id'];
|
$userId = (int)$user['id'];
|
||||||
@@ -21,13 +22,38 @@ header('Content-Type: application/json; charset=utf-8');
|
|||||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Inbox berichten ophalen
|
// 0. Mail mappen ophalen
|
||||||
if ($action === 'get_inbox') {
|
if ($action === 'get_folders') {
|
||||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
|
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||||
|
$res = microsoft_fetch_mail_folders($userId, $forceRefresh);
|
||||||
|
echo json_encode($res);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0b. Uitgeschakelde mappen opslaan
|
||||||
|
if ($action === 'save_disabled_folders') {
|
||||||
|
$input = file_get_contents('php://input');
|
||||||
|
$data = json_decode($input, true);
|
||||||
|
$folders = $data['disabled_folders'] ?? ($_POST['disabled_folders'] ?? []);
|
||||||
|
if (is_string($folders)) {
|
||||||
|
$folders = json_decode($folders, true) ?: [];
|
||||||
|
}
|
||||||
|
if (!is_array($folders)) {
|
||||||
|
$folders = [];
|
||||||
|
}
|
||||||
|
$ok = microsoft_save_disabled_folders($userId, $folders);
|
||||||
|
echo json_encode(['success' => $ok, 'disabled_folders' => $folders]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Inbox of specifieke map berichten ophalen
|
||||||
|
if ($action === 'get_folder_messages' || $action === 'get_inbox') {
|
||||||
|
$folderId = trim($_GET['folder_id'] ?? ($_POST['folder_id'] ?? 'inbox'));
|
||||||
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 30;
|
||||||
$unreadOnly = !empty($_GET['unread_only']) && $_GET['unread_only'] === '1';
|
$unreadOnly = !empty($_GET['unread_only']) && $_GET['unread_only'] === '1';
|
||||||
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||||
|
|
||||||
$res = microsoft_fetch_inbox_messages($userId, $limit, $unreadOnly, $forceRefresh);
|
$res = microsoft_fetch_folder_messages($userId, $folderId, $limit, $unreadOnly, $forceRefresh);
|
||||||
echo json_encode($res);
|
echo json_encode($res);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -164,9 +190,40 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$source = 'OUTLOOK_MAIL';
|
||||||
|
$externalId = $messageId;
|
||||||
|
$externalUrl = $webLink;
|
||||||
|
$outlookSyncMsg = null;
|
||||||
|
|
||||||
|
// Automatisch synchroniseren naar Microsoft Outlook agenda
|
||||||
|
if (function_exists('microsoft_create_event')) {
|
||||||
|
try {
|
||||||
|
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||||
|
$msResult = microsoft_create_event($userId, [
|
||||||
|
'subject' => $taskTitle,
|
||||||
|
'description' => "E-mail actiepunt gepland via Fociz ({$priority})\n\nAfzender: {$sender}\nWeblink: {$webLink}",
|
||||||
|
'date' => $planDate,
|
||||||
|
'start_time' => $syncStartTime,
|
||||||
|
'end_time' => $timeBlockEnd,
|
||||||
|
'duration_minutes' => $durationMinutes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||||
|
$source = 'OUTLOOK';
|
||||||
|
$externalId = $msResult['event_id'];
|
||||||
|
$externalUrl = $msResult['web_link'] ?? ($webLink ?: 'https://outlook.office.com');
|
||||||
|
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||||
|
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||||
|
$outlookSyncMsg = 'Gesynchroniseerd naar Microsoft Outlook.';
|
||||||
|
}
|
||||||
|
} catch (Throwable $t) {
|
||||||
|
error_log("Fout bij auto-sync Mail naar Outlook: " . $t->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$stmtTask = $db->prepare("
|
$stmtTask = $db->prepare("
|
||||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, source, external_id, external_url)
|
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, source, external_id, external_url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'OUTLOOK_MAIL', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
");
|
");
|
||||||
$stmtTask->execute([
|
$stmtTask->execute([
|
||||||
$userId,
|
$userId,
|
||||||
@@ -176,16 +233,49 @@ try {
|
|||||||
$timeBlockStart,
|
$timeBlockStart,
|
||||||
$timeBlockEnd,
|
$timeBlockEnd,
|
||||||
$durationMinutes,
|
$durationMinutes,
|
||||||
$messageId,
|
$source,
|
||||||
$webLink
|
$externalId,
|
||||||
|
$externalUrl
|
||||||
]);
|
]);
|
||||||
$taskId = (int)$db->lastInsertId();
|
$taskId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
// Optioneel toevoegen aan Bord en Tijdlijn
|
||||||
|
$boardSyncResult = null;
|
||||||
|
$addToBoard = !empty($input['add_to_board']) && !empty($input['board_id']);
|
||||||
|
if ($addToBoard) {
|
||||||
|
$boardId = (int)$input['board_id'];
|
||||||
|
$laneId = (int)($input['lane_id'] ?? 0);
|
||||||
|
$scheduleOnTimeline = !empty($input['schedule_on_timeline']);
|
||||||
|
$timelineStartDate = !empty($input['timeline_start_date']) ? trim($input['timeline_start_date']) : $planDate;
|
||||||
|
$timelineEndDate = !empty($input['timeline_end_date']) ? trim($input['timeline_end_date']) : $timelineStartDate;
|
||||||
|
|
||||||
|
$mailDesc = "Afzender: " . ($sender ?: 'Onbekend');
|
||||||
|
if (!empty($webLink)) {
|
||||||
|
$mailDesc .= "\nWeblink: " . $webLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
$boardSyncResult = fociz_add_item_to_board_and_timeline($db, $userId, (($user['role'] ?? '') === 'ADMIN'), [
|
||||||
|
'board_id' => $boardId,
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'title' => $taskTitle,
|
||||||
|
'description' => $mailDesc,
|
||||||
|
'priority' => $priority,
|
||||||
|
'color' => !empty($input['board_color']) ? $input['board_color'] : 'blue',
|
||||||
|
'schedule_on_timeline' => $scheduleOnTimeline,
|
||||||
|
'start_date' => $timelineStartDate,
|
||||||
|
'end_date' => $timelineEndDate,
|
||||||
|
'start_time' => $timeBlockStart ?: '09:00',
|
||||||
|
'end_time' => $timeBlockEnd ?: '10:00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'task_id' => $taskId,
|
'task_id' => $taskId,
|
||||||
'plan_date' => $planDate,
|
'plan_date' => $planDate,
|
||||||
'message' => "E-mail succesvol ingepland voor {$planDate}."
|
'outlook_synced' => ($source === 'OUTLOOK'),
|
||||||
|
'board_sync' => $boardSyncResult,
|
||||||
|
'message' => "E-mail succesvol ingepland voor {$planDate}." . ($outlookSyncMsg ? " ({$outlookSyncMsg})" : "")
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+530
-16
@@ -6,6 +6,7 @@
|
|||||||
require_once __DIR__ . '/../includes/auth.php';
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
require_once __DIR__ . '/../includes/settings.php';
|
require_once __DIR__ . '/../includes/settings.php';
|
||||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||||
|
require_once __DIR__ . '/../includes/boards.php';
|
||||||
$user = require_auth(true);
|
$user = require_auth(true);
|
||||||
$db = get_db_connection();
|
$db = get_db_connection();
|
||||||
|
|
||||||
@@ -86,6 +87,419 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1d. Snelle Bullet Notities ophalen
|
||||||
|
if ($action === 'get_bullets') {
|
||||||
|
$date = $_GET['date'] ?? $today;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT b.*, t.time_block_start as live_start, t.time_block_end as live_end, t.status as task_status
|
||||||
|
FROM dashboard_bullets b
|
||||||
|
LEFT JOIN tasks t ON b.scheduled_task_id = t.id
|
||||||
|
WHERE b.user_id = ? AND b.plan_date = ?
|
||||||
|
ORDER BY b.position ASC, b.id ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$user['id'], $date]);
|
||||||
|
$bullets = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Tel openstaande items van eerdere dagen
|
||||||
|
$stmtPast = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM dashboard_bullets
|
||||||
|
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
|
||||||
|
");
|
||||||
|
$stmtPast->execute([$user['id'], $date]);
|
||||||
|
$pastOpenCount = (int)$stmtPast->fetchColumn();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'bullets' => $bullets,
|
||||||
|
'past_open_count' => $pastOpenCount
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1e. Bullet notitie toevoegen
|
||||||
|
if ($action === 'add_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$date = !empty($data['date']) ? trim($data['date']) : $today;
|
||||||
|
$content = trim($data['content'] ?? '');
|
||||||
|
|
||||||
|
if ($content === '') {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Notitie mag niet leeg zijn']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bepaal positie
|
||||||
|
$stmtPos = $db->prepare("SELECT COALESCE(MAX(position), 0) + 1 FROM dashboard_bullets WHERE user_id = ? AND plan_date = ?");
|
||||||
|
$stmtPos->execute([$user['id'], $date]);
|
||||||
|
$pos = (int)$stmtPos->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO dashboard_bullets (user_id, plan_date, content, position)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([$user['id'], $date, $content, $pos]);
|
||||||
|
$bulletId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
$stmtFetch = $db->prepare("SELECT * FROM dashboard_bullets WHERE id = ?");
|
||||||
|
$stmtFetch->execute([$bulletId]);
|
||||||
|
$newBullet = $stmtFetch->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'bullet' => $newBullet
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1f. Bullet notitie status wisselen (afvinken / openen)
|
||||||
|
if ($action === 'toggle_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$isCompleted = !empty($data['is_completed']) ? 1 : 0;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE dashboard_bullets SET is_completed = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmt->execute([$isCompleted, $bulletId, $user['id']]);
|
||||||
|
|
||||||
|
// Als er een gekoppelde taak is, werk de status daarvan ook bij
|
||||||
|
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtCheck->fetchColumn();
|
||||||
|
|
||||||
|
if ($taskId > 0) {
|
||||||
|
$taskStatus = $isCompleted ? 'DONE' : 'TODO';
|
||||||
|
$stmtTask = $db->prepare("UPDATE tasks SET is_completed = ?, status = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$isCompleted, $taskStatus, $taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'is_completed' => $isCompleted]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1g. Bullet notitie bewerken
|
||||||
|
if ($action === 'update_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$content = trim($data['content'] ?? '');
|
||||||
|
|
||||||
|
if ($content === '') {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Inhoud mag niet leeg zijn']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE dashboard_bullets SET content = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmt->execute([$content, $bulletId, $user['id']]);
|
||||||
|
|
||||||
|
// Indien gekoppeld aan taak, werk ook taaktitel bij
|
||||||
|
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtCheck->fetchColumn();
|
||||||
|
if ($taskId > 0) {
|
||||||
|
$stmtTask = $db->prepare("UPDATE tasks SET title = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$content, $taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1h. Bullet notitie verwijderen
|
||||||
|
if ($action === 'delete_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$deleteTask = !empty($data['delete_task']);
|
||||||
|
|
||||||
|
// Check voor gekoppelde taak
|
||||||
|
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtCheck->fetchColumn();
|
||||||
|
|
||||||
|
if ($taskId > 0 && $deleteTask) {
|
||||||
|
// Verwijder eventuele Outlook afspraak
|
||||||
|
$stmtTask = $db->prepare("SELECT external_id FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$taskId, $user['id']]);
|
||||||
|
$externalId = $stmtTask->fetchColumn();
|
||||||
|
if (!empty($externalId) && function_exists('microsoft_delete_event')) {
|
||||||
|
try {
|
||||||
|
microsoft_delete_event($user['id'], $externalId);
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
$stmtDelTask = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtDelTask->execute([$taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("DELETE FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmt->execute([$bulletId, $user['id']]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1i. Bullet inplannen in agenda / vrij blok
|
||||||
|
if ($action === 'schedule_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$date = !empty($data['date']) ? trim($data['date']) : $today;
|
||||||
|
$title = !empty($data['title']) ? trim($data['title']) : '';
|
||||||
|
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : '';
|
||||||
|
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : '';
|
||||||
|
$duration = max(5, (int)($data['duration_minutes'] ?? 30));
|
||||||
|
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
|
||||||
|
$syncOutlook = !empty($data['sync_to_outlook']);
|
||||||
|
|
||||||
|
// Haal bullet op
|
||||||
|
$stmtB = $db->prepare("SELECT * FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtB->execute([$bulletId, $user['id']]);
|
||||||
|
$bullet = $stmtB->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$bullet) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Notitie niet gevonden']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$title = $bullet['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($startTime)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Kies een starttijd voor het agendablok']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($endTime)) {
|
||||||
|
$dtEnd = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||||
|
$dtEnd->modify("+{$duration} minutes");
|
||||||
|
$endTime = $dtEnd->format('H:i');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zorg dat daily_plan bestaat
|
||||||
|
$stmtPlan = $db->prepare("
|
||||||
|
INSERT INTO daily_plans (user_id, plan_date)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
|
||||||
|
");
|
||||||
|
$stmtPlan->execute([$user['id'], $date]);
|
||||||
|
$planId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
$source = 'FOCIZ';
|
||||||
|
$externalId = null;
|
||||||
|
$externalUrl = null;
|
||||||
|
$outlookSyncMsg = null;
|
||||||
|
|
||||||
|
// Microsoft Outlook synchronisatie
|
||||||
|
if ($syncOutlook) {
|
||||||
|
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||||
|
try {
|
||||||
|
$msResult = microsoft_create_event($user['id'], [
|
||||||
|
'subject' => $title,
|
||||||
|
'description' => "Ingepland vanuit Todo's & Notities",
|
||||||
|
'date' => $date,
|
||||||
|
'start_time' => $startTime,
|
||||||
|
'end_time' => $endTime,
|
||||||
|
'duration_minutes' => $duration,
|
||||||
|
]);
|
||||||
|
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||||
|
$source = 'OUTLOOK';
|
||||||
|
$externalId = $msResult['event_id'];
|
||||||
|
$externalUrl = $msResult['web_link'] ?? null;
|
||||||
|
$outlookSyncMsg = 'Succesvol gesynchroniseerd met Microsoft Outlook agenda.';
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskId = (int)($bullet['scheduled_task_id'] ?? 0);
|
||||||
|
if ($taskId > 0) {
|
||||||
|
// Update bestaande taak
|
||||||
|
$stmtUpdateTask = $db->prepare("
|
||||||
|
UPDATE tasks
|
||||||
|
SET daily_plan_id = ?, title = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?, source = ?, external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url)
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
");
|
||||||
|
$stmtUpdateTask->execute([$planId, $title, $priority, $startTime, $endTime, $duration, $source, $externalId, $externalUrl, $taskId, $user['id']]);
|
||||||
|
} else {
|
||||||
|
// Nieuwe taak aanmaken
|
||||||
|
$stmtInsertTask = $db->prepare("
|
||||||
|
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||||
|
VALUES (?, ?, ?, 'Ingepland vanuit Todo\'s & Notities', ?, ?, ?, ?, 'TODO', ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmtInsertTask->execute([$user['id'], $planId, $title, $priority, $startTime, $endTime, $duration, $source, $externalId, $externalUrl]);
|
||||||
|
$taskId = (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update de bullet record
|
||||||
|
$stmtUpdateB = $db->prepare("
|
||||||
|
UPDATE dashboard_bullets
|
||||||
|
SET scheduled_task_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
");
|
||||||
|
$stmtUpdateB->execute([$taskId, $date, $startTime, $endTime, $bulletId, $user['id']]);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'task_id' => $taskId,
|
||||||
|
'scheduled_date' => $date,
|
||||||
|
'scheduled_start_time' => $startTime,
|
||||||
|
'scheduled_end_time' => $endTime,
|
||||||
|
'outlook_message' => $outlookSyncMsg
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1j. Bullet uit agenda halen
|
||||||
|
if ($action === 'unschedule_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
|
||||||
|
$stmtB = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtB->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtB->fetchColumn();
|
||||||
|
|
||||||
|
if ($taskId > 0) {
|
||||||
|
$stmtTask = $db->prepare("SELECT external_id FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$taskId, $user['id']]);
|
||||||
|
$externalId = $stmtTask->fetchColumn();
|
||||||
|
if (!empty($externalId) && function_exists('microsoft_delete_event')) {
|
||||||
|
try {
|
||||||
|
microsoft_delete_event($user['id'], $externalId);
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
$stmtDel = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtDel->execute([$taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtUpdate = $db->prepare("
|
||||||
|
UPDATE dashboard_bullets
|
||||||
|
SET scheduled_task_id = NULL, scheduled_date = NULL, scheduled_start_time = NULL, scheduled_end_time = NULL
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
");
|
||||||
|
$stmtUpdate->execute([$bulletId, $user['id']]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1k. Openstaande bullets van eerdere dagen overnemen naar geselecteerde dag
|
||||||
|
if ($action === 'carryover_bullets') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$targetDate = !empty($data['target_date']) ? trim($data['target_date']) : $today;
|
||||||
|
|
||||||
|
// Verplaats alle openstaande notities van eerdere dagen naar targetDate
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE dashboard_bullets
|
||||||
|
SET plan_date = ?
|
||||||
|
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
|
||||||
|
");
|
||||||
|
$stmt->execute([$targetDate, $user['id'], $targetDate]);
|
||||||
|
$movedCount = $stmt->rowCount();
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'moved_count' => $movedCount]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1l. Haal alle beschikbare vrije agendablokken op voor een specifieke dag
|
||||||
|
if ($action === 'get_day_free_slots') {
|
||||||
|
$targetDate = $_GET['date'] ?? $today;
|
||||||
|
$duration = max(5, (int)($_GET['duration_minutes'] ?? 30));
|
||||||
|
|
||||||
|
$workdaySettings = get_workday_settings();
|
||||||
|
$workdayStartStr = $workdaySettings['workday_start'] ?? '08:30';
|
||||||
|
$workdayEndStr = $workdaySettings['workday_end'] ?? '17:00';
|
||||||
|
$lunchStartStr = $workdaySettings['lunch_start'] ?? '12:30';
|
||||||
|
$lunchEndStr = $workdaySettings['lunch_end'] ?? '13:00';
|
||||||
|
|
||||||
|
$toMins = function(string $timeStr): int {
|
||||||
|
$parts = explode(':', trim($timeStr));
|
||||||
|
return ((int)($parts[0] ?? 0) * 60) + (int)($parts[1] ?? 0);
|
||||||
|
};
|
||||||
|
$toTimeStr = function(int $mins): string {
|
||||||
|
$h = (int)floor($mins / 60);
|
||||||
|
$m = $mins % 60;
|
||||||
|
return sprintf('%02d:%02d', $h, $m);
|
||||||
|
};
|
||||||
|
|
||||||
|
$workdayStartMin = $toMins($workdayStartStr);
|
||||||
|
$workdayEndMin = $toMins($workdayEndStr);
|
||||||
|
$lunchStartMin = $toMins($lunchStartStr);
|
||||||
|
$lunchEndMin = $toMins($lunchEndStr);
|
||||||
|
|
||||||
|
$tz = new DateTimeZone('Europe/Amsterdam');
|
||||||
|
$now = new DateTime('now', $tz);
|
||||||
|
$isToday = ($targetDate === $now->format('Y-m-d'));
|
||||||
|
|
||||||
|
// Starttijd afronden naar kwartier
|
||||||
|
$earliestMin = $workdayStartMin;
|
||||||
|
if ($isToday) {
|
||||||
|
$nowMin = ((int)$now->format('G') * 60) + (int)$now->format('i');
|
||||||
|
if ($nowMin % 15 !== 0) {
|
||||||
|
$nowMin = (int)(ceil($nowMin / 15) * 15);
|
||||||
|
}
|
||||||
|
$earliestMin = max($workdayStartMin, $nowMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
$busyIntervals = [];
|
||||||
|
if ($lunchEndMin > $lunchStartMin) {
|
||||||
|
$busyIntervals[] = [$lunchStartMin, $lunchEndMin];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken op deze dag
|
||||||
|
$stmtTasks = $db->prepare("
|
||||||
|
SELECT time_block_start, time_block_end, duration_minutes
|
||||||
|
FROM tasks t
|
||||||
|
INNER JOIN daily_plans dp ON t.daily_plan_id = dp.id
|
||||||
|
WHERE t.user_id = ? AND dp.plan_date = ? AND t.time_block_start IS NOT NULL AND t.time_block_start != '' AND t.status != 'CANCELLED'
|
||||||
|
");
|
||||||
|
$stmtTasks->execute([$user['id'], $targetDate]);
|
||||||
|
while ($t = $stmtTasks->fetch()) {
|
||||||
|
$tStart = $toMins($t['time_block_start']);
|
||||||
|
$tDur = (int)($t['duration_minutes'] ?: 30);
|
||||||
|
$tEnd = !empty($t['time_block_end']) ? $toMins($t['time_block_end']) : ($tStart + $tDur);
|
||||||
|
if ($tEnd > $tStart) {
|
||||||
|
$busyIntervals[] = [$tStart, $tEnd];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outlook afspraken
|
||||||
|
if (function_exists('microsoft_fetch_events')) {
|
||||||
|
try {
|
||||||
|
$outlookEvents = microsoft_fetch_events($user['id'], $targetDate, false);
|
||||||
|
foreach ($outlookEvents as $oe) {
|
||||||
|
if (!empty($oe['is_all_day'])) continue;
|
||||||
|
if (empty($oe['start']) || empty($oe['end'])) continue;
|
||||||
|
$oStart = $toMins($oe['start']);
|
||||||
|
$oEnd = $toMins($oe['end']);
|
||||||
|
if ($oEnd > $oStart) {
|
||||||
|
$busyIntervals[] = [$oStart, $oEnd];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$freeSlots = [];
|
||||||
|
for ($slotStart = $earliestMin; ($slotStart + $duration) <= $workdayEndMin; $slotStart += 15) {
|
||||||
|
$slotEnd = $slotStart + $duration;
|
||||||
|
$collision = false;
|
||||||
|
foreach ($busyIntervals as [$bStart, $bEnd]) {
|
||||||
|
if ($slotStart < $bEnd && $slotEnd > $bStart) {
|
||||||
|
$collision = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$collision) {
|
||||||
|
$freeSlots[] = [
|
||||||
|
'start' => $toTimeStr($slotStart),
|
||||||
|
'end' => $toTimeStr($slotEnd),
|
||||||
|
'label' => $toTimeStr($slotStart) . ' - ' . $toTimeStr($slotEnd)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'date' => $targetDate,
|
||||||
|
'duration_minutes' => $duration,
|
||||||
|
'free_slots' => $freeSlots
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Taak toevoegen
|
// 2. Taak toevoegen
|
||||||
if ($action === 'add_task') {
|
if ($action === 'add_task') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -139,18 +553,28 @@ try {
|
|||||||
$externalUrl = !empty($data['external_url']) ? trim($data['external_url']) : null;
|
$externalUrl = !empty($data['external_url']) ? trim($data['external_url']) : null;
|
||||||
$outlookSyncError = null;
|
$outlookSyncError = null;
|
||||||
|
|
||||||
// Als synchronisatie naar Outlook gewenst is (of source is OUTLOOK) en nog geen extern ID heeft
|
// Controleer of de gebruiker Outlook 365 heeft gekoppeld
|
||||||
if (($syncOutlook || $source === 'OUTLOOK') && empty($externalId)) {
|
$userHasOutlook = false;
|
||||||
// Als er geen starttijd is ingevuld maar wel sync gewenst is, gebruik 09:00 als veilige standaard
|
if (function_exists('microsoft_create_event')) {
|
||||||
if (empty($timeBlockStart)) {
|
$stmtChk = $db->prepare("SELECT id FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL LIMIT 1");
|
||||||
$timeBlockStart = '09:00';
|
$stmtChk->execute([$user['id']]);
|
||||||
|
$userHasOutlook = (bool)$stmtChk->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatisch synchroniseren naar Outlook indien gevraagd OF indien gebruiker Outlook heeft gekoppeld
|
||||||
|
if (($syncOutlook || $source === 'OUTLOOK' || $userHasOutlook) && empty($externalId)) {
|
||||||
|
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||||
|
|
||||||
|
$itemDesc = !empty($data['description']) ? trim((string)$data['description']) : "Gepland via Fociz Dagplanning ({$priority})";
|
||||||
|
if ($source === 'JIRA' && !empty($data['external_url'])) {
|
||||||
|
$itemDesc .= "\n\nJira: " . $data['external_url'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$msResult = microsoft_create_event($user['id'], [
|
$msResult = microsoft_create_event($user['id'], [
|
||||||
'subject' => $title,
|
'subject' => $title,
|
||||||
'description' => "Gepland via Fociz Dagplanning ({$priority})",
|
'description' => $itemDesc,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'start_time' => $timeBlockStart,
|
'start_time' => $syncStartTime,
|
||||||
'end_time' => $timeBlockEnd,
|
'end_time' => $timeBlockEnd,
|
||||||
'duration_minutes' => $durationMinutes,
|
'duration_minutes' => $durationMinutes,
|
||||||
]);
|
]);
|
||||||
@@ -158,8 +582,8 @@ try {
|
|||||||
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||||
$source = 'OUTLOOK';
|
$source = 'OUTLOOK';
|
||||||
$externalId = $msResult['event_id'];
|
$externalId = $msResult['event_id'];
|
||||||
$externalUrl = $msResult['web_link'] ?? 'https://outlook.office.com';
|
$externalUrl = $msResult['web_link'] ?? ($externalUrl ?: 'https://outlook.office.com');
|
||||||
$timeBlockStart = $msResult['start_time'] ?? $timeBlockStart;
|
$timeBlockStart = $msResult['start_time'] ?? $syncStartTime;
|
||||||
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
|
||||||
} else {
|
} else {
|
||||||
$outlookSyncError = $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook';
|
$outlookSyncError = $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook';
|
||||||
@@ -172,6 +596,31 @@ try {
|
|||||||
$stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl]);
|
$stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl]);
|
||||||
$taskId = (int)$db->lastInsertId();
|
$taskId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
// Optioneel toevoegen aan Bord en Tijdlijn
|
||||||
|
$boardSyncResult = null;
|
||||||
|
$addToBoard = !empty($data['add_to_board']) && !empty($data['board_id']);
|
||||||
|
if ($addToBoard) {
|
||||||
|
$boardId = (int)$data['board_id'];
|
||||||
|
$laneId = (int)($data['lane_id'] ?? 0);
|
||||||
|
$scheduleOnTimeline = !empty($data['schedule_on_timeline']);
|
||||||
|
$timelineStartDate = !empty($data['timeline_start_date']) ? trim($data['timeline_start_date']) : $date;
|
||||||
|
$timelineEndDate = !empty($data['timeline_end_date']) ? trim($data['timeline_end_date']) : $timelineStartDate;
|
||||||
|
|
||||||
|
$boardSyncResult = fociz_add_item_to_board_and_timeline($db, (int)$user['id'], ($user['role'] === 'ADMIN'), [
|
||||||
|
'board_id' => $boardId,
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'title' => $title,
|
||||||
|
'description' => (!empty($data['description']) ? trim((string)$data['description']) : "") . (!empty($externalUrl) ? "\n\nReferentie: " . $externalUrl : ""),
|
||||||
|
'priority' => $priority,
|
||||||
|
'color' => !empty($data['board_color']) ? $data['board_color'] : 'blue',
|
||||||
|
'schedule_on_timeline' => $scheduleOnTimeline,
|
||||||
|
'start_date' => $timelineStartDate,
|
||||||
|
'end_date' => $timelineEndDate,
|
||||||
|
'start_time' => $timeBlockStart ?: '09:00',
|
||||||
|
'end_time' => $timeBlockEnd ?: '10:00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'task' => [
|
'task' => [
|
||||||
@@ -189,6 +638,7 @@ try {
|
|||||||
],
|
],
|
||||||
'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)),
|
'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)),
|
||||||
'outlook_error' => $outlookSyncError,
|
'outlook_error' => $outlookSyncError,
|
||||||
|
'board_sync' => $boardSyncResult,
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -257,10 +707,22 @@ try {
|
|||||||
$planDate = $stmtPlan->fetchColumn() ?: date('Y-m-d');
|
$planDate = $stmtPlan->fetchColumn() ?: date('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Controleer of de gebruiker Outlook 365 heeft gekoppeld
|
||||||
|
$userHasOutlook = false;
|
||||||
|
if (function_exists('microsoft_create_event')) {
|
||||||
|
$stmtChk = $db->prepare("SELECT id FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL LIMIT 1");
|
||||||
|
$stmtChk->execute([$user['id']]);
|
||||||
|
$userHasOutlook = (bool)$stmtChk->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $existingTask['source'] ?? 'FOCIZ';
|
||||||
|
$externalId = $existingTask['external_id'] ?? null;
|
||||||
|
$externalUrl = $existingTask['external_url'] ?? null;
|
||||||
|
|
||||||
// Indien gekoppeld aan Outlook, werk afspraak bij via Graph API
|
// Indien gekoppeld aan Outlook, werk afspraak bij via Graph API
|
||||||
if (!empty($existingTask['external_id']) && $existingTask['source'] === 'OUTLOOK') {
|
if (!empty($externalId) && $source === 'OUTLOOK') {
|
||||||
try {
|
try {
|
||||||
microsoft_update_event($user['id'], $existingTask['external_id'], [
|
microsoft_update_event($user['id'], $externalId, [
|
||||||
'subject' => $title,
|
'subject' => $title,
|
||||||
'date' => $planDate,
|
'date' => $planDate,
|
||||||
'start_time' => $timeBlockStart ?: '09:00',
|
'start_time' => $timeBlockStart ?: '09:00',
|
||||||
@@ -269,6 +731,32 @@ try {
|
|||||||
} catch (Throwable $t) {
|
} catch (Throwable $t) {
|
||||||
error_log("Fout bij bijwerken Outlook event: " . $t->getMessage());
|
error_log("Fout bij bijwerken Outlook event: " . $t->getMessage());
|
||||||
}
|
}
|
||||||
|
} elseif ($userHasOutlook && empty($externalId) && function_exists('microsoft_create_event')) {
|
||||||
|
// Nog niet in Outlook gesynchroniseerd, maar gebruiker heeft Outlook: maak afspraak aan
|
||||||
|
try {
|
||||||
|
$syncStartTime = !empty($timeBlockStart) ? $timeBlockStart : '09:00';
|
||||||
|
$itemDesc = !empty($existingTask['description']) ? trim((string)$existingTask['description']) : "Gepland via Fociz Dagplanning ({$priority})";
|
||||||
|
if ($source === 'JIRA' && !empty($externalUrl)) {
|
||||||
|
$itemDesc .= "\n\nJira: " . $externalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msResult = microsoft_create_event($user['id'], [
|
||||||
|
'subject' => $title,
|
||||||
|
'description' => $itemDesc,
|
||||||
|
'date' => $planDate,
|
||||||
|
'start_time' => $syncStartTime,
|
||||||
|
'end_time' => $timeBlockEnd,
|
||||||
|
'duration_minutes' => $durationMinutes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||||
|
$source = 'OUTLOOK';
|
||||||
|
$externalId = $msResult['event_id'];
|
||||||
|
$externalUrl = $msResult['web_link'] ?? ($externalUrl ?: 'https://outlook.office.com');
|
||||||
|
}
|
||||||
|
} catch (Throwable $t) {
|
||||||
|
error_log("Fout bij auto-create Outlook event in update_task: " . $t->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$description = isset($data['description']) ? trim((string)$data['description']) : ($existingTask['description'] ?? null);
|
$description = isset($data['description']) ? trim((string)$data['description']) : ($existingTask['description'] ?? null);
|
||||||
@@ -276,17 +764,42 @@ try {
|
|||||||
if ($newPlanId) {
|
if ($newPlanId) {
|
||||||
$stmtUpdate = $db->prepare("
|
$stmtUpdate = $db->prepare("
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET title = ?, description = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?, daily_plan_id = ?
|
SET title = ?, description = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?, source = ?, external_id = ?, external_url = ?, daily_plan_id = ?
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
");
|
");
|
||||||
$stmtUpdate->execute([$title, $description, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $newPlanId, $taskId, $user['id']]);
|
$stmtUpdate->execute([$title, $description, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl, $newPlanId, $taskId, $user['id']]);
|
||||||
} else {
|
} else {
|
||||||
$stmtUpdate = $db->prepare("
|
$stmtUpdate = $db->prepare("
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET title = ?, description = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
|
SET title = ?, description = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?, source = ?, external_id = ?, external_url = ?
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
");
|
");
|
||||||
$stmtUpdate->execute([$title, $description, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $taskId, $user['id']]);
|
$stmtUpdate->execute([$title, $description, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl, $taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optioneel toevoegen aan Bord en Tijdlijn bij herplannen
|
||||||
|
$boardSyncResult = null;
|
||||||
|
$addToBoard = !empty($data['add_to_board']) && !empty($data['board_id']);
|
||||||
|
if ($addToBoard) {
|
||||||
|
$boardId = (int)$data['board_id'];
|
||||||
|
$laneId = (int)($data['lane_id'] ?? 0);
|
||||||
|
$scheduleOnTimeline = !empty($data['schedule_on_timeline']);
|
||||||
|
$timelineStartDate = !empty($data['timeline_start_date']) ? trim($data['timeline_start_date']) : $planDate;
|
||||||
|
$timelineEndDate = !empty($data['timeline_end_date']) ? trim($data['timeline_end_date']) : $timelineStartDate;
|
||||||
|
|
||||||
|
$boardSyncResult = fociz_add_item_to_board_and_timeline($db, (int)$user['id'], ($user['role'] === 'ADMIN'), [
|
||||||
|
'board_id' => $boardId,
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'title' => $title,
|
||||||
|
'description' => (!empty($description) ? trim((string)$description) : "") . (!empty($externalUrl) ? "\n\nReferentie: " . $externalUrl : ""),
|
||||||
|
'priority' => $priority,
|
||||||
|
'color' => !empty($data['board_color']) ? $data['board_color'] : 'blue',
|
||||||
|
'schedule_on_timeline' => $scheduleOnTimeline,
|
||||||
|
'start_date' => $timelineStartDate,
|
||||||
|
'end_date' => $timelineEndDate,
|
||||||
|
'start_time' => $timeBlockStart ?: '09:00',
|
||||||
|
'end_time' => $timeBlockEnd ?: '10:00',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
@@ -294,7 +807,8 @@ try {
|
|||||||
'time_block_start' => $timeBlockStart,
|
'time_block_start' => $timeBlockStart,
|
||||||
'time_block_end' => $timeBlockEnd,
|
'time_block_end' => $timeBlockEnd,
|
||||||
'duration_minutes' => $durationMinutes,
|
'duration_minutes' => $durationMinutes,
|
||||||
'date' => $planDate
|
'date' => $planDate,
|
||||||
|
'board_sync' => $boardSyncResult,
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-9
@@ -49,11 +49,40 @@ $step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
|
|||||||
|
|
||||||
// Terug naar stap 1
|
// Terug naar stap 1
|
||||||
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
||||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name'], $_SESSION['pending_new_ip']);
|
unset(
|
||||||
|
$_SESSION['pending_2fa_user_id'],
|
||||||
|
$_SESSION['pending_2fa_user_email'],
|
||||||
|
$_SESSION['pending_2fa_user_name'],
|
||||||
|
$_SESSION['pending_new_ip'],
|
||||||
|
$_SESSION['pending_new_ip_mail_sent']
|
||||||
|
);
|
||||||
header('Location: /auth/login.php');
|
header('Location: /auth/login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificatiemail opnieuw verzenden bij nieuw IP
|
||||||
|
if (isset($_GET['action']) && $_GET['action'] === 'resend_ip_mail' && !empty($_SESSION['pending_2fa_user_id']) && !empty($_SESSION['pending_new_ip'])) {
|
||||||
|
$resendUserId = (int)$_SESSION['pending_2fa_user_id'];
|
||||||
|
$resendRateKey = "resend_ip_mail_{$resendUserId}";
|
||||||
|
$resendLimit = fociz_check_rate_limit($resendRateKey, 2, 60);
|
||||||
|
|
||||||
|
if (!$resendLimit['allowed']) {
|
||||||
|
$error = "Wacht even voordat u opnieuw een e-mail aanvraagt (over " . ceil($resendLimit['retry_after']) . " seconden).";
|
||||||
|
} else {
|
||||||
|
fociz_hit_rate_limit($resendRateKey, 60);
|
||||||
|
$resendEmail = $_SESSION['pending_2fa_user_email'] ?? '';
|
||||||
|
$resendName = $_SESSION['pending_2fa_user_name'] ?? '';
|
||||||
|
$resendIp = $_SESSION['pending_new_ip'];
|
||||||
|
$sent = fociz_send_new_ip_verification($resendUserId, $resendEmail, $resendName, $resendIp);
|
||||||
|
if ($sent) {
|
||||||
|
$infoMessage = "Er is een nieuwe verificatielink verstuurd naar " . htmlspecialchars($resendEmail) . ".";
|
||||||
|
$_SESSION['pending_new_ip_mail_sent'] = true;
|
||||||
|
} else {
|
||||||
|
$error = "De mailserver kon de e-mail niet direct afleveren. U kunt echter direct hieronder inloggen met uw 2FA Authenticator code om deze locatie te autoriseren.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST Verwerking
|
// POST Verwerking
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$db = get_db_connection();
|
$db = get_db_connection();
|
||||||
@@ -87,15 +116,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
||||||
|
|
||||||
if (!$isIpKnown) {
|
if (!$isIpKnown) {
|
||||||
// Stuur verificatie-mail en informeer gebruiker
|
// Stuur verificatie-mail en log audit challenge
|
||||||
fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
|
$mailSent = fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
|
||||||
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp], 'PENDING', $user['email']);
|
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
|
||||||
$error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan.";
|
|
||||||
} else {
|
// Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
|
||||||
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
|
// Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
|
||||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||||
|
$_SESSION['pending_new_ip'] = $clientIp;
|
||||||
|
$_SESSION['pending_new_ip_mail_sent'] = $mailSent;
|
||||||
|
$step = 2;
|
||||||
|
} else {
|
||||||
|
// Bekende locatie: direct door naar 2FA
|
||||||
|
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||||
|
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||||
|
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||||
|
unset($_SESSION['pending_new_ip'], $_SESSION['pending_new_ip_mail_sent']);
|
||||||
$step = 2;
|
$step = 2;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -161,8 +199,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
fociz_clear_rate_limit($ipRateKey);
|
fociz_clear_rate_limit($ipRateKey);
|
||||||
|
|
||||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
||||||
|
// Autoriseert het IP-adres definitief in user_known_ips
|
||||||
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
||||||
|
|
||||||
|
$wasNewIp = !empty($_SESSION['pending_new_ip']);
|
||||||
|
if ($wasNewIp) {
|
||||||
|
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_VERIFIED_VIA_2FA', 'user_known_ips', (int)$user['id'], ['ip' => $clientIp], 'SUCCESS', $user['email']);
|
||||||
|
}
|
||||||
|
|
||||||
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
||||||
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
||||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
@@ -189,9 +233,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||||
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
||||||
|
|
||||||
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA'], 'SUCCESS', $user['email']);
|
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
|
||||||
|
|
||||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
|
unset(
|
||||||
|
$_SESSION['pending_2fa_user_id'],
|
||||||
|
$_SESSION['pending_2fa_user_email'],
|
||||||
|
$_SESSION['pending_2fa_user_name'],
|
||||||
|
$_SESSION['pending_new_ip'],
|
||||||
|
$_SESSION['pending_new_ip_mail_sent']
|
||||||
|
);
|
||||||
|
|
||||||
header('Location: /planner.php');
|
header('Location: /planner.php');
|
||||||
exit;
|
exit;
|
||||||
@@ -315,6 +365,29 @@ $pageTitle = "Fociz — Inloggen";
|
|||||||
<!-- STAP 2: 2FA TOTP Code -->
|
<!-- STAP 2: 2FA TOTP Code -->
|
||||||
<form method="POST" action="/auth/login.php" class="space-y-5">
|
<form method="POST" action="/auth/login.php" class="space-y-5">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['pending_new_ip'])): ?>
|
||||||
|
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-900 dark:text-amber-200 text-xs space-y-2 text-left">
|
||||||
|
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
|
||||||
|
<i data-lucide="shield-alert" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
<span>Nieuwe inloglocatie gedetecteerd</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300/90">
|
||||||
|
U logt in vanaf een nieuw netwerkadres (<code class="font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded text-[10px]"><?= htmlspecialchars($_SESSION['pending_new_ip']) ?></code>).
|
||||||
|
Voer uw 2FA Authenticator code in om uw identiteit te bevestigen en deze locatie automatisch te autoriseren.
|
||||||
|
</p>
|
||||||
|
<div class="pt-1.5 flex items-center justify-between text-[11px] border-t border-amber-200/60 dark:border-amber-800/40 text-amber-700 dark:text-amber-400">
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<i data-lucide="mail-check" class="w-3.5 h-3.5"></i>
|
||||||
|
<span><?= !empty($_SESSION['pending_new_ip_mail_sent']) ? 'Verificatiemail verzonden' : '2FA verificatie actief' ?></span>
|
||||||
|
</span>
|
||||||
|
<a href="/auth/login.php?action=resend_ip_mail" class="font-bold underline hover:text-amber-900 dark:hover:text-amber-200 cursor-pointer">
|
||||||
|
Mail opnieuw sturen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
|
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
|
||||||
<i data-lucide="key" class="w-6 h-6"></i>
|
<i data-lucide="key" class="w-6 h-6"></i>
|
||||||
|
|||||||
@@ -22,54 +22,61 @@ $isAdmin = ($userRole === 'ADMIN');
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Realistische Echte Post-it Sticky Note Styling (Conform referentie linksboven) --- */
|
/* --- Strakke, Moderne & Professionele Post-it Sticky Note Styling --- */
|
||||||
.postit-note {
|
.postit-note {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 2px 2px 0 2px;
|
border-radius: 6px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 3px rgba(0, 0, 0, 0.05),
|
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||||
0 6px 14px -2px rgba(0, 0, 0, 0.10),
|
0 4px 10px -2px rgba(0, 0, 0, 0.08),
|
||||||
0 16px 26px -6px rgba(0, 0, 0, 0.12),
|
0 12px 20px -4px rgba(0, 0, 0, 0.06);
|
||||||
6px 14px 18px -4px rgba(0, 0, 0, 0.15);
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
.postit-note:hover {
|
|
||||||
transform: translateY(-3px) rotate(-0.5deg);
|
|
||||||
box-shadow:
|
|
||||||
0 3px 6px rgba(0, 0, 0, 0.08),
|
|
||||||
0 10px 20px -3px rgba(0, 0, 0, 0.14),
|
|
||||||
0 22px 32px -6px rgba(0, 0, 0, 0.16),
|
|
||||||
8px 18px 22px -4px rgba(0, 0, 0, 0.20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* De echte opkrullende flap rechtsonder rechtstreeks uit de referentieafbeelding */
|
.postit-note:hover {
|
||||||
.postit-note::after {
|
transform: translateY(-3px);
|
||||||
|
box-shadow:
|
||||||
|
0 3px 6px rgba(0, 0, 0, 0.06),
|
||||||
|
0 8px 18px -2px rgba(0, 0, 0, 0.12),
|
||||||
|
0 16px 28px -4px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strakke, semi-transparante plakstrip (tape) bovenaan het briefje */
|
||||||
|
.postit-note::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -18px;
|
top: -6px;
|
||||||
right: -16px;
|
left: 50%;
|
||||||
width: 60px;
|
transform: translateX(-50%);
|
||||||
height: 52px;
|
width: 54px;
|
||||||
background-image: url('/assets/images/postit_curl_corner.png');
|
height: 13px;
|
||||||
background-size: contain;
|
background: rgba(255, 255, 255, 0.65);
|
||||||
background-repeat: no-repeat;
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
background-position: bottom right;
|
backdrop-filter: blur(2px);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3D Rode Punaise (Pushpin) linksboven rechtstreeks uit de referentieafbeelding */
|
.dark .postit-note::before {
|
||||||
.postit-pin {
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtiele plakrand glans bovenaan de post-it zelf */
|
||||||
|
.postit-note::after {
|
||||||
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -15px;
|
top: 0;
|
||||||
left: 18px;
|
left: 0;
|
||||||
width: 36px;
|
right: 0;
|
||||||
height: 31px;
|
height: 4px;
|
||||||
background-image: url('/assets/images/postit_pin.png');
|
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04), transparent);
|
||||||
background-size: contain;
|
border-top-left-radius: 6px;
|
||||||
background-repeat: no-repeat;
|
border-top-right-radius: 6px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 25;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -94,9 +101,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
|||||||
Beheerder
|
Beheerder
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="boardDescriptionText" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p id="boardDescriptionText" class="hidden text-xs text-slate-500 dark:text-slate-400 mt-0.5"></p>
|
||||||
Teamtaken, opdrachten & Outlook agendablokkering
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,10 +132,10 @@ $isAdmin = ($userRole === 'ADMIN');
|
|||||||
<!-- Filter: Prioriteit -->
|
<!-- Filter: Prioriteit -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select id="filterPriority" onchange="renderCards()" class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium focus:ring-2 focus:ring-indigo-500/30 cursor-pointer shadow-2xs">
|
<select id="filterPriority" onchange="renderCards()" class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium focus:ring-2 focus:ring-indigo-500/30 cursor-pointer shadow-2xs">
|
||||||
<option value="ALL">Alle prioriteiten</option>
|
<option value="ALL">Prioriteiten (Alle)</option>
|
||||||
<option value="P1">P1 — Urgent</option>
|
<option value="P1">P1 Urgent</option>
|
||||||
<option value="P2">P2 — Hoog</option>
|
<option value="P2">P2 Hoog</option>
|
||||||
<option value="P3">P3 — Normaal</option>
|
<option value="P3">P3 Normaal</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -630,7 +635,14 @@ async function loadBoardData(boardId, showLoader = true) {
|
|||||||
currentBoardData = data;
|
currentBoardData = data;
|
||||||
|
|
||||||
// Titels en beheerknop
|
// Titels en beheerknop
|
||||||
document.getElementById('boardDescriptionText').innerText = data.board.description || 'Geen toelichting';
|
const descEl = document.getElementById('boardDescriptionText');
|
||||||
|
if (data.board.description && data.board.description.trim() !== '') {
|
||||||
|
descEl.innerText = data.board.description;
|
||||||
|
descEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
descEl.innerText = '';
|
||||||
|
descEl.classList.add('hidden');
|
||||||
|
}
|
||||||
const btnManage = document.getElementById('btnManageBoard');
|
const btnManage = document.getElementById('btnManageBoard');
|
||||||
const roleBadge = document.getElementById('boardRoleBadge');
|
const roleBadge = document.getElementById('boardRoleBadge');
|
||||||
|
|
||||||
@@ -842,9 +854,6 @@ function renderCards() {
|
|||||||
oncontextmenu="openBoardCardContextMenu(event, ${card.id}); return false;"
|
oncontextmenu="openBoardCardContextMenu(event, ${card.id}); return false;"
|
||||||
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5 postit-note"
|
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5 postit-note"
|
||||||
>
|
>
|
||||||
<!-- Realistische Rode Punaise rechtstreeks uit referentieafbeelding -->
|
|
||||||
<div class="postit-pin" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-1.5 mb-1.5">
|
<div class="flex items-start justify-between gap-1.5 mb-1.5">
|
||||||
<h4 class="text-xs font-bold leading-snug line-clamp-2 break-words">
|
<h4 class="text-xs font-bold leading-snug line-clamp-2 break-words">
|
||||||
${escapeHtml(card.title)}
|
${escapeHtml(card.title)}
|
||||||
|
|||||||
+180
-26
@@ -19,7 +19,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
|||||||
$theme = $firstActive ? $firstActive['name'] : 'Product & Strategie';
|
$theme = $firstActive ? $firstActive['name'] : 'Product & Strategie';
|
||||||
}
|
}
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
$autoAi = !empty($_POST['auto_ai']) ? 1 : 0;
|
||||||
|
|
||||||
if (!empty($title)) {
|
if (!empty($title)) {
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
@@ -28,7 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
|||||||
");
|
");
|
||||||
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
|
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
|
||||||
$newId = $db->lastInsertId();
|
$newId = $db->lastInsertId();
|
||||||
header("Location: /brainstorm_canvas.php?id={$newId}");
|
$redirectUrl = "/brainstorm_canvas.php?id={$newId}" . ($autoAi ? "&auto_ai=1" : "");
|
||||||
|
header("Location: {$redirectUrl}");
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$error = "Titel van de brainstormsessie is verplicht.";
|
$error = "Titel van de brainstormsessie is verplicht.";
|
||||||
@@ -95,9 +96,11 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($sessions as $sess): ?>
|
<?php foreach ($sessions as $sess):
|
||||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group fociz-card-interactive p-5 flex flex-col justify-between hover:border-indigo-400 dark:hover:border-indigo-500/60">
|
$canDelete = ($currentUser['role'] === 'ADMIN' || (int)$sess['created_by_id'] === (int)$currentUser['id']);
|
||||||
<div>
|
?>
|
||||||
|
<div class="group fociz-card-interactive p-5 flex flex-col justify-between hover:border-indigo-400 dark:hover:border-indigo-500/60 relative">
|
||||||
|
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="block flex-1">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-400 font-semibold text-[11px]">
|
<span class="px-2.5 py-0.5 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-400 font-semibold text-[11px]">
|
||||||
<?= htmlspecialchars($sess['theme']) ?>
|
<?= htmlspecialchars($sess['theme']) ?>
|
||||||
@@ -117,29 +120,46 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
<?= htmlspecialchars($sess['description']) ?>
|
<?= htmlspecialchars($sess['description']) ?>
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<div class="mt-5 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
<div class="mt-5 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span class="truncate">Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
<span class="truncate">Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
||||||
<div class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold flex-shrink-0 ml-2">
|
<div class="flex items-center space-x-2 flex-shrink-0 ml-2">
|
||||||
|
<?php if ($canDelete): ?>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="promptDeleteSession(event, <?= (int)$sess['id'] ?>, '<?= htmlspecialchars(addslashes($sess['title']), ENT_QUOTES) ?>', <?= (int)$sess['cards_count'] ?>)"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer"
|
||||||
|
title="Sessie verwijderen"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold">
|
||||||
<span>Open canvas</span>
|
<span>Open canvas</span>
|
||||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
|
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Nieuwe sessie -->
|
<!-- Modal: Nieuwe sessie -->
|
||||||
<div id="newSessionModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
<div id="newSessionModal" onclick="if(event.target === this) this.classList.add('hidden')" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
|
||||||
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
|
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-xl w-full my-6 space-y-4">
|
||||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
<div class="flex items-center space-x-2.5">
|
||||||
<i data-lucide="sparkles" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
<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 border border-indigo-100 dark:border-indigo-800/60">
|
||||||
<span>Nieuwe Brainstormsessie</span>
|
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||||
</h3>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Nieuwe Brainstormsessie</h3>
|
||||||
|
<p class="text-[11px] text-slate-500 dark:text-slate-400">Creëer een canvas en voed de AI Co-Pilot met achtergrondinformatie</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||||
<i data-lucide="x" class="w-4 h-4"></i>
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -148,14 +168,15 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
<form method="POST" action="/brainstorm.php" class="space-y-4">
|
<form method="POST" action="/brainstorm.php" class="space-y-4">
|
||||||
<input type="hidden" name="action" value="create_session">
|
<input type="hidden" name="action" value="create_session">
|
||||||
|
|
||||||
<div>
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Onderwerp / Titel</label>
|
<div class="sm:col-span-2">
|
||||||
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
Onderwerp / Titel <span class="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="title" required placeholder="Bijv. Modernisering Klantportaal & Self-service" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs font-semibold">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Categorie</label>
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
|
||||||
<select name="category" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
<select name="category" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||||
<?php
|
<?php
|
||||||
$hasActiveCategory = false;
|
$hasActiveCategory = false;
|
||||||
@@ -174,15 +195,51 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Doel / Beschrijving</label>
|
|
||||||
<input type="text" name="description" placeholder="Doel van deze sessie..." class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||||
|
Uitgebreide Briefing, Context & Doelstellingen
|
||||||
|
</label>
|
||||||
|
<span class="text-[10px] text-indigo-600 dark:text-indigo-400 font-medium">Voeding voor AI post-its</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Voer hier alle achtergrondinformatie, context en doelen in waar de AI post-its van gaat maken. Bijvoorbeeld: • Achtergrond: Klanten klagen over lange wachttijden bij onboarding. • Doel: Binnen 3 maanden de doorlooptijd met 50% verkorten. • Bekende knelpunten: Handmatige validatie, ontbrekende API-koppeling met CRM. • Gewenste richting: Self-service portaal, automatische validatie, duidelijke statusupdates."
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs leading-relaxed resize-y font-normal"
|
||||||
|
></textarea>
|
||||||
|
<p class="text-[11px] text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Tip: Hoe gedetailleerder de informatie, hoe gerichter en waardevoller de ideeën, knelpunten en oplossingen die AI genereert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Direct Genereren Optie -->
|
||||||
|
<div class="p-3.5 rounded-xl bg-violet-50/70 dark:bg-violet-950/25 border border-violet-200/80 dark:border-violet-800/40 flex items-start space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="auto_ai_checkbox"
|
||||||
|
name="auto_ai"
|
||||||
|
value="1"
|
||||||
|
checked
|
||||||
|
class="w-4 h-4 mt-0.5 rounded text-violet-600 focus:ring-0 border-violet-300 dark:border-violet-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
<label for="auto_ai_checkbox" class="text-xs cursor-pointer select-none">
|
||||||
|
<span class="font-bold text-violet-900 dark:text-violet-200 block flex items-center space-x-1.5">
|
||||||
|
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-violet-600 dark:text-violet-400"></i>
|
||||||
|
<span>Direct AI post-its genereren op basis van deze sessiebeschrijving</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-violet-700/80 dark:text-violet-300/80 block mt-0.5 leading-snug">
|
||||||
|
AI Co-Pilot analyseert de ingevoerde context direct bij het openen en maakt automatisch gerichte post-its aan voor Ideeën, Knelpunten, Oplossingen en Quick Wins.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||||
<button type="submit" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
|
<button type="submit" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-2">
|
||||||
Sessie Aanmaken & Canvas Openen
|
<i data-lucide="layout-grid" class="w-4 h-4"></i>
|
||||||
|
<span>Sessie Aanmaken & Canvas Openen</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||||
Annuleren
|
Annuleren
|
||||||
@@ -192,11 +249,108 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Sessie Verwijderen Bevestiging -->
|
||||||
|
<div id="deleteSessionModal" onclick="if(event.target === this) closeDeleteSessionModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-md w-full space-y-4">
|
||||||
|
<div class="flex items-start space-x-3.5 pb-2">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i data-lucide="alert-triangle" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-slate-900 dark:text-white">Brainstormsessie verwijderen</h3>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Weet u zeker dat u deze sessie wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3.5 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/80 dark:border-rose-900/40 text-xs text-rose-800 dark:text-rose-300 space-y-1">
|
||||||
|
<p class="font-bold text-slate-900 dark:text-white" id="deleteSessionTitle">—</p>
|
||||||
|
<p id="deleteSessionWarning" class="text-[11px] text-rose-700 dark:text-rose-400">
|
||||||
|
Alle bijbehorende ideeën, knelpunten en kaarten worden definitief gewist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||||
|
<button type="button" onclick="closeDeleteSessionModal()" class="flex-1 px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btnConfirmDeleteSession" onclick="executeDeleteSession()" class="flex-1 px-4 py-2.5 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
<span>Verwijderen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let sessionToDeleteId = null;
|
||||||
|
|
||||||
|
function promptDeleteSession(e, sessionId, sessionTitle, cardsCount) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
sessionToDeleteId = sessionId;
|
||||||
|
document.getElementById('deleteSessionTitle').textContent = sessionTitle || 'Brainstormsessie';
|
||||||
|
const warningEl = document.getElementById('deleteSessionWarning');
|
||||||
|
if (cardsCount > 0) {
|
||||||
|
warningEl.textContent = `Let op: Deze sessie bevat ${cardsCount} brainstormkaart(en). Alle kaarten worden definitief verwijderd.`;
|
||||||
|
} else {
|
||||||
|
warningEl.textContent = 'Alle inhoud van deze sessie wordt definitief gewist.';
|
||||||
|
}
|
||||||
|
const modal = document.getElementById('deleteSessionModal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteSessionModal() {
|
||||||
|
sessionToDeleteId = null;
|
||||||
|
const modal = document.getElementById('deleteSessionModal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteSession() {
|
||||||
|
if (!sessionToDeleteId) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('btnConfirmDeleteSession');
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Verwijderen...</span>';
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/brainstorm.php?action=delete_session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionToDeleteId })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
closeDeleteSessionModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fout bij verwijderen: ' + (data.error || 'Onbekende fout'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Netwerkfout bij verwijderen: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
const modal = document.getElementById('newSessionModal');
|
const modal = document.getElementById('newSessionModal');
|
||||||
if (modal) modal.classList.add('hidden');
|
if (modal) modal.classList.add('hidden');
|
||||||
|
closeDeleteSessionModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+308
-61
@@ -87,54 +87,62 @@ $categories = [
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* --- Realistische Echte Post-it Sticky Note Styling (Conform referentie linksboven) --- */
|
/* --- Strakke, Moderne & Professionele Post-it Sticky Note Styling --- */
|
||||||
.postit-note {
|
.postit-note {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 2px 2px 0 2px;
|
border-radius: 6px;
|
||||||
|
/* Natuurlijke, strakke papieren diepteschaduw */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 3px rgba(0, 0, 0, 0.05),
|
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||||
0 6px 14px -2px rgba(0, 0, 0, 0.10),
|
0 4px 10px -2px rgba(0, 0, 0, 0.08),
|
||||||
0 16px 26px -6px rgba(0, 0, 0, 0.12),
|
0 12px 20px -4px rgba(0, 0, 0, 0.06);
|
||||||
6px 14px 18px -4px rgba(0, 0, 0, 0.15);
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
.postit-note:hover {
|
|
||||||
transform: translateY(-3px) rotate(-0.5deg);
|
|
||||||
box-shadow:
|
|
||||||
0 3px 6px rgba(0, 0, 0, 0.08),
|
|
||||||
0 10px 20px -3px rgba(0, 0, 0, 0.14),
|
|
||||||
0 22px 32px -6px rgba(0, 0, 0, 0.16),
|
|
||||||
8px 18px 22px -4px rgba(0, 0, 0, 0.20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* De echte opkrullende flap rechtsonder rechtstreeks uit de referentieafbeelding */
|
.postit-note:hover {
|
||||||
.postit-note::after {
|
transform: translateY(-3px);
|
||||||
|
box-shadow:
|
||||||
|
0 3px 6px rgba(0, 0, 0, 0.06),
|
||||||
|
0 8px 18px -2px rgba(0, 0, 0, 0.12),
|
||||||
|
0 16px 28px -4px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strakke, semi-transparante plakstrip (tape) bovenaan het briefje */
|
||||||
|
.postit-note::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -18px;
|
top: -6px;
|
||||||
right: -16px;
|
left: 50%;
|
||||||
width: 64px;
|
transform: translateX(-50%);
|
||||||
height: 56px;
|
width: 60px;
|
||||||
background-image: url('/assets/images/postit_curl_corner.png');
|
height: 14px;
|
||||||
background-size: contain;
|
background: rgba(255, 255, 255, 0.65);
|
||||||
background-repeat: no-repeat;
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
background-position: bottom right;
|
backdrop-filter: blur(2px);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3D Rode Punaise (Pushpin) linksboven rechtstreeks uit de referentieafbeelding */
|
.dark .postit-note::before {
|
||||||
.postit-pin {
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtiele plakrand glans bovenaan de post-it zelf */
|
||||||
|
.postit-note::after {
|
||||||
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -16px;
|
top: 0;
|
||||||
left: 22px;
|
left: 0;
|
||||||
width: 38px;
|
right: 0;
|
||||||
height: 33px;
|
height: 4px;
|
||||||
background-image: url('/assets/images/postit_pin.png');
|
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04), transparent);
|
||||||
background-size: contain;
|
border-top-left-radius: 6px;
|
||||||
background-repeat: no-repeat;
|
border-top-right-radius: 6px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 25;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -160,14 +168,14 @@ $categories = [
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2.5">
|
<div class="flex flex-wrap items-center gap-2.5">
|
||||||
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
|
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
|
||||||
<button onclick="aiGenerateIdeas(this)" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl bg-violet-50 hover:bg-violet-100 dark:bg-violet-950/50 dark:hover:bg-violet-900/50 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-800/60 text-xs font-bold transition cursor-pointer shadow-xs" title="Genereer 4 innovatieve ideeën via AI Co-Pilot">
|
<button onclick="aiGenerateSessionBoard(this)" class="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold transition cursor-pointer shadow-xs" title="Genereer een compleet whiteboard (Ideeën, Knelpunten, Oplossingen en Quick Wins) op basis van de sessie briefing">
|
||||||
<i data-lucide="sparkles" class="w-4 h-4 text-violet-500"></i>
|
<i data-lucide="sparkles" class="w-4 h-4 text-violet-200"></i>
|
||||||
<span>💡 AI Ideeën</span>
|
<span>AI Post-its Genereren</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onclick="aiCategorizeCards(this)" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/50 dark:hover:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60 text-xs font-bold transition cursor-pointer shadow-xs" title="Sorteer alle kaartjes automatisch in Ideeën, Knelpunten, Oplossingen of Quick Wins">
|
<button onclick="aiCategorizeCards(this)" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/50 dark:hover:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60 text-xs font-bold transition cursor-pointer shadow-xs" title="Sorteer alle kaartjes automatisch in Ideeën, Knelpunten, Oplossingen of Quick Wins">
|
||||||
<i data-lucide="wand-2" class="w-4 h-4 text-indigo-500"></i>
|
<i data-lucide="wand-2" class="w-4 h-4 text-indigo-500"></i>
|
||||||
<span>✨ Auto-Categoriseer</span>
|
<span>Categoriseren</span>
|
||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -181,9 +189,41 @@ $categories = [
|
|||||||
<i data-lucide="map" class="w-4 h-4"></i>
|
<i data-lucide="map" class="w-4 h-4"></i>
|
||||||
<span>Zet om naar Roadmap</span>
|
<span>Zet om naar Roadmap</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<?php if (($currentUser['role'] ?? '') === 'ADMIN' || (int)$session['created_by_id'] === (int)$userId): ?>
|
||||||
|
<button onclick="promptDeleteCurrentCanvasSession()" class="p-2 rounded-xl text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 border border-transparent hover:border-rose-200 dark:hover:border-rose-900/40 transition cursor-pointer" title="Brainstormsessie verwijderen">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sessie Context & Briefing Banner -->
|
||||||
|
<?php if (!empty($session['description'])): ?>
|
||||||
|
<div class="fociz-card p-4 sm:p-5 border-l-4 border-l-indigo-500 relative overflow-hidden transition">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1.5 flex-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 flex items-center space-x-1.5">
|
||||||
|
<i data-lucide="file-text" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Sessie Briefing & Doelstellingen</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-slate-400 dark:text-slate-500">• Context voor AI & Team</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-line leading-relaxed font-normal">
|
||||||
|
<?= htmlspecialchars($session['description']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
|
||||||
|
<button onclick="aiGenerateSessionBoard(this)" class="flex-shrink-0 px-3 py-2 rounded-xl bg-violet-50 hover:bg-violet-100 dark:bg-violet-950/40 dark:hover:bg-violet-900/40 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-800/60 text-xs font-bold transition cursor-pointer flex items-center space-x-1.5 shadow-2xs">
|
||||||
|
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-violet-500"></i>
|
||||||
|
<span>AI Post-its Maken</span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Whiteboard Categorie Kolommen -->
|
<!-- Whiteboard Categorie Kolommen -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5">
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5">
|
||||||
<?php foreach ($categories as $catKey => $catConfig):
|
<?php foreach ($categories as $catKey => $catConfig):
|
||||||
@@ -237,9 +277,6 @@ $categories = [
|
|||||||
oncontextmenu="openCardContextMenu(event, <?= $cardJson ?>); return false;"
|
oncontextmenu="openCardContextMenu(event, <?= $cardJson ?>); return false;"
|
||||||
class="brainstorm-card postit-note group relative p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> transition-all duration-150 cursor-grab active:cursor-grabbing hover:-translate-y-1 flex flex-col justify-between"
|
class="brainstorm-card postit-note group relative p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> transition-all duration-150 cursor-grab active:cursor-grabbing hover:-translate-y-1 flex flex-col justify-between"
|
||||||
>
|
>
|
||||||
<!-- Echte Rode Punaise rechtstreeks uit referentieafbeelding -->
|
|
||||||
<div class="postit-pin" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<!-- Top: Checkbox voor selectie + Roadmap badge -->
|
<!-- Top: Checkbox voor selectie + Roadmap badge -->
|
||||||
<div class="flex items-center justify-between gap-2 mb-2">
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
<label class="inline-flex items-center space-x-2 cursor-pointer select-none">
|
<label class="inline-flex items-center space-x-2 cursor-pointer select-none">
|
||||||
@@ -781,6 +818,40 @@ $categories = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Sessie Verwijderen Bevestiging -->
|
||||||
|
<div id="deleteCanvasSessionModal" onclick="if(event.target === this) closeDeleteCanvasSessionModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-md w-full space-y-4">
|
||||||
|
<div class="flex items-start space-x-3.5 pb-2">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i data-lucide="alert-triangle" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-slate-900 dark:text-white">Brainstormsessie verwijderen</h3>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Weet u zeker dat u deze sessie wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3.5 rounded-xl bg-rose-50/60 dark:bg-rose-950/20 border border-rose-200/80 dark:border-rose-900/40 text-xs text-rose-800 dark:text-rose-300 space-y-1">
|
||||||
|
<p class="font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($session['title']) ?></p>
|
||||||
|
<p class="text-[11px] text-rose-700 dark:text-rose-400">
|
||||||
|
Let op: Alle <?= count($cards) ?> brainstormkaart(en) en bijbehorende inhoud worden definitief verwijderd.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||||
|
<button type="button" onclick="closeDeleteCanvasSessionModal()" class="flex-1 px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btnConfirmDeleteCanvasSession" onclick="executeDeleteCanvasSession()" class="flex-1 px-4 py-2.5 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
<span>Verwijderen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const sessionId = <?= $sessionId ?>;
|
const sessionId = <?= $sessionId ?>;
|
||||||
|
|
||||||
@@ -1255,13 +1326,27 @@ async function submitConvertToRoadmap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
|
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
|
||||||
async function aiGenerateIdeas(btn) {
|
async function aiGenerateSessionBoard(btn, silent = false, attempt = 1, maxAttempts = 3) {
|
||||||
if (!confirm('Wil je AI Co-Pilot 4 innovatieve ideeën laten genereren en direct op het canvas plaatsen?')) return;
|
if (!silent && attempt === 1) {
|
||||||
|
const hasBriefing = <?= !empty($session['description']) ? 'true' : 'false' ?>;
|
||||||
|
const confirmMsg = hasBriefing
|
||||||
|
? 'Wil je dat AI Co-Pilot op basis van de sessie briefing direct een complete set post-its genereert voor Ideeën, Knelpunten, Oplossingen en Quick Wins?'
|
||||||
|
: 'Wil je dat AI Co-Pilot innovatieve post-its genereert voor het whiteboard?';
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origHtml = btn ? (btn.dataset.origHtml || btn.innerHTML) : '';
|
||||||
|
if (btn && !btn.dataset.origHtml) {
|
||||||
|
btn.dataset.origHtml = origHtml;
|
||||||
|
}
|
||||||
|
|
||||||
const origHtml = btn ? btn.innerHTML : '';
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Genereren...</span></span>';
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
if (attempt > 1) {
|
||||||
|
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Piekdrukte, wachtrij (${attempt}/${maxAttempts})...</span></span>`;
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
|
||||||
|
}
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,29 +1354,46 @@ async function aiGenerateIdeas(btn) {
|
|||||||
const res = await fetch('/api/ai.php?action=generate_ideas', {
|
const res = await fetch('/api/ai.php?action=generate_ideas', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ session_id: sessionId, count: 4 })
|
body: JSON.stringify({ session_id: sessionId, count: 4, full_board: 1 })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
// Verwijder auto_ai parameter uit URL bij reload
|
||||||
|
const cleanUrl = window.location.pathname + '?id=' + sessionId;
|
||||||
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || 'Kon geen ideeën genereren via AI');
|
const err = (data.error || '').toLowerCase();
|
||||||
|
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
|
||||||
|
if (attempt < maxAttempts && isOverloaded) {
|
||||||
|
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Kon geen post-its genereren via AI');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = btn.dataset.origHtml || origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
alert('Netwerkfout bij aanroepen van AI');
|
if (attempt < maxAttempts) {
|
||||||
|
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('Netwerkfout bij aanroepen van AI: ' + err.message);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = btn.dataset.origHtml || origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function aiGenerateIdeas(btn) {
|
||||||
|
return aiGenerateSessionBoard(btn);
|
||||||
|
}
|
||||||
|
|
||||||
async function aiCategorizeCards(btn) {
|
async function aiCategorizeCards(btn) {
|
||||||
if (!confirm('Wil je dat AI Co-Pilot alle kaartjes automatisch classificeert in de juiste kolom (Ideeën, Knelpunten, Oplossingen of Quick Wins)?')) return;
|
if (!confirm('Wil je dat AI Co-Pilot alle kaartjes automatisch classificeert in de juiste kolom (Ideeën, Knelpunten, Oplossingen of Quick Wins)?')) return;
|
||||||
|
|
||||||
@@ -1337,8 +1439,13 @@ async function aiSuggestRoadmapMeta(btn) {
|
|||||||
if (!firstContent) return;
|
if (!firstContent) return;
|
||||||
|
|
||||||
const origHtml = btn ? btn.innerHTML : '';
|
const origHtml = btn ? btn.innerHTML : '';
|
||||||
|
let attempt = 0;
|
||||||
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
const executeRequest = async () => {
|
||||||
|
attempt++;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Analyseren...</span></span>';
|
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>${attempt > 1 ? `Wachtrij (${attempt}/${maxAttempts})...` : 'Analyseren...'}</span></span>`;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
@@ -1364,7 +1471,7 @@ async function aiSuggestRoadmapMeta(btn) {
|
|||||||
document.getElementById('roadmapPriority').value = data.item.priority;
|
document.getElementById('roadmapPriority').value = data.item.priority;
|
||||||
}
|
}
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld!</span>';
|
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>' + (data.is_fallback ? 'Ingevuld (Standaard)' : 'Ingevuld!') + '</span>';
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = origHtml;
|
||||||
@@ -1373,15 +1480,36 @@ async function aiSuggestRoadmapMeta(btn) {
|
|||||||
}, 1800);
|
}, 1800);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || 'Kon geen AI advies ophalen');
|
const err = (data.error || '').toLowerCase();
|
||||||
|
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
|
||||||
|
|
||||||
|
if (attempt < maxAttempts && isOverloaded) {
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="clock" class="w-3.5 h-3.5"></i><span>Piekdrukte, wachtrij...</span></span>`;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
setTimeout(executeRequest, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: vul standaardwaarden in zodat het omzetten altijd kan doorgaan
|
||||||
|
if (document.getElementById('roadmapQuarter') && !document.getElementById('roadmapQuarter').value) {
|
||||||
|
document.getElementById('roadmapQuarter').value = 'Q3';
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld (Standaard)</span>';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
setTimeout(() => {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}, 1800);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
alert('Netwerkfout bij aanroepen van AI');
|
if (attempt < maxAttempts) {
|
||||||
|
setTimeout(executeRequest, 2000);
|
||||||
|
} else {
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -1389,6 +1517,10 @@ async function aiSuggestRoadmapMeta(btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeRequest();
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// AI IDEE UITWERKEN & EXPORTEREN LOGICA
|
// AI IDEE UITWERKEN & EXPORTEREN LOGICA
|
||||||
@@ -1451,7 +1583,16 @@ async function regenerateBreakdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAiBreakdown(content) {
|
async function fetchAiBreakdown(content, attempt = 1, maxAttempts = 3) {
|
||||||
|
const loadingEl = document.getElementById('breakdownLoading');
|
||||||
|
const loadingText = loadingEl ? loadingEl.querySelector('p') : null;
|
||||||
|
|
||||||
|
if (attempt > 1 && loadingText) {
|
||||||
|
loadingText.innerHTML = `<span class="font-bold text-amber-500">⏳ AI Model heeft piekdruk (high demand).</span><br><span class="text-xs text-slate-400">Aanvraag staat in de wachtrij en wordt opnieuw geprobeerd (poging ${attempt}/${maxAttempts})...</span>`;
|
||||||
|
} else if (loadingText) {
|
||||||
|
loadingText.innerHTML = 'AI ontleedt het idee in behapbare taken...';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai.php?action=breakdown_idea', {
|
const res = await fetch('/api/ai.php?action=breakdown_idea', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1467,12 +1608,48 @@ async function fetchAiBreakdown(content) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!data.success || !data.items || data.items.length === 0) {
|
if (!data.success || !data.items || data.items.length === 0) {
|
||||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
const err = (data.error || '').toLowerCase();
|
||||||
document.getElementById('breakdownError').classList.remove('hidden');
|
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
|
||||||
document.getElementById('breakdownErrorMsg').innerText = data.error || 'Geen taken gegenereerd door AI.';
|
|
||||||
|
if (attempt < maxAttempts && isOverloaded) {
|
||||||
|
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graceful Fallback: zorg dat het omzetten van het idee altijd vlekkeloos doorgaat
|
||||||
|
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
|
||||||
|
data = {
|
||||||
|
success: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Onderzoek & specificatie: ' + cleanTitle,
|
||||||
|
description: 'Randvoorwaarden en functionele specificaties vastleggen.',
|
||||||
|
priority: 'P1',
|
||||||
|
estimated_minutes: 45,
|
||||||
|
color: 'indigo',
|
||||||
|
category: 'Onderzoek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Realisatie & uitwerking: ' + cleanTitle,
|
||||||
|
description: 'Implementatie en concrete uitvoering van het idee.',
|
||||||
|
priority: 'P2',
|
||||||
|
estimated_minutes: 90,
|
||||||
|
color: 'blue',
|
||||||
|
category: 'Frontend'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle,
|
||||||
|
description: 'Testen, feedback verwerken en afronden.',
|
||||||
|
priority: 'P2',
|
||||||
|
estimated_minutes: 30,
|
||||||
|
color: 'emerald',
|
||||||
|
category: 'Testing'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
summary: 'AI model had tijdelijke piekdruk; standaard deeltaken zijn direct klaargezet zodat je meteen kunt doorwerken.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
currentBreakdownItems = data.items.map((it, idx) => ({
|
currentBreakdownItems = data.items.map((it, idx) => ({
|
||||||
id: idx + 1,
|
id: idx + 1,
|
||||||
selected: true,
|
selected: true,
|
||||||
@@ -1492,9 +1669,21 @@ async function fetchAiBreakdown(content) {
|
|||||||
document.getElementById('breakdownResult').classList.remove('hidden');
|
document.getElementById('breakdownResult').classList.remove('hidden');
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
|
||||||
|
currentBreakdownItems = [
|
||||||
|
{ id: 1, selected: true, title: 'Onderzoek & specificatie: ' + cleanTitle, description: 'Randvoorwaarden en specificaties vastleggen.', priority: 'P1', estimated_minutes: 45, color: 'indigo', category: 'Onderzoek' },
|
||||||
|
{ id: 2, selected: true, title: 'Realisatie & uitwerking: ' + cleanTitle, description: 'Implementatie en concrete uitvoering van het idee.', priority: 'P2', estimated_minutes: 90, color: 'blue', category: 'Frontend' },
|
||||||
|
{ id: 3, selected: true, title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle, description: 'Testen, feedback verwerken en afronden.', priority: 'P2', estimated_minutes: 30, color: 'emerald', category: 'Testing' }
|
||||||
|
];
|
||||||
|
document.getElementById('breakdownSummaryText').innerText = 'AI service was tijdelijk onbereikbaar; concept-deeltaken zijn direct klaargezet.';
|
||||||
|
renderBreakdownTasks();
|
||||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
document.getElementById('breakdownLoading').classList.add('hidden');
|
||||||
document.getElementById('breakdownError').classList.remove('hidden');
|
document.getElementById('breakdownResult').classList.remove('hidden');
|
||||||
document.getElementById('breakdownErrorMsg').innerText = 'Netwerkfout bij communicatie met AI: ' + err.message;
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,12 +1929,70 @@ async function submitBreakdownExport() {
|
|||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function promptDeleteCurrentCanvasSession() {
|
||||||
|
const modal = document.getElementById('deleteCanvasSessionModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteCanvasSessionModal() {
|
||||||
|
const modal = document.getElementById('deleteCanvasSessionModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteCanvasSession() {
|
||||||
|
const btn = document.getElementById('btnConfirmDeleteCanvasSession');
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Verwijderen...</span>';
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/brainstorm.php?action=delete_session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: <?= (int)$sessionId ?> })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = '/brainstorm.php';
|
||||||
|
} else {
|
||||||
|
alert('Fout bij verwijderen: ' + (data.error || 'Onbekende fout'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Netwerkfout bij verwijderen: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeAddCardModal();
|
closeAddCardModal();
|
||||||
closeConvertModal();
|
closeConvertModal();
|
||||||
closeAiBreakdownModal();
|
closeAiBreakdownModal();
|
||||||
|
closeDeleteCanvasSessionModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatische AI-generatie bij doorgestuurde auto_ai=1 parameter (bijv. direct na creatie)
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.get('auto_ai') === '1') {
|
||||||
|
// Toon subtiele toast of indicator en start generatie
|
||||||
|
const aiBtn = document.querySelector('button[onclick*="aiGenerateSessionBoard"]');
|
||||||
|
aiGenerateSessionBoard(aiBtn, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.4",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"ext-pdo_mysql": "*",
|
"ext-pdo_mysql": "*",
|
||||||
"ext-mysqli": "*"
|
"ext-mysqli": "*"
|
||||||
|
|||||||
+9
-12
@@ -1,17 +1,14 @@
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# FOCIZ CONFIGURATIE — LIVE PRODUCTIE (www.fociz.nl)
|
# FOCIZ CONFIGURATIE (Centraal)
|
||||||
|
# De omgeving en database worden automatisch veilig bepaald op basis van het domein:
|
||||||
|
# - dev.fociz.nl / localhost => development (focizdev / focizdev_user)
|
||||||
|
# - www.fociz.nl / fociz.nl => live (fociz / focizlive_user)
|
||||||
|
# Optionele serverspecifieke overrides kunnen in de root /.env worden geplaatst.
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
# MariaDB Verbinding
|
# MariaDB Server
|
||||||
DB_HOST="192.168.2.33"
|
DB_HOST="192.168.2.33"
|
||||||
DB_PORT="3306"
|
DB_PORT="3306"
|
||||||
DB_NAME="fociz"
|
|
||||||
DB_USER="focizlive_user"
|
|
||||||
DB_PASS="F0c1z_Live_Secure_2026!"
|
|
||||||
|
|
||||||
# Omgeving & URL
|
|
||||||
APP_ENV="live"
|
|
||||||
APP_URL="https://www.fociz.nl"
|
|
||||||
|
|
||||||
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
||||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||||
@@ -26,8 +23,8 @@ SMTP_USER="info@fociz.nl"
|
|||||||
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
||||||
SMTP_SECURE="tls"
|
SMTP_SECURE="tls"
|
||||||
SMTP_FROM_EMAIL="info@fociz.nl"
|
SMTP_FROM_EMAIL="info@fociz.nl"
|
||||||
SMTP_FROM_NAME="Fociz"
|
SMTP_FROM_NAME="Fociz Dev"
|
||||||
SMTP_REPLY_TO="info@fociz.nl"
|
SMTP_REPLY_TO="info@fociz.nl"
|
||||||
|
|
||||||
# Webhooks (optioneel)
|
# Webhooks (optioneel)
|
||||||
JIRA_WEBHOOK_SECRET="fociz_webhook_live_2026"
|
JIRA_WEBHOOK_SECRET="fociz_webhook_dev_2026"
|
||||||
|
|||||||
+35
-13
@@ -25,9 +25,9 @@ if (!headers_sent()) {
|
|||||||
}
|
}
|
||||||
@ini_set('expose_php', 'off');
|
@ini_set('expose_php', 'off');
|
||||||
|
|
||||||
// 1. .env inlezing (controleert zowel config/.env als hoofdmap .env)
|
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
|
||||||
$envVars = [];
|
$envVars = [];
|
||||||
$envLocations = [__DIR__ . '/.env', __DIR__ . '/../.env'];
|
$envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
|
||||||
foreach ($envLocations as $envFile) {
|
foreach ($envLocations as $envFile) {
|
||||||
if (file_exists($envFile)) {
|
if (file_exists($envFile)) {
|
||||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
@@ -81,30 +81,52 @@ if (!function_exists('fociz_env')) {
|
|||||||
define('APP_NAME', 'Fociz');
|
define('APP_NAME', 'Fociz');
|
||||||
|
|
||||||
// Automatische omgevingsdetectie:
|
// Automatische omgevingsdetectie:
|
||||||
// 1. Expliciete .env variabele 'APP_ENV'
|
// 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
|
||||||
// 2. Automatisch 'development' indien hostname 'dev.' of 'localhost' bevat
|
$detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
|
||||||
// 3. Standaard 'live'
|
|
||||||
$detectedHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
||||||
$isDevHost = (
|
$isDevHost = (
|
||||||
str_contains($detectedHost, 'dev.') ||
|
str_contains($detectedHost, 'dev.') ||
|
||||||
str_contains($detectedHost, 'localhost') ||
|
str_contains($detectedHost, 'localhost') ||
|
||||||
str_contains($detectedHost, '127.0.0.1')
|
str_contains($detectedHost, '127.0.0.1')
|
||||||
);
|
);
|
||||||
$defaultEnv = $isDevHost ? 'development' : 'live';
|
$isLiveHost = (
|
||||||
define('APP_ENV', fociz_env('APP_ENV', $defaultEnv));
|
!empty($detectedHost) &&
|
||||||
|
!$isDevHost &&
|
||||||
|
(str_contains($detectedHost, 'fociz.nl') || str_contains($detectedHost, 'live'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bepaal APP_ENV: op live domeinen (bijv. www.fociz.nl of fociz.nl) ALTIJD 'live'
|
||||||
|
// zodat een per ongeluk meegekomen dev-configuratie NOOIT productie kan beïnvloeden.
|
||||||
|
if ($isLiveHost) {
|
||||||
|
$effectiveEnv = 'live';
|
||||||
|
} elseif ($isDevHost) {
|
||||||
|
$effectiveEnv = 'development';
|
||||||
|
} else {
|
||||||
|
$effectiveEnv = fociz_env('APP_ENV', 'live');
|
||||||
|
}
|
||||||
|
define('APP_ENV', $effectiveEnv);
|
||||||
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
|
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Leest eerst eventuele .env variabelen (die per server uniek en gitignored zijn).
|
|
||||||
// Indien niet ingesteld via .env, wordt automatisch de juiste database gekozen op basis van APP_ENV.
|
|
||||||
$isDevEnv = (APP_ENV === 'development');
|
$isDevEnv = (APP_ENV === 'development');
|
||||||
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
||||||
define('DB_PORT', fociz_env('DB_PORT', '3306'));
|
define('DB_PORT', fociz_env('DB_PORT', '3306'));
|
||||||
define('DB_NAME', fociz_env('DB_NAME', $isDevEnv ? 'focizdev' : 'fociz'));
|
|
||||||
define('DB_USER', fociz_env('DB_USER', $isDevEnv ? 'focizdev_user' : 'focizlive_user'));
|
// Veiligheidswaarborg: op het live domein of live omgeving NOOIT per ongeluk de dev database gebruiken
|
||||||
define('DB_PASS', fociz_env('DB_PASS', $isDevEnv ? 'F0c1z_Dev_Secure_2026!' : 'F0c1z_Live_Secure_2026!'));
|
if ($isLiveHost || APP_ENV === 'live') {
|
||||||
|
$rawDbName = fociz_env('DB_NAME');
|
||||||
|
$rawDbUser = fociz_env('DB_USER');
|
||||||
|
$rawDbPass = fociz_env('DB_PASS');
|
||||||
|
|
||||||
|
define('DB_NAME', (!empty($rawDbName) && $rawDbName !== 'focizdev') ? $rawDbName : 'fociz');
|
||||||
|
define('DB_USER', (!empty($rawDbUser) && $rawDbUser !== 'focizdev_user') ? $rawDbUser : 'focizlive_user');
|
||||||
|
define('DB_PASS', (!empty($rawDbPass) && $rawDbPass !== 'F0c1z_Dev_Secure_2026!') ? $rawDbPass : 'F0c1z_Live_Secure_2026!');
|
||||||
|
} else {
|
||||||
|
define('DB_NAME', fociz_env('DB_NAME', 'focizdev'));
|
||||||
|
define('DB_USER', fociz_env('DB_USER', 'focizdev_user'));
|
||||||
|
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Dev_Secure_2026!'));
|
||||||
|
}
|
||||||
define('DB_CHARSET', 'utf8mb4');
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
// Bepaal de dynamische of geconfigureerde applicatie URL
|
// Bepaal de dynamische of geconfigureerde applicatie URL
|
||||||
|
|||||||
+60
-18
@@ -126,8 +126,14 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
if ($tablesChecked) return;
|
if ($tablesChecked) return;
|
||||||
$tablesChecked = true;
|
$tablesChecked = true;
|
||||||
|
|
||||||
try {
|
|
||||||
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
||||||
|
$lockFile = sys_get_temp_dir() . '/fociz_schema_' . md5(__DIR__) . '.lock';
|
||||||
|
|
||||||
|
if (file_exists($lockFile) && file_exists($schemaFile) && filemtime($lockFile) >= filemtime($schemaFile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (file_exists($schemaFile)) {
|
if (file_exists($schemaFile)) {
|
||||||
$sql = file_get_contents($schemaFile);
|
$sql = file_get_contents($schemaFile);
|
||||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||||
@@ -162,6 +168,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",
|
||||||
|
// Jira: Meerdere wachtrijfilters per teamlid
|
||||||
|
"ALTER TABLE users ADD COLUMN default_jira_filter_codes TEXT NULL AFTER default_jira_filter_code",
|
||||||
// V2.0: Subtaken & Checklists
|
// 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",
|
"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",
|
||||||
// Beveiliging: IP tracking & locatie-verificatie
|
// Beveiliging: IP tracking & locatie-verificatie
|
||||||
@@ -182,6 +190,8 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
|
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
|
||||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
|
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
|
||||||
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
|
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
|
||||||
|
// Dashboard Snelle Bullet Notities & Todo Lijst
|
||||||
|
"CREATE TABLE IF NOT EXISTS `dashboard_bullets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `plan_date` DATE NOT NULL, `content` VARCHAR(500) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `scheduled_task_id` INT NULL, `scheduled_date` DATE NULL, `scheduled_start_time` VARCHAR(10) NULL, `scheduled_end_time` VARCHAR(10) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `plan_date`), INDEX (`scheduled_task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
];
|
];
|
||||||
foreach ($colUpgrades as $sql) {
|
foreach ($colUpgrades as $sql) {
|
||||||
try {
|
try {
|
||||||
@@ -191,31 +201,62 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zorg dat de standaard Jira Servicedesk wachtrijfilters (DX en SD) aanwezig zijn
|
// Zorg dat de standaard Jira wachtrijfilters (SD, IN, AAN, GEN, DX) aanwezig zijn
|
||||||
try {
|
try {
|
||||||
$filterCount = (int)$pdo->query("SELECT COUNT(*) FROM jira_queue_filters")->fetchColumn();
|
$existingCodes = $pdo->query("SELECT filter_code FROM jira_queue_filters")->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||||
if ($filterCount === 0) {
|
$defaultFilters = [
|
||||||
|
[
|
||||||
|
'code' => 'SD',
|
||||||
|
'name' => 'SD — Servicedesk',
|
||||||
|
'project' => 'SD',
|
||||||
|
'jql' => 'project = "SD"',
|
||||||
|
'desc' => 'Servicedesk supportvragen en operationele meldingen.',
|
||||||
|
'order' => 1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => 'IN',
|
||||||
|
'name' => 'IN — Interfaces',
|
||||||
|
'project' => 'IN',
|
||||||
|
'jql' => 'project = "IN"',
|
||||||
|
'desc' => 'Interface-koppelingen, API-endpoints en data-uitwisselingen.',
|
||||||
|
'order' => 2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => 'AAN',
|
||||||
|
'name' => 'AAN — Aanvragen',
|
||||||
|
'project' => 'AAN',
|
||||||
|
'jql' => 'project = "AAN"',
|
||||||
|
'desc' => 'Klant- en gebruikersaanvragen en wijzigingsverzoeken.',
|
||||||
|
'order' => 3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => 'GEN',
|
||||||
|
'name' => 'GEN — General',
|
||||||
|
'project' => 'GEN',
|
||||||
|
'jql' => 'project = "GEN"',
|
||||||
|
'desc' => 'Algemene taken, organisatie en platformprojecten.',
|
||||||
|
'order' => 4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => 'DX',
|
||||||
|
'name' => 'DX — Koppelingen & SSO',
|
||||||
|
'project' => 'SD',
|
||||||
|
'jql' => '"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
|
||||||
|
'desc' => 'Servicedesk tickets specifiek voor koppelingen en migraties.',
|
||||||
|
'order' => 5
|
||||||
|
]
|
||||||
|
];
|
||||||
$insertFilterStmt = $pdo->prepare("
|
$insertFilterStmt = $pdo->prepare("
|
||||||
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
|
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||||
");
|
");
|
||||||
|
foreach ($defaultFilters as $df) {
|
||||||
|
if (!in_array($df['code'], $existingCodes)) {
|
||||||
$insertFilterStmt->execute([
|
$insertFilterStmt->execute([
|
||||||
'DX',
|
$df['code'], $df['name'], $df['project'], $df['jql'], $df['desc'], $df['order']
|
||||||
'DX — Koppelingen, SSO & Migratie',
|
|
||||||
'SD',
|
|
||||||
'"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
|
|
||||||
'Servicedesk tickets met betrekking tot koppelingen, endpoints en datamigraties.',
|
|
||||||
1
|
|
||||||
]);
|
|
||||||
$insertFilterStmt->execute([
|
|
||||||
'SD',
|
|
||||||
'SD — Regulier Servicedesk',
|
|
||||||
'SD',
|
|
||||||
'("Onderwerp logging[Dropdown]" = EMPTY OR "Onderwerp logging[Dropdown]" != Koppelingen AND "Onderwerp logging[Dropdown]" != Migratie AND "Onderwerp logging[Dropdown]" != SSO-koppeling AND "Onderwerp logging[Dropdown]" != UWV-koppeling AND "Onderwerp logging[Dropdown]" != Rest-endpoints AND "Onderwerp logging[Dropdown]" != Datadump)',
|
|
||||||
'Reguliere servicedesk incidenten, algemene meldingen en gebruikersvragen.',
|
|
||||||
2
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Stil negeren indien tabel nog in opbouw is
|
// Stil negeren indien tabel nog in opbouw is
|
||||||
}
|
}
|
||||||
@@ -267,6 +308,7 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Stil negeren indien system_settings tabel nog niet bestaat
|
// Stil negeren indien system_settings tabel nog niet bestaat
|
||||||
}
|
}
|
||||||
|
@touch($lockFile);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("Fout bij controleren tabellen: " . $e->getMessage());
|
error_log("Fout bij controleren tabellen: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-21
@@ -160,30 +160,43 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
||||||
$candidateUrls = [];
|
$candidateUrls = [];
|
||||||
|
|
||||||
// Helper om endpoints op te bouwen voor v1beta en v1
|
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
|
||||||
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
||||||
$m = preg_replace('#^models/#', '', trim($m));
|
$m = preg_replace('#^models/#', '', trim($m));
|
||||||
if ($m === '') return;
|
if ($m === '') return;
|
||||||
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
||||||
$v1 = "https://generativelanguage.googleapis.com/v1/models/{$m}:generateContent";
|
|
||||||
if (!in_array($v1beta, $candidateUrls, true)) {
|
if (!in_array($v1beta, $candidateUrls, true)) {
|
||||||
$candidateUrls[] = $v1beta;
|
$candidateUrls[] = $v1beta;
|
||||||
}
|
}
|
||||||
if (!in_array($v1, $candidateUrls, true)) {
|
|
||||||
$candidateUrls[] = $v1;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Voeg het geconfigureerde model toe als eerste kandidaat (tenzij het een niet-bestaand/fictief model is)
|
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
|
||||||
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
||||||
$addCandidatesFor($cleanModel);
|
$addCandidatesFor($cleanModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voeg altijd de stabiele, snelle Google AI modellen toe als betrouwbare fallbacks/standaarden
|
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
|
||||||
$addCandidatesFor('gemini-2.0-flash');
|
$addCandidatesFor('gemini-2.0-flash');
|
||||||
$addCandidatesFor('gemini-1.5-flash');
|
$addCandidatesFor('gemini-1.5-flash');
|
||||||
$addCandidatesFor('gemini-1.5-pro');
|
|
||||||
$addCandidatesFor('gemini-2.5-flash');
|
$addCandidatesFor('gemini-2.5-flash');
|
||||||
|
$addCandidatesFor('gemini-1.5-pro');
|
||||||
|
|
||||||
|
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
|
||||||
|
$status = $res['status'] ?? 0;
|
||||||
|
if ($status === 503 || $status === 429) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$lower = strtolower($msg);
|
||||||
|
return (
|
||||||
|
str_contains($lower, 'high demand') ||
|
||||||
|
str_contains($lower, 'spikes in demand') ||
|
||||||
|
str_contains($lower, 'temporarily unavailable') ||
|
||||||
|
str_contains($lower, 'resource has been exhausted') ||
|
||||||
|
str_contains($lower, 'rate limit') ||
|
||||||
|
str_contains($lower, 'overloaded') ||
|
||||||
|
str_contains($lower, 'too many requests')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$response = null;
|
$response = null;
|
||||||
$lastError = null;
|
$lastError = null;
|
||||||
@@ -193,6 +206,9 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
for ($i = 0; $i < count($candidateUrls); $i++) {
|
for ($i = 0; $i < count($candidateUrls); $i++) {
|
||||||
$baseUrl = $candidateUrls[$i];
|
$baseUrl = $candidateUrls[$i];
|
||||||
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
||||||
|
|
||||||
|
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
|
||||||
|
for ($attempt = 1; $attempt <= 2; $attempt++) {
|
||||||
$res = fociz_http_request($endpoint, [
|
$res = fociz_http_request($endpoint, [
|
||||||
'method' => 'POST',
|
'method' => 'POST',
|
||||||
'headers' => [
|
'headers' => [
|
||||||
@@ -200,7 +216,7 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
'x-goog-api-key' => $apiKey,
|
'x-goog-api-key' => $apiKey,
|
||||||
],
|
],
|
||||||
'body' => $payload,
|
'body' => $payload,
|
||||||
'timeout' => 20,
|
'timeout' => 25,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
||||||
@@ -212,13 +228,21 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
||||||
$successfulModel = $mName[1];
|
$successfulModel = $mName[1];
|
||||||
}
|
}
|
||||||
break;
|
break 2; // Volledig succes, beëindig beide loops
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
||||||
$lastError = $msg;
|
$lastError = $msg;
|
||||||
|
|
||||||
|
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
|
||||||
|
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
|
||||||
|
usleep(800000); // 800ms
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
||||||
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
||||||
$recModel = $recMatch[1];
|
$recModel = $recMatch[1];
|
||||||
@@ -228,16 +252,28 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Als het model niet gevonden is, niet meer beschikbaar is of deprecated is, probeer de volgende kandidaat
|
// 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
|
||||||
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bij autorisatiefouten (zoals ongeldige API key 400/403) direct stoppen
|
// 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
|
||||||
|
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
|
||||||
|
if ($isHighDemandOrRateLimited($res, $msg)) {
|
||||||
|
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
|
||||||
|
if (in_array($res['status'], [400, 401, 403], true)) {
|
||||||
$response = $res;
|
$response = $res;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
||||||
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
|
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
|
||||||
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
|
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
|
||||||
@@ -549,9 +585,39 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
|
|||||||
|
|
||||||
if (!$aiRes['success']) {
|
if (!$aiRes['success']) {
|
||||||
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
||||||
|
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
|
||||||
|
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => $aiRes['error'] ?? 'Kon het idee niet ontleden in taken via AI.',
|
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||||
|
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||||
|
'estimated_minutes' => 45,
|
||||||
|
'priority' => 'P1',
|
||||||
|
'color' => 'indigo',
|
||||||
|
'category' => 'Onderzoek'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||||
|
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||||
|
'estimated_minutes' => 90,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'blue',
|
||||||
|
'category' => 'Frontend'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||||
|
'description' => "Testen, feedback verwerken en afronden.",
|
||||||
|
'estimated_minutes' => 30,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'emerald',
|
||||||
|
'category' => 'Testing'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'model' => 'Smart Fallback',
|
||||||
|
'is_fallback' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,9 +652,38 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
|
|||||||
|
|
||||||
if (empty($items)) {
|
if (empty($items)) {
|
||||||
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
||||||
|
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => 'De AI gaf een antwoord maar kon de taken niet structureren. Probeer het nogmaals.',
|
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||||
|
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||||
|
'estimated_minutes' => 45,
|
||||||
|
'priority' => 'P1',
|
||||||
|
'color' => 'indigo',
|
||||||
|
'category' => 'Onderzoek'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||||
|
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||||
|
'estimated_minutes' => 90,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'blue',
|
||||||
|
'category' => 'Frontend'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||||
|
'description' => "Testen, feedback verwerken en afronden.",
|
||||||
|
'estimated_minutes' => 30,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'emerald',
|
||||||
|
'category' => 'Testing'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'model' => 'Smart Fallback',
|
||||||
|
'is_fallback' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,24 +742,85 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
|
|||||||
/**
|
/**
|
||||||
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
||||||
*/
|
*/
|
||||||
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4, ?int $userId = null): array {
|
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4, ?int $userId = null, string $description = ''): array {
|
||||||
|
$descContext = !empty($description) ? "\nContext en briefing van de sessie:\n" . trim($description) . "\n" : "";
|
||||||
|
|
||||||
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
|
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
|
||||||
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||||
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\n"
|
. $descContext
|
||||||
|
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een post-it staan).\n"
|
||||||
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
|
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
|
||||||
|
|
||||||
$aiRes = fociz_ai_chat([
|
$aiRes = fociz_ai_chat([
|
||||||
['role' => 'system', 'content' => $systemPrompt],
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
|
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën op basis van de sessie en context."],
|
||||||
], ['json_mode' => true, 'user_id' => $userId]);
|
], ['json_mode' => true, 'user_id' => $userId]);
|
||||||
|
|
||||||
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
||||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
|
$ideas = [
|
||||||
|
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
|
||||||
|
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||||
|
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||||
|
"Feedback- en validatielus inrichten met eindgebruikers"
|
||||||
|
];
|
||||||
|
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyseert de volledige sessie briefing en genereert een complete set post-its verdeeld over:
|
||||||
|
* - Ideeën & Concepten (amber)
|
||||||
|
* - Knelpunten & Uitdagingen (rose)
|
||||||
|
* - Oplossingen & Features (teal)
|
||||||
|
* - Quick Wins (indigo)
|
||||||
|
*/
|
||||||
|
function fociz_ai_generate_session_board(string $sessionTitle, string $theme, string $description = '', ?int $userId = null): array {
|
||||||
|
$descContext = !empty($description)
|
||||||
|
? "Uitgebreide sessiebeschrijving en briefing:\n" . trim($description) . "\n\n"
|
||||||
|
: "Geen aanvullende briefing opgegeven; baseer je op de titel en het thema.\n\n";
|
||||||
|
|
||||||
|
$systemPrompt = "Je bent een ervaren agile facilitator en strategisch adviseur.\n"
|
||||||
|
. "Je analyseert de briefing van een brainstormsessie getiteld '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||||
|
. $descContext
|
||||||
|
. "Jouw taak is om de sessie direct uit te werken tot een rijk, concreet whiteboard met post-its over exact 4 categorieën:\n"
|
||||||
|
. "1. 'Ideeën': Vernieuwende concepten, functionaliteiten en strategische kansen (2 tot 4 post-its).\n"
|
||||||
|
. "2. 'Knelpunten': Concreet benoemde knelpunten, risico's, blokkades of gebruikersfrustraties (2 tot 4 post-its).\n"
|
||||||
|
. "3. 'Oplossingen': Structurele oplossingen, architectuurkeuzes of procesaanpassingen voor de knelpunten (2 tot 4 post-its).\n"
|
||||||
|
. "4. 'Quick Wins': Laagdrempelige acties die met minimale inspanning direct resultaat opleveren (2 tot 3 post-its).\n\n"
|
||||||
|
. "Regels per post-it:\n"
|
||||||
|
. "- Formuleer scherp, helder en direct to-the-point (1-2 zinnen per kaartje alsof het op een fysieke post-it staat).\n"
|
||||||
|
. "- Sluit naadloos aan op de verstrekte briefing en achtergrondinformatie.\n\n"
|
||||||
|
. "Retourneer UITSLUITEND een geldig JSON-object met het veld 'cards', waarbij elk item bestaat uit:\n"
|
||||||
|
. "- 'content': string (de tekst op de post-it)\n"
|
||||||
|
. "- 'category': exact één van ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins']";
|
||||||
|
|
||||||
|
$aiRes = fociz_ai_chat([
|
||||||
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
|
['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
|
||||||
|
], ['json_mode' => true, 'user_id' => $userId]);
|
||||||
|
|
||||||
|
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'cards' => [
|
||||||
|
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
|
||||||
|
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
|
||||||
|
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
|
||||||
|
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
|
||||||
|
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
|
||||||
|
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
|
||||||
|
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
|
||||||
|
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
|
||||||
|
],
|
||||||
|
'is_fallback' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'cards' => $aiRes['json']['cards']];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zet brainstorm kaartjes om in een scherp Roadmap Item
|
* Zet brainstorm kaartjes om in een scherp Roadmap Item
|
||||||
*/
|
*/
|
||||||
@@ -683,7 +839,25 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
|
|||||||
], ['json_mode' => true, 'user_id' => $userId]);
|
], ['json_mode' => true, 'user_id' => $userId]);
|
||||||
|
|
||||||
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
||||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
|
// Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
|
||||||
|
$lines = explode("\n", trim($content));
|
||||||
|
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
|
||||||
|
$cleanTitle = mb_substr($firstLine, 0, 75);
|
||||||
|
$currentMonth = (int)date('n');
|
||||||
|
$q = 'Q' . ceil($currentMonth / 3);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'item' => [
|
||||||
|
'title' => $cleanTitle,
|
||||||
|
'description' => $content,
|
||||||
|
'priority' => 'HIGH',
|
||||||
|
'type' => 'FEATURE',
|
||||||
|
'quarter' => $q,
|
||||||
|
],
|
||||||
|
'is_fallback' => true,
|
||||||
|
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'item' => $aiRes['json']];
|
return ['success' => true, 'item' => $aiRes['json']];
|
||||||
|
|||||||
+87
-8
@@ -129,6 +129,12 @@ function fociz_log_audit(?int $userId, string $action, string $resourceType = ''
|
|||||||
$statusNormalized,
|
$statusNormalized,
|
||||||
$createdAt
|
$createdAt
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Bewaartermijn audit logs: 3 maanden (90 dagen)
|
||||||
|
// Voer periodiek (1 op de 20 log-acties) een automatische opschoning uit
|
||||||
|
if (mt_rand(1, 20) === 1) {
|
||||||
|
$db->exec("DELETE FROM audit_logs WHERE created_at < NOW() - INTERVAL 90 DAY");
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Audit log mag NOOIT de applicatiewerking breken
|
// Audit log mag NOOIT de applicatiewerking breken
|
||||||
error_log("fociz_log_audit failure: " . $e->getMessage());
|
error_log("fociz_log_audit failure: " . $e->getMessage());
|
||||||
@@ -285,6 +291,9 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
|
$res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
|
||||||
|
if (empty($res['success'])) {
|
||||||
|
error_log("fociz_send_new_ip_verification: Mail verzenden mislukt naar {$email}: " . ($res['error'] ?? 'onbekende fout'));
|
||||||
|
}
|
||||||
return !empty($res['success']);
|
return !empty($res['success']);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
|
error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
|
||||||
@@ -341,6 +350,11 @@ function ensure_remember_token_columns(PDO $db): void {
|
|||||||
if ($columnsChecked) return;
|
if ($columnsChecked) return;
|
||||||
$columnsChecked = true;
|
$columnsChecked = true;
|
||||||
|
|
||||||
|
$lockFile = sys_get_temp_dir() . '/fociz_auth_cols_' . md5(__DIR__) . '.lock';
|
||||||
|
if (file_exists($lockFile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'");
|
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'");
|
||||||
if (!$result || $result->rowCount() === 0) {
|
if (!$result || $result->rowCount() === 0) {
|
||||||
@@ -354,6 +368,7 @@ function ensure_remember_token_columns(PDO $db): void {
|
|||||||
if (!$resActivity || $resActivity->rowCount() === 0) {
|
if (!$resActivity || $resActivity->rowCount() === 0) {
|
||||||
$db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`");
|
$db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`");
|
||||||
}
|
}
|
||||||
|
@touch($lockFile);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Stil negeren indien al aanwezig of tijdens migraties
|
// Stil negeren indien al aanwezig of tijdens migraties
|
||||||
}
|
}
|
||||||
@@ -368,6 +383,11 @@ function ensure_sessions_table(PDO $db): void {
|
|||||||
if ($sessionsTableChecked) return;
|
if ($sessionsTableChecked) return;
|
||||||
$sessionsTableChecked = true;
|
$sessionsTableChecked = true;
|
||||||
|
|
||||||
|
$lockFile = sys_get_temp_dir() . '/fociz_sessions_tbl_' . md5(__DIR__) . '.lock';
|
||||||
|
if (file_exists($lockFile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||||
@@ -388,6 +408,7 @@ function ensure_sessions_table(PDO $db): void {
|
|||||||
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
");
|
");
|
||||||
|
@touch($lockFile);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("ensure_sessions_table notice: " . $e->getMessage());
|
error_log("ensure_sessions_table notice: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -419,6 +440,36 @@ function fociz_get_device_name(?string $userAgent): string {
|
|||||||
return "{$browser} op {$platform}";
|
return "{$browser} op {$platform}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruimt verlopen sessies op en beperkt het aantal sessies per gebruiker tot maximaal 20 (FIFO / oldest out)
|
||||||
|
*/
|
||||||
|
function fociz_cleanup_user_sessions(PDO $db, int $userId): void {
|
||||||
|
try {
|
||||||
|
// 1. Markeer sessies waarvan de geldigheidsduur is verstreken direct als inactief
|
||||||
|
$stmtExp = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE user_id = ? AND is_active = 1 AND expires_at < NOW()");
|
||||||
|
$stmtExp->execute([$userId]);
|
||||||
|
|
||||||
|
// 2. Beperk tot maximaal 20 sessies per gebruiker (FIFO: behoud de 20 meest recente, ruim oudere op)
|
||||||
|
$stmtLimit = $db->prepare("
|
||||||
|
SELECT id FROM user_sessions
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY last_activity_at DESC, id DESC
|
||||||
|
LIMIT 20, 200
|
||||||
|
");
|
||||||
|
$stmtLimit->execute([$userId]);
|
||||||
|
$excessIds = $stmtLimit->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!empty($excessIds)) {
|
||||||
|
$inClause = implode(',', array_map('intval', $excessIds));
|
||||||
|
$db->exec("DELETE FROM user_sessions WHERE id IN ($inClause)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verwijder inactieve/verlopen sessies ouder dan 7 dagen over de gehele tabel
|
||||||
|
$db->exec("DELETE FROM user_sessions WHERE is_active = 0 AND (expires_at < NOW() - INTERVAL 7 DAY OR last_activity_at < NOW() - INTERVAL 7 DAY)");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("fociz_cleanup_user_sessions error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registreert of vernieuwt een actieve sessie in user_sessions (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
* Registreert of vernieuwt een actieve sessie in user_sessions (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||||
*/
|
*/
|
||||||
@@ -447,8 +498,8 @@ function fociz_register_active_session(int $userId, string $sessionId, int $maxI
|
|||||||
");
|
");
|
||||||
$stmt->execute([$userId, $hash, $ip, $ua, $deviceName, $expiresAt]);
|
$stmt->execute([$userId, $hash, $ip, $ua, $deviceName, $expiresAt]);
|
||||||
|
|
||||||
// Verwijder oude verlopen sessies (> 7 dagen geleden verlopen)
|
// Ruim verlopen sessies op en handhaaf max 20 sessies
|
||||||
$db->exec("DELETE FROM user_sessions WHERE expires_at < NOW() - INTERVAL 7 DAY");
|
fociz_cleanup_user_sessions($db, $userId);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("fociz_register_active_session error: " . $e->getMessage());
|
error_log("fociz_register_active_session error: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -505,7 +556,14 @@ function fociz_is_session_active(string $sessionId, int $userId): bool {
|
|||||||
fociz_register_active_session($userId, $sessionId, $maxIdleSeconds);
|
fociz_register_active_session($userId, $sessionId, $maxIdleSeconds);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (!empty($row['is_active']));
|
if (empty($row['is_active'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!empty($row['expires_at']) && strtotime($row['expires_at']) < time()) {
|
||||||
|
$db->prepare("UPDATE user_sessions SET is_active = 0 WHERE session_id_hash = ?")->execute([$hash]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return true; // Fallback op toegestaan bij databaseverstoring
|
return true; // Fallback op toegestaan bij databaseverstoring
|
||||||
}
|
}
|
||||||
@@ -567,7 +625,7 @@ function fociz_revoke_session_by_id(int $userId, int $sessionDbId): bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Haalt alle actieve sessies van een gebruiker op voor de profielpagina
|
* Haalt alle actieve sessies van een gebruiker op voor de profielpagina (maximaal 20, actieve expiratiedatum)
|
||||||
*/
|
*/
|
||||||
function fociz_get_user_active_sessions(int $userId, string $currentSessionId): array {
|
function fociz_get_user_active_sessions(int $userId, string $currentSessionId): array {
|
||||||
try {
|
try {
|
||||||
@@ -575,14 +633,35 @@ function fociz_get_user_active_sessions(int $userId, string $currentSessionId):
|
|||||||
ensure_sessions_table($db);
|
ensure_sessions_table($db);
|
||||||
$currentHash = hash('sha256', $currentSessionId);
|
$currentHash = hash('sha256', $currentSessionId);
|
||||||
|
|
||||||
|
// Voer direct opschoning uit: deactiveer verlopen sessies en handhaaf limiet van max 20
|
||||||
|
fociz_cleanup_user_sessions($db, $userId);
|
||||||
|
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT id, ip_address, user_agent, device_name, last_activity_at, created_at, (session_id_hash = ?) AS is_current
|
SELECT id, ip_address, user_agent, device_name, last_activity_at, created_at, (session_id_hash = ?) AS is_current
|
||||||
FROM user_sessions
|
FROM user_sessions
|
||||||
WHERE user_id = ? AND is_active = 1
|
WHERE user_id = ? AND is_active = 1 AND expires_at >= NOW()
|
||||||
ORDER BY is_current DESC, last_activity_at DESC
|
ORDER BY is_current DESC, last_activity_at DESC
|
||||||
|
LIMIT 20
|
||||||
");
|
");
|
||||||
$stmt->execute([$currentHash, $userId]);
|
$stmt->execute([$currentHash, $userId]);
|
||||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
$sessions = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
// Zorg dat het huidige apparaat altijd geregistreerd staat als actief
|
||||||
|
$hasCurrent = false;
|
||||||
|
foreach ($sessions as $s) {
|
||||||
|
if (!empty($s['is_current'])) {
|
||||||
|
$hasCurrent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasCurrent && !empty($currentSessionId)) {
|
||||||
|
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||||
|
fociz_register_active_session($userId, $currentSessionId, $maxIdleSeconds);
|
||||||
|
$stmt->execute([$currentHash, $userId]);
|
||||||
|
$sessions = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sessions;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -908,7 +987,7 @@ function get_logged_in_user(): ?array {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, created_at FROM users WHERE id = ?");
|
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, default_jira_filter_codes, fociz_widget_layout, created_at FROM users WHERE id = ?");
|
||||||
$stmt->execute([$_SESSION['user_id']]);
|
$stmt->execute([$_SESSION['user_id']]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
@@ -958,7 +1037,7 @@ function get_logged_in_user(): ?array {
|
|||||||
// 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag én mits niet inactief!)
|
// 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag én mits niet inactief!)
|
||||||
$dayToken = $_COOKIE['fociz_day_session'] ?? ($_COOKIE['fociz_remember'] ?? null);
|
$dayToken = $_COOKIE['fociz_day_session'] ?? ($_COOKIE['fociz_remember'] ?? null);
|
||||||
if (!empty($dayToken) && is_string($dayToken)) {
|
if (!empty($dayToken) && is_string($dayToken)) {
|
||||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, last_activity_at, last_login_at, created_at FROM users WHERE remember_token = ? AND remember_token_expires_at >= NOW() AND DATE(remember_token_expires_at) = ?");
|
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, default_jira_filter_codes, fociz_widget_layout, last_activity_at, last_login_at, created_at FROM users WHERE remember_token = ? AND remember_token_expires_at >= NOW() AND DATE(remember_token_expires_at) = ?");
|
||||||
$stmt->execute([$dayToken, $today]);
|
$stmt->execute([$dayToken, $today]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Fociz Board & Timeline Integratie Helper
|
||||||
|
* Biedt functies om taken / e-mails / Jira tickets direct als kaarten toe te voegen aan Kanban-borden en Project Tijdlijnen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt alle actieve borden op waar de gebruiker toegang toe heeft, inclusief kolommen (lanes).
|
||||||
|
*/
|
||||||
|
function fociz_get_user_boards_with_lanes(PDO $db, int $userId, bool $isAdmin): array {
|
||||||
|
if ($isAdmin) {
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT b.id, b.title, b.description, b.created_by_id
|
||||||
|
FROM boards b
|
||||||
|
WHERE b.is_archived = 0
|
||||||
|
ORDER BY b.id ASC
|
||||||
|
");
|
||||||
|
$boards = $stmt->fetchAll();
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT b.id, b.title, b.description, b.created_by_id
|
||||||
|
FROM boards b
|
||||||
|
JOIN board_members bm ON b.id = bm.board_id
|
||||||
|
WHERE bm.user_id = ? AND b.is_archived = 0
|
||||||
|
ORDER BY b.id ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$boards = $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($boards)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$boardIds = array_map(fn($b) => (int)$b['id'], $boards);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($boardIds), '?'));
|
||||||
|
|
||||||
|
$laneStmt = $db->prepare("
|
||||||
|
SELECT id, board_id, title, color, position
|
||||||
|
FROM board_lanes
|
||||||
|
WHERE board_id IN ({$placeholders})
|
||||||
|
ORDER BY position ASC, id ASC
|
||||||
|
");
|
||||||
|
$laneStmt->execute($boardIds);
|
||||||
|
$lanes = $laneStmt->fetchAll();
|
||||||
|
|
||||||
|
$lanesByBoard = [];
|
||||||
|
foreach ($lanes as $lane) {
|
||||||
|
$bId = (int)$lane['board_id'];
|
||||||
|
$lanesByBoard[$bId][] = $lane;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($boards as &$b) {
|
||||||
|
$bId = (int)$b['id'];
|
||||||
|
$b['lanes'] = $lanesByBoard[$bId] ?? [];
|
||||||
|
}
|
||||||
|
unset($b);
|
||||||
|
|
||||||
|
return $boards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voegt een item toe aan een Kanban bord en optioneel aan de Project Tijdlijn.
|
||||||
|
*
|
||||||
|
* @param PDO $db Database verbinding
|
||||||
|
* @param int $userId ID van de gebruiker
|
||||||
|
* @param bool $isAdmin Is gebruiker admin
|
||||||
|
* @param array $data Data array met:
|
||||||
|
* - board_id (int)
|
||||||
|
* - lane_id (int, optioneel - defaults naar 1e lane)
|
||||||
|
* - title (string)
|
||||||
|
* - description (string, optioneel)
|
||||||
|
* - priority ('P1'|'P2'|'P3', optioneel)
|
||||||
|
* - color (string, optioneel)
|
||||||
|
* - schedule_on_timeline (bool, optioneel)
|
||||||
|
* - start_date (string 'YYYY-MM-DD', optioneel)
|
||||||
|
* - end_date (string 'YYYY-MM-DD', optioneel)
|
||||||
|
* - start_time (string 'HH:MM', optioneel)
|
||||||
|
* - end_time (string 'HH:MM', optioneel)
|
||||||
|
* - sync_outlook (bool, optioneel)
|
||||||
|
* @return array ['success' => bool, 'card_id' => int, 'message' => string]
|
||||||
|
*/
|
||||||
|
function fociz_add_item_to_board_and_timeline(PDO $db, int $userId, bool $isAdmin, array $data): array {
|
||||||
|
$boardId = (int)($data['board_id'] ?? 0);
|
||||||
|
$title = trim($data['title'] ?? '');
|
||||||
|
if ($boardId <= 0 || empty($title)) {
|
||||||
|
return ['success' => false, 'error' => 'Geldig bord en titel zijn verplicht.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autorisatiecheck
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$boardId, $userId]);
|
||||||
|
if ((int)$stmtCheck->fetchColumn() === 0) {
|
||||||
|
return ['success' => false, 'error' => 'Geen toegang tot dit bord.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$laneId = (int)($data['lane_id'] ?? 0);
|
||||||
|
if ($laneId <= 0) {
|
||||||
|
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
|
||||||
|
$stmtLane->execute([$boardId]);
|
||||||
|
$laneId = (int)$stmtLane->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($laneId <= 0) {
|
||||||
|
return ['success' => false, 'error' => 'Het geselecteerde bord heeft geen kolommen.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = trim($data['description'] ?? '');
|
||||||
|
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
|
||||||
|
$color = in_array($data['color'] ?? '', ['yellow', 'blue', 'green', 'purple', 'pink', 'orange', 'slate', 'indigo']) ? $data['color'] : 'blue';
|
||||||
|
|
||||||
|
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||||
|
$stmtMax->execute([$laneId]);
|
||||||
|
$maxPos = (int)$stmtMax->fetchColumn();
|
||||||
|
|
||||||
|
$stmtInsert = $db->prepare("
|
||||||
|
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmtInsert->execute([$boardId, $laneId, $title, $description, $userId, $priority, $color, $maxPos + 1, $userId]);
|
||||||
|
$cardId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
// Optioneel inplannen op Tijdlijn / Gantt
|
||||||
|
$scheduleOnTimeline = !empty($data['schedule_on_timeline']);
|
||||||
|
if ($scheduleOnTimeline && $cardId > 0) {
|
||||||
|
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
|
||||||
|
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : $startDate;
|
||||||
|
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : '09:00';
|
||||||
|
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : '17:00';
|
||||||
|
$daysOfWeek = trim($data['days_of_week'] ?? '1,2,3,4,5');
|
||||||
|
|
||||||
|
$stmtSched = $db->prepare("
|
||||||
|
INSERT INTO board_card_schedules (card_id, schedule_type, start_date, end_date, start_time, end_time, days_of_week)
|
||||||
|
VALUES (?, 'DAILY_RANGE', ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
start_date = VALUES(start_date),
|
||||||
|
end_date = VALUES(end_date),
|
||||||
|
start_time = VALUES(start_time),
|
||||||
|
end_time = VALUES(end_time),
|
||||||
|
days_of_week = VALUES(days_of_week)
|
||||||
|
");
|
||||||
|
$stmtSched->execute([$cardId, $startDate, $endDate, $startTime, $endTime, $daysOfWeek]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'card_id' => $cardId,
|
||||||
|
'board_id' => $boardId,
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'scheduled_on_timeline' => $scheduleOnTimeline,
|
||||||
|
'message' => $scheduleOnTimeline
|
||||||
|
? 'Item succesvol toegevoegd aan het Bord en ingepland op de Tijdlijn!'
|
||||||
|
: 'Item succesvol toegevoegd aan het Bord!'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,13 +14,9 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/../config/config.php';
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
|
// Standaard waarde voor de main branch: 'live' (bepaald via centrale config.php)
|
||||||
$environment = defined('APP_ENV') ? APP_ENV : 'live';
|
$environment = defined('APP_ENV') ? APP_ENV : 'live';
|
||||||
|
|
||||||
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
|
|
||||||
$environment = fociz_env('APP_ENV');
|
|
||||||
}
|
|
||||||
|
|
||||||
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
|
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
+75
-6
@@ -7,6 +7,11 @@ require_once __DIR__ . '/auth.php';
|
|||||||
$currentUser = $currentUser ?? require_auth();
|
$currentUser = $currentUser ?? require_auth();
|
||||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||||
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
|
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
|
||||||
|
|
||||||
|
// Geef direct het PHP sessieslot vrij voor getoonde pagina's zodat parallelle requests en navigatie nooit blokkeren
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="nl" class="dark">
|
<html lang="nl" class="dark">
|
||||||
@@ -149,9 +154,9 @@ require_once __DIR__ . '/env_badge.php';
|
|||||||
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
|
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-16">
|
<div class="flex items-center justify-between h-16">
|
||||||
<!-- Brand Logo (met linker padding zodat het dev/live badge vrij staat) -->
|
<!-- Brand Logo (uitgelijnd met de widgets / content container) -->
|
||||||
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
|
<div class="flex items-center space-x-4 lg:space-x-6 shrink-0">
|
||||||
<a href="/planner.php" class="flex items-center space-x-2.5 group">
|
<a href="/planner.php" class="flex items-center space-x-2.5 group shrink-0">
|
||||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-500/20 group-hover:scale-105 transition">
|
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-500/20 group-hover:scale-105 transition">
|
||||||
F
|
F
|
||||||
</div>
|
</div>
|
||||||
@@ -250,10 +255,74 @@ require_once __DIR__ . '/env_badge.php';
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Unified Dashboard Toolbar Pill (Gecentreerd in de header) -->
|
||||||
|
<?php if ($currentPage === 'planner.php' && !empty($formattedSelectedDate)): ?>
|
||||||
|
<div class="flex items-center justify-center flex-1 mx-2 lg:mx-4 shrink-0">
|
||||||
|
<div class="flex items-center bg-slate-100/80 dark:bg-slate-800/60 rounded-xl p-1 border border-slate-200/80 dark:border-slate-700/60 shadow-2xs whitespace-nowrap">
|
||||||
|
<!-- Date & Status -->
|
||||||
|
<div class="flex items-center space-x-2 px-2.5 py-1 text-xs text-slate-700 dark:text-slate-200 select-none">
|
||||||
|
<div class="w-6 h-6 rounded-lg bg-indigo-50 dark:bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 flex items-center justify-center shrink-0">
|
||||||
|
<i data-lucide="calendar" class="w-3.5 h-3.5"></i>
|
||||||
|
</div>
|
||||||
|
<span id="headerCurrentDateDisplay" class="font-semibold tracking-tight whitespace-nowrap leading-none"><?= htmlspecialchars($formattedSelectedDate) ?></span>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<span id="headerDateBadgesContainer" class="inline-flex items-center gap-1.5 ml-1">
|
||||||
|
<?php if (!empty($isSelectedDateWorkday)): ?>
|
||||||
|
<span class="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 leading-none" title="Reguliere werkdag">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||||
|
<span>Werkdag</span>
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 leading-none" title="Weekend / Vrije dag">
|
||||||
|
<i data-lucide="coffee" class="w-2.5 h-2.5 text-amber-500"></i>
|
||||||
|
<span>Vrij</span>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($isSelectedDateBlocked)): ?>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30 cursor-pointer hover:bg-amber-500/25 transition leading-none" onclick="openCalendarBlockModal(<?= htmlspecialchars(json_encode($selectedDayBlock), ENT_QUOTES) ?>)" title="<?= htmlspecialchars($selectedDayBlock['title'] ?? '') ?> (klik om te bewerken)">
|
||||||
|
<i data-lucide="palmtree" class="w-2.5 h-2.5 text-amber-600 dark:text-amber-400"></i>
|
||||||
|
<span><?= htmlspecialchars($selectedDayBlock['title'] ?? '') ?></span>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700 mx-0.5 shrink-0 self-center"></div>
|
||||||
|
|
||||||
|
<!-- Instellingen Tandwiel Dropdown -->
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<button id="plannerSettingsDropdownBtn" onclick="togglePlannerSettingsMenu(event)" class="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-white dark:hover:bg-slate-700/60 transition cursor-pointer" title="Dashboard Opties & Instellingen">
|
||||||
|
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<div id="plannerSettingsDropdownMenu" class="hidden absolute right-0 top-full mt-2 w-56 rounded-2xl bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 shadow-xl py-1.5 z-50 text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
<button onclick="closePlannerSettingsMenu(); openWidgetManagerModal();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer">
|
||||||
|
<i data-lucide="layout-grid" class="w-4 h-4 text-indigo-500"></i>
|
||||||
|
<span class="font-medium">Widgets & Indeling</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="closePlannerSettingsMenu(); openCalendarBlockModal();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer">
|
||||||
|
<i data-lucide="palmtree" class="w-4 h-4 text-amber-500"></i>
|
||||||
|
<span class="font-medium">Vakantie / Dagen Blokkeren</span>
|
||||||
|
</button>
|
||||||
|
<?php if (!empty($isOutlookConnected)): ?>
|
||||||
|
<button onclick="closePlannerSettingsMenu(); triggerManualSync();" class="w-full text-left px-3.5 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/70 flex items-center space-x-2.5 transition cursor-pointer border-t border-slate-100 dark:border-slate-800/80">
|
||||||
|
<i data-lucide="refresh-cw" class="w-4 h-4 text-sky-500"></i>
|
||||||
|
<span class="font-medium">Outlook Synchroniseren</span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Right side: Clean User Profile & Logout -->
|
<!-- Right side: Clean User Profile & Logout -->
|
||||||
<div class="flex items-center space-x-2 sm:space-x-3">
|
<div class="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||||
|
|
||||||
<!-- Profiel link rechtsboven -->
|
<!-- Profiel link rechtsboven -->
|
||||||
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60" title="Mijn Profiel & Instellingen">
|
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60 shrink-0" title="Mijn Profiel & Instellingen">
|
||||||
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs overflow-hidden">
|
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs overflow-hidden">
|
||||||
<?php if (!empty($currentUser['avatar_url'])): ?>
|
<?php if (!empty($currentUser['avatar_url'])): ?>
|
||||||
<img src="<?= htmlspecialchars($currentUser['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[6px] object-cover bg-white dark:bg-slate-950" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
<img src="<?= htmlspecialchars($currentUser['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[6px] object-cover bg-white dark:bg-slate-950" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||||
@@ -278,7 +347,7 @@ require_once __DIR__ . '/env_badge.php';
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Uitloggen -->
|
<!-- Uitloggen -->
|
||||||
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition">
|
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition shrink-0">
|
||||||
<i data-lucide="log-out" class="w-4 h-4"></i>
|
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -70,7 +70,8 @@ if (!function_exists('fociz_http_request')) {
|
|||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(4, max(1, $timeout - 1)));
|
||||||
|
curl_setopt($ch, CURLOPT_DNS_CACHE_TIMEOUT, 600);
|
||||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
|
||||||
|
|||||||
+613
-280
File diff suppressed because it is too large
Load Diff
@@ -330,7 +330,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
|
|||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||||
],
|
],
|
||||||
'timeout' => 12,
|
'timeout' => 6,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Retry bij 401 Unauthorized
|
// Retry bij 401 Unauthorized
|
||||||
@@ -345,7 +345,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
|
|||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||||
],
|
],
|
||||||
'timeout' => 12,
|
'timeout' => 6,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,7 +416,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
fociz_cache_set($cacheKey, $events, 60);
|
fociz_cache_set($cacheKey, $events, 300); // 5 minuten cache
|
||||||
return $events;
|
return $events;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,11 +940,156 @@ function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate,
|
|||||||
/**
|
/**
|
||||||
* Levert realistische demo e-mails voor accounts zonder actieve Outlook verbinding
|
* Levert realistische demo e-mails voor accounts zonder actieve Outlook verbinding
|
||||||
*/
|
*/
|
||||||
function microsoft_get_mail_demo_data(): array {
|
function microsoft_get_mail_demo_data(?string $folderId = 'inbox'): array {
|
||||||
$now = time();
|
$now = time();
|
||||||
|
$fId = strtolower(trim($folderId ?: 'inbox'));
|
||||||
|
|
||||||
|
if ($fId === 'demo-folder-dx') {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-dx-1',
|
||||||
|
'folder_id' => 'demo-folder-dx',
|
||||||
|
'subject' => '⚡ Update: REST endpoint v2 specificatie voor UWV & Koppelingen',
|
||||||
|
'body_preview' => 'Beste collega, hierbij de bijgewerkte documentatie voor de REST endpoints en de schema-wijzigingen voor de datadump...',
|
||||||
|
'body_html' => '<p>Beste collega,</p><p>Hierbij de bijgewerkte documentatie voor de <strong>REST endpoints</strong> en de schema-wijzigingen voor de datadump.</p><p>Graag controleren of de data mapping aansluit bij onze migratiestappen.</p><p>Met vriendelijke groet,<br><strong>Team Koppelingen & DX</strong></p>',
|
||||||
|
'from_name' => 'Koppelingen Team DX',
|
||||||
|
'from_email' => 'dx-koppelingen@fociz.nl',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (25 * 60)),
|
||||||
|
'received_human' => '25 min geleden',
|
||||||
|
'is_read' => false,
|
||||||
|
'has_attachments' => true,
|
||||||
|
'importance' => 'high',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-dx-2',
|
||||||
|
'folder_id' => 'demo-folder-dx',
|
||||||
|
'subject' => 'Migratie draaiboek koppelingen fase 3 gereed',
|
||||||
|
'body_preview' => 'Het draaiboek voor de uitrol van de vernieuwde interfaces is goedgekeurd door de lead architect...',
|
||||||
|
'body_html' => '<p>Het draaiboek voor de uitrol van de vernieuwde interfaces is goedgekeurd door de lead architect. Zie de bijlage voor de tijdsplanning.</p>',
|
||||||
|
'from_name' => 'Lead Architect',
|
||||||
|
'from_email' => 'architect@fociz.nl',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (4 * 3600)),
|
||||||
|
'received_human' => '4 uur geleden',
|
||||||
|
'is_read' => true,
|
||||||
|
'has_attachments' => false,
|
||||||
|
'importance' => 'normal',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-dx-3',
|
||||||
|
'folder_id' => 'demo-folder-dx',
|
||||||
|
'subject' => 'SSO Certificaat verificatie voor staging omgeving',
|
||||||
|
'body_preview' => 'Het SSL/TLS certificaat voor de authenticatiekoppeling verloopt over 30 dagen. Het nieuwe certificaat is klaargezet...',
|
||||||
|
'body_html' => '<p>Het SSL/TLS certificaat voor de authenticatiekoppeling verloopt over 30 dagen. Het nieuwe certificaat is klaargezet op de staging server.</p>',
|
||||||
|
'from_name' => 'Security & Certificaten',
|
||||||
|
'from_email' => 'security@fociz.nl',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (26 * 3600)),
|
||||||
|
'received_human' => 'Gisteren',
|
||||||
|
'is_read' => true,
|
||||||
|
'has_attachments' => false,
|
||||||
|
'importance' => 'normal',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fId === 'demo-folder-klanten') {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-klant-1',
|
||||||
|
'folder_id' => 'demo-folder-klanten',
|
||||||
|
'subject' => 'Project kick-off partnerportaal integratie',
|
||||||
|
'body_preview' => 'Beste Fociz team, bedankt voor de toelichting gisteren. We kijken uit naar de gezamenlijke sprintstart volgende maand...',
|
||||||
|
'body_html' => '<p>Beste Fociz team,</p><p>Bedankt voor de toelichting gisteren. We kijken uit naar de gezamenlijke sprintstart volgende maand.</p><p>Vriendelijke groet,<br><strong>Partner Relatiemanager</strong></p>',
|
||||||
|
'from_name' => 'Partner Relaties',
|
||||||
|
'from_email' => 'partners@klantportaal.nl',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (5 * 3600)),
|
||||||
|
'received_human' => '5 uur geleden',
|
||||||
|
'is_read' => true,
|
||||||
|
'has_attachments' => false,
|
||||||
|
'importance' => 'normal',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-klant-2',
|
||||||
|
'folder_id' => 'demo-folder-klanten',
|
||||||
|
'subject' => 'Vraag over API sleutels en documentatie',
|
||||||
|
'body_preview' => 'Hallo, kunnen we de sandbox tokens alvast ontvangen voor onze ontwikkelaars?...',
|
||||||
|
'body_html' => '<p>Hallo, kunnen we de sandbox tokens alvast ontvangen voor onze ontwikkelaars? Alvast dank!</p>',
|
||||||
|
'from_name' => 'IT Lead Klant',
|
||||||
|
'from_email' => 'tech@partnerbedrijf.com',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (28 * 3600)),
|
||||||
|
'received_human' => 'Gisteren',
|
||||||
|
'is_read' => true,
|
||||||
|
'has_attachments' => false,
|
||||||
|
'importance' => 'normal',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fId === 'demo-folder-fin') {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-fin-1',
|
||||||
|
'folder_id' => 'demo-folder-fin',
|
||||||
|
'subject' => 'Nieuwe factuur September licenties ter goedkeuring',
|
||||||
|
'body_preview' => 'Beste directie, bijgaand de specificatie van de maandelijks cloud infrastructuur kosten ter akkoord...',
|
||||||
|
'body_html' => '<p>Beste directie,</p><p>Bijgaand de specificatie van de maandelijkse cloud infrastructuur kosten ter akkoord. Graag uiterlijk vrijdag verwerken.</p>',
|
||||||
|
'from_name' => 'Financiële Administratie',
|
||||||
|
'from_email' => 'facturen@fociz.nl',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (40 * 60)),
|
||||||
|
'received_human' => '40 min geleden',
|
||||||
|
'is_read' => false,
|
||||||
|
'has_attachments' => true,
|
||||||
|
'importance' => 'high',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-mail-fin-2',
|
||||||
|
'folder_id' => 'demo-folder-fin',
|
||||||
|
'subject' => 'Betaalbevestiging software koppelingen',
|
||||||
|
'body_preview' => 'Uw betaling voor de kwartaallicenties is succesvol verwerkt...',
|
||||||
|
'body_html' => '<p>Uw betaling voor de kwartaallicenties is succesvol verwerkt. Factuurnummer INV-2026-09.</p>',
|
||||||
|
'from_name' => 'Accounts Payable',
|
||||||
|
'from_email' => 'billing@cloudvendor.com',
|
||||||
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
|
'cc_recipients' => [],
|
||||||
|
'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)),
|
||||||
|
'received_human' => '2 dagen geleden',
|
||||||
|
'is_read' => true,
|
||||||
|
'has_attachments' => false,
|
||||||
|
'importance' => 'low',
|
||||||
|
'web_link' => 'https://outlook.office.com/mail/',
|
||||||
|
'is_demo' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standaard Inbox demo berichten
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'id' => 'demo-mail-1',
|
'id' => 'demo-mail-1',
|
||||||
|
'folder_id' => 'inbox',
|
||||||
'subject' => '⚡ Urgent: UWV Koppeling REST-endpoints en Datamigratie timing',
|
'subject' => '⚡ Urgent: UWV Koppeling REST-endpoints en Datamigratie timing',
|
||||||
'body_preview' => 'Beste collega, we hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de UWV REST-endpoints en de validatie van werknemersdossiers...',
|
'body_preview' => 'Beste collega, we hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de UWV REST-endpoints en de validatie van werknemersdossiers...',
|
||||||
'body_html' => '<p>Beste collega,</p><p>We hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de <strong>UWV REST-endpoints</strong> en de validatie van werknemersdossiers.</p><p>Zou je kunnen verifiëren of onze SSO-koppeling en de datadump export hier al op aansluiten? Het zou fijn zijn als we dit vanmiddag kort kunnen afstemmen in de planning.</p><p>Met vriendelijke groet,<br><strong>Directie UWV Koppelingen & Integraties</strong></p>',
|
'body_html' => '<p>Beste collega,</p><p>We hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de <strong>UWV REST-endpoints</strong> en de validatie van werknemersdossiers.</p><p>Zou je kunnen verifiëren of onze SSO-koppeling en de datadump export hier al op aansluiten? Het zou fijn zijn als we dit vanmiddag kort kunnen afstemmen in de planning.</p><p>Met vriendelijke groet,<br><strong>Directie UWV Koppelingen & Integraties</strong></p>',
|
||||||
@@ -952,7 +1097,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
'from_email' => 'koppelingen@uwv-overheid.nl',
|
'from_email' => 'koppelingen@uwv-overheid.nl',
|
||||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
'cc_recipients' => [],
|
'cc_recipients' => [],
|
||||||
'received_at' => date('Y-m-d H:i:s', $now - (12 * 60)), // 12 minuten geleden
|
'received_at' => date('Y-m-d H:i:s', $now - (12 * 60)),
|
||||||
'received_human' => '12 min geleden',
|
'received_human' => '12 min geleden',
|
||||||
'is_read' => false,
|
'is_read' => false,
|
||||||
'has_attachments' => true,
|
'has_attachments' => true,
|
||||||
@@ -962,6 +1107,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'demo-mail-2',
|
'id' => 'demo-mail-2',
|
||||||
|
'folder_id' => 'inbox',
|
||||||
'subject' => 'Factuur & Urenverantwoording September — Goedkeuring vereist',
|
'subject' => 'Factuur & Urenverantwoording September — Goedkeuring vereist',
|
||||||
'body_preview' => 'Hallo team, bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de week accorderen...',
|
'body_preview' => 'Hallo team, bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de week accorderen...',
|
||||||
'body_html' => '<p>Hallo team,</p><p>Bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de werkweek accorderen in het systeem.</p><p>Laat het weten indien er correcties nodig zijn op de dagregistraties.</p><p>Hartelijke groet,<br><strong>Financiële Administratie Fociz</strong></p>',
|
'body_html' => '<p>Hallo team,</p><p>Bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de werkweek accorderen in het systeem.</p><p>Laat het weten indien er correcties nodig zijn op de dagregistraties.</p><p>Hartelijke groet,<br><strong>Financiële Administratie Fociz</strong></p>',
|
||||||
@@ -969,7 +1115,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
'from_email' => 'facturatie@fociz.nl',
|
'from_email' => 'facturatie@fociz.nl',
|
||||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
'cc_recipients' => [],
|
'cc_recipients' => [],
|
||||||
'received_at' => date('Y-m-d H:i:s', $now - (65 * 60)), // 1 uur geleden
|
'received_at' => date('Y-m-d H:i:s', $now - (65 * 60)),
|
||||||
'received_human' => '1 uur geleden',
|
'received_human' => '1 uur geleden',
|
||||||
'is_read' => false,
|
'is_read' => false,
|
||||||
'has_attachments' => false,
|
'has_attachments' => false,
|
||||||
@@ -979,6 +1125,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'demo-mail-3',
|
'id' => 'demo-mail-3',
|
||||||
|
'folder_id' => 'inbox',
|
||||||
'subject' => 'Jira SSO Migratie & Staging server testomgeving',
|
'subject' => 'Jira SSO Migratie & Staging server testomgeving',
|
||||||
'body_preview' => 'Goedemiddag, de testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten...',
|
'body_preview' => 'Goedemiddag, de testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten...',
|
||||||
'body_html' => '<p>Goedemiddag,</p><p>De testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten op de Servicedesk wachtrijen en DX filters.</p><p>Alle endpoint logs zijn beschikbaar via het beheerpaneel.</p><p>Groeten,<br><strong>Team DevOps & Infrastructuur</strong></p>',
|
'body_html' => '<p>Goedemiddag,</p><p>De testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten op de Servicedesk wachtrijen en DX filters.</p><p>Alle endpoint logs zijn beschikbaar via het beheerpaneel.</p><p>Groeten,<br><strong>Team DevOps & Infrastructuur</strong></p>',
|
||||||
@@ -986,7 +1133,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
'from_email' => 'infra@fociz.nl',
|
'from_email' => 'infra@fociz.nl',
|
||||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
'cc_recipients' => [],
|
'cc_recipients' => [],
|
||||||
'received_at' => date('Y-m-d H:i:s', $now - (3 * 3600)), // 3 uur geleden
|
'received_at' => date('Y-m-d H:i:s', $now - (3 * 3600)),
|
||||||
'received_human' => '3 uur geleden',
|
'received_human' => '3 uur geleden',
|
||||||
'is_read' => true,
|
'is_read' => true,
|
||||||
'has_attachments' => true,
|
'has_attachments' => true,
|
||||||
@@ -996,6 +1143,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'demo-mail-4',
|
'id' => 'demo-mail-4',
|
||||||
|
'folder_id' => 'inbox',
|
||||||
'subject' => 'Herinnering: Kwartaal Roadmap Review & Strategie sessie',
|
'subject' => 'Herinnering: Kwartaal Roadmap Review & Strategie sessie',
|
||||||
'body_preview' => 'Beste collega\'s, vergeet niet je prioriteiten en feature requests in te dienen in de Fociz Roadmap module voor de meeting van aanstaande donderdag...',
|
'body_preview' => 'Beste collega\'s, vergeet niet je prioriteiten en feature requests in te dienen in de Fociz Roadmap module voor de meeting van aanstaande donderdag...',
|
||||||
'body_html' => '<p>Beste collega\'s,</p><p>Vergeet niet je prioriteiten en feature requests in te dienen in de <strong>Fociz Roadmap</strong> module voor de meeting van aanstaande donderdag.</p><p>We behandelen dan tevens de nieuwe Kanban borden en de Outlook time-blocking ervaring.</p><p>Tot dan!<br><strong>Product Management</strong></p>',
|
'body_html' => '<p>Beste collega\'s,</p><p>Vergeet niet je prioriteiten en feature requests in te dienen in de <strong>Fociz Roadmap</strong> module voor de meeting van aanstaande donderdag.</p><p>We behandelen dan tevens de nieuwe Kanban borden en de Outlook time-blocking ervaring.</p><p>Tot dan!<br><strong>Product Management</strong></p>',
|
||||||
@@ -1003,7 +1151,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
'from_email' => 'product@fociz.nl',
|
'from_email' => 'product@fociz.nl',
|
||||||
'to_recipients' => [['name' => 'Alle Medewerkers', 'address' => 'team@fociz.nl']],
|
'to_recipients' => [['name' => 'Alle Medewerkers', 'address' => 'team@fociz.nl']],
|
||||||
'cc_recipients' => [],
|
'cc_recipients' => [],
|
||||||
'received_at' => date('Y-m-d H:i:s', $now - (24 * 3600)), // Gisteren
|
'received_at' => date('Y-m-d H:i:s', $now - (24 * 3600)),
|
||||||
'received_human' => 'Gisteren',
|
'received_human' => 'Gisteren',
|
||||||
'is_read' => true,
|
'is_read' => true,
|
||||||
'has_attachments' => false,
|
'has_attachments' => false,
|
||||||
@@ -1013,6 +1161,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'demo-mail-5',
|
'id' => 'demo-mail-5',
|
||||||
|
'folder_id' => 'inbox',
|
||||||
'subject' => 'Welkom bij Fociz Live — Handige tips voor jouw dagplanning',
|
'subject' => 'Welkom bij Fociz Live — Handige tips voor jouw dagplanning',
|
||||||
'body_preview' => 'Welkom bij Fociz! Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw dagrooster?...',
|
'body_preview' => 'Welkom bij Fociz! Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw dagrooster?...',
|
||||||
'body_html' => '<p>Welkom bij Fociz!</p><p>Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw persoonlijke dagplanning?</p><p>Hierdoor houd je maximale grip op je werkdag en behoud je de ultieme focus.</p><p>Veel productiviteit gewenst,<br><strong>Het Fociz Team</strong></p>',
|
'body_html' => '<p>Welkom bij Fociz!</p><p>Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw persoonlijke dagplanning?</p><p>Hierdoor houd je maximale grip op je werkdag en behoud je de ultieme focus.</p><p>Veel productiviteit gewenst,<br><strong>Het Fociz Team</strong></p>',
|
||||||
@@ -1020,7 +1169,7 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
'from_email' => 'no-reply@fociz.nl',
|
'from_email' => 'no-reply@fociz.nl',
|
||||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||||
'cc_recipients' => [],
|
'cc_recipients' => [],
|
||||||
'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)), // 2 dagen geleden
|
'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)),
|
||||||
'received_human' => '2 dagen geleden',
|
'received_human' => '2 dagen geleden',
|
||||||
'is_read' => true,
|
'is_read' => true,
|
||||||
'has_attachments' => false,
|
'has_attachments' => false,
|
||||||
@@ -1032,10 +1181,334 @@ function microsoft_get_mail_demo_data(): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Haalt inbox berichten op van Microsoft Graph API (of retourneert demo data)
|
* Haalt de door de gebruiker uitgeschakelde mailmappen op
|
||||||
*/
|
*/
|
||||||
function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unreadOnly = false, bool $forceRefresh = false): array {
|
function microsoft_get_disabled_folders(int $userId): array {
|
||||||
$cacheKey = "ms_inbox_{$userId}_{$limit}_" . ($unreadOnly ? '1' : '0');
|
try {
|
||||||
|
$db = get_db_connection();
|
||||||
|
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row || empty($row['settings'])) return [];
|
||||||
|
$settings = json_decode($row['settings'], true);
|
||||||
|
if (is_array($settings) && !empty($settings['disabled_mail_folders']) && is_array($settings['disabled_mail_folders'])) {
|
||||||
|
return array_values(array_unique(array_filter(array_map('strval', $settings['disabled_mail_folders']))));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slaat de uitgeschakelde mailmappen op voor een gebruiker
|
||||||
|
*/
|
||||||
|
function microsoft_save_disabled_folders(int $userId, array $disabledFolders): bool {
|
||||||
|
try {
|
||||||
|
$db = get_db_connection();
|
||||||
|
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
$settings = ($row && !empty($row['settings'])) ? (json_decode($row['settings'], true) ?: []) : [];
|
||||||
|
|
||||||
|
$clean = array_values(array_unique(array_filter(array_map('strval', $disabledFolders))));
|
||||||
|
$settings['disabled_mail_folders'] = $clean;
|
||||||
|
|
||||||
|
$update = $db->prepare("UPDATE integrations SET settings = ?, updated_at = NOW() WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||||
|
$ok = $update->execute([json_encode($settings, JSON_UNESCAPED_UNICODE), $userId]);
|
||||||
|
|
||||||
|
fociz_cache_delete("ms_folders_{$userId}");
|
||||||
|
return $ok;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("microsoft_save_disabled_folders error: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt alle beschikbare mailmappen op van Microsoft Graph API (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox en Concepten)
|
||||||
|
*/
|
||||||
|
function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false): array {
|
||||||
|
$cacheKey = "ms_folders_{$userId}";
|
||||||
|
if (!$forceRefresh) {
|
||||||
|
$cached = fociz_cache_get($cacheKey);
|
||||||
|
if ($cached !== null && is_array($cached) && !empty($cached['success'])) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = get_db_connection();
|
||||||
|
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$integration = $stmt->fetch();
|
||||||
|
|
||||||
|
$isLive = !empty($integration['access_token']) && empty($integration['is_demo_mode']);
|
||||||
|
if (!$isLive) {
|
||||||
|
$demoFolders = [
|
||||||
|
[
|
||||||
|
'id' => 'inbox',
|
||||||
|
'name' => 'Postvak IN',
|
||||||
|
'unread_count' => 2,
|
||||||
|
'total_count' => 5,
|
||||||
|
'is_inbox' => true,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-folder-dx',
|
||||||
|
'name' => 'Projecten & DX Koppelingen',
|
||||||
|
'unread_count' => 1,
|
||||||
|
'total_count' => 3,
|
||||||
|
'is_inbox' => false,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-folder-klanten',
|
||||||
|
'name' => 'Klanten & Partners',
|
||||||
|
'unread_count' => 0,
|
||||||
|
'total_count' => 2,
|
||||||
|
'is_inbox' => false,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'demo-folder-fin',
|
||||||
|
'name' => 'Financiën & Facturatie',
|
||||||
|
'unread_count' => 1,
|
||||||
|
'total_count' => 2,
|
||||||
|
'is_inbox' => false,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$disabledFolders = microsoft_get_disabled_folders($userId);
|
||||||
|
$disabledSet = array_flip($disabledFolders);
|
||||||
|
$totalUnread = 0;
|
||||||
|
foreach ($demoFolders as $df) {
|
||||||
|
if (!isset($disabledSet[$df['id']])) {
|
||||||
|
$totalUnread += (int)($df['unread_count'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'is_demo' => true,
|
||||||
|
'folders' => $demoFolders,
|
||||||
|
'total_unread' => $totalUnread,
|
||||||
|
'disabled_folders' => $disabledFolders,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = microsoft_get_valid_token($userId);
|
||||||
|
if (!$token) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
|
||||||
|
'folders' => [
|
||||||
|
[
|
||||||
|
'id' => 'inbox',
|
||||||
|
'name' => 'Postvak IN',
|
||||||
|
'unread_count' => 0,
|
||||||
|
'total_count' => 0,
|
||||||
|
'is_inbox' => true,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'total_unread' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Haal de hoofdmappen op ZONDER restrictief $select (voorkomt 400 Bad Request op wellKnownName)
|
||||||
|
$url = MS_GRAPH_BASE_URL . '/me/mailFolders?$top=100';
|
||||||
|
$res = fociz_http_request($url, [
|
||||||
|
'method' => 'GET',
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => "Bearer {$token}",
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 15,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
|
||||||
|
$graphError = $res['json']['error']['message'] ?? ($res['error'] ?? 'HTTP ' . $res['status']);
|
||||||
|
error_log("Microsoft fetch folders error: HTTP {$res['status']} - {$graphError}");
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $graphError,
|
||||||
|
'folders' => [
|
||||||
|
[
|
||||||
|
'id' => 'inbox',
|
||||||
|
'name' => 'Postvak IN',
|
||||||
|
'unread_count' => 0,
|
||||||
|
'total_count' => 0,
|
||||||
|
'is_inbox' => true,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'total_unread' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawFolders = is_array($res['json']['value']) ? $res['json']['value'] : [];
|
||||||
|
|
||||||
|
// 2. Haal ALTIJD expliciet de submappen van Postvak IN op
|
||||||
|
// (In Outlook bevinden 95% van de door gebruikers aangemaakte mappen zich direct onder Inbox)
|
||||||
|
$inboxChildUrl = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox/childFolders?$top=100';
|
||||||
|
$inboxChildRes = fociz_http_request($inboxChildUrl, [
|
||||||
|
'method' => 'GET',
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => "Bearer {$token}",
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 8,
|
||||||
|
]);
|
||||||
|
if ($inboxChildRes['status'] === 200 && !empty($inboxChildRes['json']['value']) && is_array($inboxChildRes['json']['value'])) {
|
||||||
|
foreach ($inboxChildRes['json']['value'] as $child) {
|
||||||
|
$rawFolders[] = $child;
|
||||||
|
// Als een eigen submap onder Inbox zelf ook weer submappen heeft: haal die ook op
|
||||||
|
$cfc = (int)($child['childFolderCount'] ?? 0);
|
||||||
|
$cId = $child['id'] ?? '';
|
||||||
|
if ($cfc > 0 && !empty($cId)) {
|
||||||
|
$subRes = fociz_http_request(MS_GRAPH_BASE_URL . '/me/mailFolders/' . rawurlencode($cId) . '/childFolders?$top=100', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => "Bearer {$token}",
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 6,
|
||||||
|
]);
|
||||||
|
if ($subRes['status'] === 200 && !empty($subRes['json']['value']) && is_array($subRes['json']['value'])) {
|
||||||
|
foreach ($subRes['json']['value'] as $subChild) {
|
||||||
|
$rawFolders[] = $subChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Uitsluitingen conform gebruikerswens:
|
||||||
|
// "de SPAM , Ongewenste email en Prullenbak hoeven er niet in."
|
||||||
|
// Ook Outbox, Drafts en technische sync logs uitsluiten
|
||||||
|
$excludedWellKnown = [
|
||||||
|
'junkemail', 'deleteditems', 'outbox', 'drafts',
|
||||||
|
'conflicts', 'localfailures', 'serverfailures', 'syncissues', 'conversationhistory',
|
||||||
|
'clutter', 'recoverableitemsdeletions'
|
||||||
|
];
|
||||||
|
|
||||||
|
$excludedExactNames = [
|
||||||
|
// Nederlands
|
||||||
|
'ongewenste e-mail', 'ongewenste email', 'ongewenst', 'spam', 'junk',
|
||||||
|
'verwijderde items', 'prullenbak', 'trash', 'verwijderd',
|
||||||
|
'postvak uit', 'outbox',
|
||||||
|
'concepten', 'drafts',
|
||||||
|
'gespreksgeschiedenis', 'conversation history',
|
||||||
|
'problemen met synchronisatie', 'synchronisatieproblemen', 'conflicten',
|
||||||
|
// Engels
|
||||||
|
'junk email', 'deleted items', 'sync issues', 'conflicts', 'local failures', 'server failures'
|
||||||
|
];
|
||||||
|
|
||||||
|
$folders = [];
|
||||||
|
$seenIds = [];
|
||||||
|
|
||||||
|
foreach ($rawFolders as $rf) {
|
||||||
|
$fId = trim($rf['id'] ?? '');
|
||||||
|
if (empty($fId) || isset($seenIds[$fId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sla verborgen mappen over
|
||||||
|
if (!empty($rf['isHidden'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wk = strtolower(trim($rf['wellKnownName'] ?? ''));
|
||||||
|
if (in_array($wk, $excludedWellKnown, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dName = trim($rf['displayName'] ?? '');
|
||||||
|
$dNameLower = strtolower($dName);
|
||||||
|
|
||||||
|
if (in_array($dNameLower, $excludedExactNames, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strpos($dNameLower, 'problemen met synchronisatie') === 0 || strpos($dNameLower, 'sync issues') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isInbox = ($wk === 'inbox' || $dNameLower === 'inbox' || $dNameLower === 'postvak in');
|
||||||
|
|
||||||
|
$seenIds[$fId] = true;
|
||||||
|
|
||||||
|
$folders[] = [
|
||||||
|
'id' => $isInbox ? 'inbox' : $fId,
|
||||||
|
'name' => $dName ?: ($isInbox ? 'Postvak IN' : 'Map'),
|
||||||
|
'unread_count' => (int)($rf['unreadItemCount'] ?? 0),
|
||||||
|
'total_count' => (int)($rf['totalItemCount'] ?? 0),
|
||||||
|
'is_inbox' => $isInbox,
|
||||||
|
'parent_id' => $rf['parentFolderId'] ?? null,
|
||||||
|
'is_child' => !empty($rf['parentFolderId']) && !$isInbox,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garandeer dat Inbox altijd aanwezig is
|
||||||
|
$hasInbox = false;
|
||||||
|
foreach ($folders as $f) {
|
||||||
|
if (!empty($f['is_inbox'])) {
|
||||||
|
$hasInbox = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasInbox) {
|
||||||
|
array_unshift($folders, [
|
||||||
|
'id' => 'inbox',
|
||||||
|
'name' => 'Postvak IN',
|
||||||
|
'unread_count' => 0,
|
||||||
|
'total_count' => 0,
|
||||||
|
'is_inbox' => true,
|
||||||
|
'parent_id' => null,
|
||||||
|
'is_child' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorteer: Inbox altijd bovenaan, daarna alfabetisch
|
||||||
|
usort($folders, function($a, $b) {
|
||||||
|
if (!empty($a['is_inbox'])) return -1;
|
||||||
|
if (!empty($b['is_inbox'])) return 1;
|
||||||
|
return strcasecmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$disabledFolders = microsoft_get_disabled_folders($userId);
|
||||||
|
$disabledSet = array_flip($disabledFolders);
|
||||||
|
|
||||||
|
$totalUnread = 0;
|
||||||
|
foreach ($folders as $f) {
|
||||||
|
if (!isset($disabledSet[$f['id']])) {
|
||||||
|
$totalUnread += (int)($f['unread_count'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'success' => true,
|
||||||
|
'is_demo' => false,
|
||||||
|
'folders' => $folders,
|
||||||
|
'total_unread' => $totalUnread,
|
||||||
|
'disabled_folders' => $disabledFolders,
|
||||||
|
];
|
||||||
|
|
||||||
|
fociz_cache_set($cacheKey, $result, 300); // 5 minuten cache
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt berichten op voor een specifieke map van Microsoft Graph API (of retourneert demo data)
|
||||||
|
*/
|
||||||
|
function microsoft_fetch_folder_messages(int $userId, string $folderId = 'inbox', int $limit = 30, bool $unreadOnly = false, bool $forceRefresh = false): array {
|
||||||
|
$cleanFolderId = trim($folderId ?: 'inbox');
|
||||||
|
$cacheKey = "ms_folder_{$userId}_" . md5($cleanFolderId) . "_{$limit}_" . ($unreadOnly ? '1' : '0');
|
||||||
if (!$forceRefresh) {
|
if (!$forceRefresh) {
|
||||||
$cached = fociz_cache_get($cacheKey);
|
$cached = fociz_cache_get($cacheKey);
|
||||||
if ($cached !== null && is_array($cached)) {
|
if ($cached !== null && is_array($cached)) {
|
||||||
@@ -1050,13 +1523,14 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
|
|
||||||
$isLive = !empty($integration['access_token']) && empty($integration['is_demo_mode']);
|
$isLive = !empty($integration['access_token']) && empty($integration['is_demo_mode']);
|
||||||
if (!$isLive) {
|
if (!$isLive) {
|
||||||
$demo = microsoft_get_mail_demo_data();
|
$demo = microsoft_get_mail_demo_data($cleanFolderId);
|
||||||
if ($unreadOnly) {
|
if ($unreadOnly) {
|
||||||
$demo = array_values(array_filter($demo, function($m) { return empty($m['is_read']); }));
|
$demo = array_values(array_filter($demo, function($m) { return empty($m['is_read']); }));
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'is_demo' => true,
|
'is_demo' => true,
|
||||||
|
'folder_id' => $cleanFolderId,
|
||||||
'messages' => array_slice($demo, 0, $limit),
|
'messages' => array_slice($demo, 0, $limit),
|
||||||
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
|
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
|
||||||
'total_count' => count($demo),
|
'total_count' => count($demo),
|
||||||
@@ -1068,6 +1542,7 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
|
'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
|
||||||
|
'folder_id' => $cleanFolderId,
|
||||||
'messages' => [],
|
'messages' => [],
|
||||||
'unread_count' => 0,
|
'unread_count' => 0,
|
||||||
];
|
];
|
||||||
@@ -1083,7 +1558,8 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
$queryParams['$filter'] = 'isRead eq false';
|
$queryParams['$filter'] = 'isRead eq false';
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox/messages?' . http_build_query($queryParams);
|
$folderEndpoint = ($cleanFolderId === 'inbox') ? 'inbox' : rawurlencode($cleanFolderId);
|
||||||
|
$url = MS_GRAPH_BASE_URL . '/me/mailFolders/' . $folderEndpoint . '/messages?' . http_build_query($queryParams);
|
||||||
$res = fociz_http_request($url, [
|
$res = fociz_http_request($url, [
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'headers' => [
|
'headers' => [
|
||||||
@@ -1091,20 +1567,21 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
'Prefer' => 'outlook.body-content-type="text"',
|
'Prefer' => 'outlook.body-content-type="text"',
|
||||||
],
|
],
|
||||||
'timeout' => 15,
|
'timeout' => 6,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
|
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
|
||||||
$graphError = $res['json']['error']['message'] ?? ($res['error'] ?? 'HTTP ' . $res['status']);
|
$graphError = $res['json']['error']['message'] ?? ($res['error'] ?? 'HTTP ' . $res['status']);
|
||||||
$graphCode = $res['json']['error']['code'] ?? '';
|
$graphCode = $res['json']['error']['code'] ?? '';
|
||||||
error_log("Microsoft fetch inbox error: HTTP {$res['status']} [{$graphCode}] - {$graphError}");
|
error_log("Microsoft fetch folder messages error for {$cleanFolderId}: HTTP {$res['status']} [{$graphCode}] - {$graphError}");
|
||||||
|
|
||||||
$demo = microsoft_get_mail_demo_data();
|
$demo = microsoft_get_mail_demo_data($cleanFolderId);
|
||||||
$needsReconsent = ($res['status'] === 403 || stripos($graphError, 'access') !== false || stripos($graphError, 'privilege') !== false || stripos($graphError, 'denied') !== false || stripos($graphError, 'consent') !== false);
|
$needsReconsent = ($res['status'] === 403 || stripos($graphError, 'access') !== false || stripos($graphError, 'privilege') !== false || stripos($graphError, 'denied') !== false || stripos($graphError, 'consent') !== false);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'is_demo' => true,
|
'is_demo' => true,
|
||||||
|
'folder_id' => $cleanFolderId,
|
||||||
'messages' => array_slice($demo, 0, $limit),
|
'messages' => array_slice($demo, 0, $limit),
|
||||||
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
|
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
|
||||||
'total_count' => count($demo),
|
'total_count' => count($demo),
|
||||||
@@ -1153,6 +1630,7 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
|
|
||||||
$messages[] = [
|
$messages[] = [
|
||||||
'id' => $msg['id'] ?? '',
|
'id' => $msg['id'] ?? '',
|
||||||
|
'folder_id' => $cleanFolderId,
|
||||||
'subject' => $msg['subject'] ?: '(Geen onderwerp)',
|
'subject' => $msg['subject'] ?: '(Geen onderwerp)',
|
||||||
'body_preview' => $msg['bodyPreview'] ?? '',
|
'body_preview' => $msg['bodyPreview'] ?? '',
|
||||||
'from_name' => $fromName,
|
'from_name' => $fromName,
|
||||||
@@ -1178,6 +1656,7 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
$result = [
|
$result = [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'is_demo' => false,
|
'is_demo' => false,
|
||||||
|
'folder_id' => $cleanFolderId,
|
||||||
'messages' => $messages,
|
'messages' => $messages,
|
||||||
'unread_count' => $unreadCount,
|
'unread_count' => $unreadCount,
|
||||||
'total_count' => count($messages),
|
'total_count' => count($messages),
|
||||||
@@ -1186,6 +1665,13 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt inbox berichten op van Microsoft Graph API (of retourneert demo data)
|
||||||
|
*/
|
||||||
|
function microsoft_fetch_inbox_messages(int $userId, int $limit = 30, bool $unreadOnly = false, bool $forceRefresh = false): array {
|
||||||
|
return microsoft_fetch_folder_messages($userId, 'inbox', $limit, $unreadOnly, $forceRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Haalt de volledige inhoud en details van een enkel e-mailbericht op
|
* Haalt de volledige inhoud en details van een enkel e-mailbericht op
|
||||||
*/
|
*/
|
||||||
|
|||||||
+51
-11
@@ -131,15 +131,58 @@ class FocizMailer {
|
|||||||
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. EHLO Handshake
|
// 4. EHLO Handshake conform RFC 5321 §4.1.4
|
||||||
$clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl';
|
// Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
|
||||||
$resp = $sendCommand("EHLO {$clientHost}");
|
// Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
|
||||||
if ($resp['code'] !== 250) {
|
$resolveHeloHost = function() use ($host): string {
|
||||||
$resp = $sendCommand("HELO {$clientHost}");
|
// 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
|
||||||
if ($resp['code'] !== 250) {
|
if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
|
||||||
throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}");
|
return $host;
|
||||||
|
}
|
||||||
|
// 2. SERVER_NAME mits het niet het bare hoofddomein is
|
||||||
|
if (!empty($_SERVER['SERVER_NAME'])) {
|
||||||
|
$sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME']));
|
||||||
|
if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) {
|
||||||
|
return $sn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 3. Systeemhostnaam
|
||||||
|
if (function_exists('gethostname')) {
|
||||||
|
$gh = gethostname();
|
||||||
|
if (!empty($gh) && strtolower($gh) !== 'fociz.nl') {
|
||||||
|
return $gh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'mail.fociz.nl';
|
||||||
|
};
|
||||||
|
|
||||||
|
$performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string {
|
||||||
|
$candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']);
|
||||||
|
$lastResp = null;
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$resp = $sendCommand("EHLO {$candidate}");
|
||||||
|
if ($resp['code'] === 250) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
$lastResp = $resp;
|
||||||
|
$log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback naar HELO indien EHLO op alle kandidaten niet werkt
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$resp = $sendCommand("HELO {$candidate}");
|
||||||
|
if ($resp['code'] === 250) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
$lastResp = $resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend'));
|
||||||
|
};
|
||||||
|
|
||||||
|
$preferredHost = $resolveHeloHost();
|
||||||
|
$clientHost = $performHeloHandshake($preferredHost);
|
||||||
|
|
||||||
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
|
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
|
||||||
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
||||||
@@ -165,10 +208,7 @@ class FocizMailer {
|
|||||||
$log("TLS encryptie succesvol geactiveerd.");
|
$log("TLS encryptie succesvol geactiveerd.");
|
||||||
|
|
||||||
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
||||||
$resp = $sendCommand("EHLO {$clientHost}");
|
$clientHost = $performHeloHandshake($clientHost);
|
||||||
if ($resp['code'] !== 250) {
|
|
||||||
throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. SMTP Authenticatie
|
// 6. SMTP Authenticatie
|
||||||
|
|||||||
@@ -191,9 +191,9 @@ function get_ai_settings(?int $userId = null): array {
|
|||||||
$provider = 'gemini';
|
$provider = 'gemini';
|
||||||
}
|
}
|
||||||
|
|
||||||
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-1.5-pro');
|
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
|
||||||
if (empty($model)) {
|
if (empty($model)) {
|
||||||
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-1.5-pro';
|
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
|
||||||
}
|
}
|
||||||
|
|
||||||
$apiKey = trim((string)$apiKey);
|
$apiKey = trim((string)$apiKey);
|
||||||
|
|||||||
@@ -7,19 +7,34 @@
|
|||||||
$pageTitle = 'Fociz — Microsoft Outlook Mail';
|
$pageTitle = 'Fociz — Microsoft Outlook Mail';
|
||||||
require_once __DIR__ . '/includes/header.php';
|
require_once __DIR__ . '/includes/header.php';
|
||||||
require_once __DIR__ . '/includes/integrations/microsoft.php';
|
require_once __DIR__ . '/includes/integrations/microsoft.php';
|
||||||
|
require_once __DIR__ . '/includes/boards.php';
|
||||||
|
|
||||||
$userId = (int)($currentUser['id'] ?? 0);
|
$userId = (int)($currentUser['id'] ?? 0);
|
||||||
|
$isAdmin = (($currentUser['role'] ?? '') === 'ADMIN');
|
||||||
$db = get_db_connection();
|
$db = get_db_connection();
|
||||||
|
$userBoards = fociz_get_user_boards_with_lanes($db, $userId, $isAdmin);
|
||||||
|
|
||||||
|
// Geef direct het PHP sessieslot vrij zodat achtergrondverzoeken en paginanavigatie nooit blokkeren
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haal alle beschikbare mailmappen op (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox, Drafts)
|
||||||
|
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
||||||
|
$foldersData = microsoft_fetch_mail_folders($userId, $forceRefresh);
|
||||||
|
$mailFolders = $foldersData['folders'] ?? [];
|
||||||
|
$totalUnreadCount = (int)($foldersData['total_unread'] ?? 0);
|
||||||
|
|
||||||
// Haal initiële inbox data op (met 45s cache, tenzij ?refresh=1)
|
// Haal initiële inbox data op (met 45s cache, tenzij ?refresh=1)
|
||||||
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
|
|
||||||
$inboxData = microsoft_fetch_inbox_messages($userId, 30, false, $forceRefresh);
|
$inboxData = microsoft_fetch_inbox_messages($userId, 30, false, $forceRefresh);
|
||||||
$messages = $inboxData['messages'] ?? [];
|
$messages = $inboxData['messages'] ?? [];
|
||||||
$isDemo = !empty($inboxData['is_demo']);
|
$isDemo = !empty($inboxData['is_demo']) || !empty($foldersData['is_demo']);
|
||||||
$unreadCount = (int)($inboxData['unread_count'] ?? 0);
|
$warning = $inboxData['warning'] ?? ($foldersData['warning'] ?? ($foldersData['error'] ?? ''));
|
||||||
$warning = $inboxData['warning'] ?? '';
|
|
||||||
$needsReconsent = !empty($inboxData['needs_reconsent']);
|
$needsReconsent = !empty($inboxData['needs_reconsent']);
|
||||||
|
|
||||||
|
// Bereken actuele ongelezen aantal: neem het hoogste van total_unread over mappen en inbox unread_count
|
||||||
|
$unreadCount = max($totalUnreadCount, (int)($inboxData['unread_count'] ?? 0));
|
||||||
|
|
||||||
// Controleer status
|
// Controleer status
|
||||||
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||||
$stmtInt->execute([$userId]);
|
$stmtInt->execute([$userId]);
|
||||||
@@ -27,7 +42,11 @@ $outlookInt = $stmtInt->fetch();
|
|||||||
$isOutlookConfigured = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
|
$isOutlookConfigured = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
|
||||||
$isLiveMail = $isOutlookConfigured && !$isDemo;
|
$isLiveMail = $isOutlookConfigured && !$isDemo;
|
||||||
|
|
||||||
|
$disabledFolderIds = $foldersData['disabled_folders'] ?? microsoft_get_disabled_folders($userId);
|
||||||
|
$jsonDisabledFolders = json_encode(array_values($disabledFolderIds), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||||||
|
|
||||||
$jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
$jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||||||
|
$jsonFolders = json_encode($mailFolders, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto space-y-6">
|
<div class="max-w-7xl mx-auto space-y-6">
|
||||||
@@ -44,6 +63,18 @@ $jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_Q
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($warning)): ?>
|
||||||
|
<div class="p-4 rounded-2xl bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs text-amber-800 dark:text-amber-200 shadow-2xs">
|
||||||
|
<div class="flex items-center space-x-2.5">
|
||||||
|
<i data-lucide="alert-triangle" class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0"></i>
|
||||||
|
<span><?= htmlspecialchars($warning) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($needsReconsent)): ?>
|
||||||
|
<a href="/auth/oauth_microsoft.php?reconsent=1&return_to=/mail.php" class="px-3 py-1.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs shadow-xs transition whitespace-nowrap flex-shrink-0 text-center">Machtigingen Bijwerken</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Top Banner: Status & Snelle Acties -->
|
<!-- Top Banner: Status & Snelle Acties -->
|
||||||
<div class="fociz-card p-6 sm:p-7 relative overflow-hidden">
|
<div class="fociz-card p-6 sm:p-7 relative overflow-hidden">
|
||||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
@@ -140,15 +171,23 @@ $jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_Q
|
|||||||
<!-- Twee-kolommen Mail Interface -->
|
<!-- Twee-kolommen Mail Interface -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-[600px]">
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-[600px]">
|
||||||
|
|
||||||
<!-- Linker kolom: Berichtenlijst (5 kolommen) -->
|
<!-- Linker kolom: Berichtenlijst & Mappen (5 kolommen) -->
|
||||||
<div id="mailListColumn" class="lg:col-span-5 fociz-card p-3 sm:p-4 flex flex-col h-[720px] overflow-hidden">
|
<div id="mailListColumn" class="lg:col-span-5 fociz-card p-3 sm:p-4 flex flex-col h-[720px] overflow-hidden">
|
||||||
<div class="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 px-2 py-1 mb-2 flex items-center justify-between">
|
<div class="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 px-2 py-1 mb-2 flex items-center justify-between gap-2">
|
||||||
<span>Inbox Berichten (<span id="visibleMailCount"><?= count($messages) ?></span>)</span>
|
<div class="flex items-center space-x-2 truncate">
|
||||||
<span class="text-[10px] text-slate-400 font-normal">Sorteer: Nieuwste eerst</span>
|
<span>Inbox & Mappen (<span id="visibleMailCount"><?= count($messages) ?></span>)</span>
|
||||||
|
<span id="disabledFoldersIndicator" class="hidden px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/60"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<button type="button" onclick="openManageFoldersModal()" title="Mailmappen beheren / in- of uitschakelen" class="flex items-center space-x-1 text-[11px] font-semibold text-slate-500 hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400 px-2 py-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||||
|
<i data-lucide="sliders-horizontal" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Beheren</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-1.5 pr-1" id="mailListContainer">
|
<div class="flex-1 overflow-y-auto space-y-2.5 pr-1 custom-scrollbar" id="mailFoldersContainer">
|
||||||
<!-- Wordt dynamisch gerenderd via JS -->
|
<!-- Wordt dynamisch gerenderd via JS (Inbox open, overige mappen inklapbaar) -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,6 +364,55 @@ $jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_Q
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Optie: Tevens toevoegen aan Bord & Tijdlijn -->
|
||||||
|
<div class="p-3 rounded-xl bg-slate-50 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-800 space-y-2.5">
|
||||||
|
<label class="flex items-center space-x-2.5 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" id="mailPlanAddToBoard" onchange="toggleMailBoardOptions(this.checked)" class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700 dark:bg-slate-900 cursor-pointer">
|
||||||
|
<span class="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
|
||||||
|
<i data-lucide="layout-grid" class="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400"></i>
|
||||||
|
<span>Tevens toevoegen aan Bord & Tijdlijn</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div id="mailBoardOptionsWrapper" class="hidden space-y-2.5 pt-2.5 border-t border-slate-200/70 dark:border-slate-800">
|
||||||
|
<?php if (empty($userBoards)): ?>
|
||||||
|
<p class="text-[11px] text-slate-500 dark:text-slate-400">Er zijn geen actieve borden gevonden.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label for="mailPlanBoardSelect" class="block text-[10px] font-bold text-slate-600 dark:text-slate-400 mb-1 uppercase tracking-wider">Bord</label>
|
||||||
|
<select id="mailPlanBoardSelect" onchange="onMailBoardSelectChange(this.value)" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
<?php foreach ($userBoards as $b): ?>
|
||||||
|
<option value="<?= (int)$b['id'] ?>"><?= htmlspecialchars($b['title']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mailPlanLaneSelect" class="block text-[10px] font-bold text-slate-600 dark:text-slate-400 mb-1 uppercase tracking-wider">Kolom</label>
|
||||||
|
<select id="mailPlanLaneSelect" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
<!-- Wordt gevuld via JS -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pt-1">
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" id="mailPlanScheduleOnTimeline" checked onchange="toggleMailTimelineDates(this.checked)" class="w-3.5 h-3.5 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700 dark:bg-slate-900 cursor-pointer">
|
||||||
|
<span class="text-xs font-medium text-slate-700 dark:text-slate-300">Inplannen op de Project Tijdlijn</span>
|
||||||
|
</label>
|
||||||
|
<div id="mailTimelineDatesWrapper" class="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<div>
|
||||||
|
<label for="mailTimelineStartDate" class="block text-[10px] font-bold text-slate-600 dark:text-slate-400 mb-1 uppercase tracking-wider">Startdatum</label>
|
||||||
|
<input type="date" id="mailTimelineStartDate" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mailTimelineEndDate" class="block text-[10px] font-bold text-slate-600 dark:text-slate-400 mb-1 uppercase tracking-wider">Einddatum</label>
|
||||||
|
<input type="date" id="mailTimelineEndDate" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
<div class="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||||
<button type="button" onclick="closePlanMailModal()" class="px-4 py-2 rounded-xl text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
<button type="button" onclick="closePlanMailModal()" class="px-4 py-2 rounded-xl text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||||
Annuleren
|
Annuleren
|
||||||
@@ -474,8 +562,48 @@ $jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_Q
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL 4: Mailmappen Beheren (In- en uitschakelen / herstellen) -->
|
||||||
|
<div id="manageFoldersModal" onclick="if(event.target === this) closeManageFoldersModal()" class="fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-3xl max-w-lg w-full p-6 sm:p-7 shadow-2xl space-y-5 animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
|
||||||
|
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<div class="flex items-center space-x-2.5">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-sky-500/10 text-sky-600 dark:text-sky-400 flex items-center justify-center">
|
||||||
|
<i data-lucide="sliders-horizontal" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Mailmappen Beheren</h3>
|
||||||
|
<p class="text-[11px] text-slate-400">Schakel mappen uit om ze te verbergen uit het overzicht. Dit is op ieder moment weer te herstellen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeManageFoldersModal()" class="text-slate-400 hover:text-slate-600 p-1.5 rounded-lg transition cursor-pointer">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar max-h-[50vh]" id="manageFoldersList">
|
||||||
|
<!-- Dynamisch gevuld via renderManageFoldersList() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<button type="button" onclick="resetAllFoldersVisibility()" class="text-xs font-semibold text-sky-600 hover:text-sky-700 dark:text-sky-400 hover:underline cursor-pointer flex items-center space-x-1">
|
||||||
|
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Alles herstellen (alle mappen aan)</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeManageFoldersModal()" class="px-4 py-2 rounded-xl text-xs font-bold bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white transition cursor-pointer">
|
||||||
|
Sluiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let allMessages = <?= $jsonMessages ?>;
|
let mailFolders = <?= $jsonFolders ?>;
|
||||||
|
let disabledFolderIds = new Set(<?= $jsonDisabledFolders ?>);
|
||||||
|
let folderMessagesMap = {
|
||||||
|
'inbox': <?= $jsonMessages ?>
|
||||||
|
};
|
||||||
|
let expandedFolders = new Set(['inbox']); // Inbox is standaard geopend, overige mappen ingeklapt
|
||||||
|
let loadingFolders = new Set();
|
||||||
let activeFilter = 'all'; // 'all' | 'unread'
|
let activeFilter = 'all'; // 'all' | 'unread'
|
||||||
let selectedMessage = null;
|
let selectedMessage = null;
|
||||||
|
|
||||||
@@ -485,26 +613,127 @@ function setMailFilter(filter) {
|
|||||||
const btnUnread = document.getElementById('filterBtn_unread');
|
const btnUnread = document.getElementById('filterBtn_unread');
|
||||||
|
|
||||||
if (filter === 'all') {
|
if (filter === 'all') {
|
||||||
btnAll.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs transition";
|
btnAll.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs transition cursor-pointer";
|
||||||
btnUnread.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition";
|
btnUnread.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition cursor-pointer";
|
||||||
} else {
|
} else {
|
||||||
btnUnread.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs transition";
|
btnUnread.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs transition cursor-pointer";
|
||||||
btnAll.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition";
|
btnAll.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition cursor-pointer";
|
||||||
|
|
||||||
|
// Bij ongelezen filter: klap automatisch alle actieve mappen met ongelezen mails open!
|
||||||
|
mailFolders.forEach(f => {
|
||||||
|
if (disabledFolderIds.has(f.id)) return;
|
||||||
|
const msgs = folderMessagesMap[f.id];
|
||||||
|
const hasUnread = msgs ? msgs.some(m => !m.is_read) : ((f.unread_count || 0) > 0);
|
||||||
|
if (hasUnread) {
|
||||||
|
expandedFolders.add(f.id);
|
||||||
|
if (!folderMessagesMap[f.id]) {
|
||||||
|
loadFolderMessagesInBackground(f.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMailSearch();
|
renderFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMailSearch() {
|
function applyMailSearch() {
|
||||||
const q = (document.getElementById('mailSearchInput')?.value || '').toLowerCase().trim();
|
renderFolders();
|
||||||
let filtered = allMessages;
|
|
||||||
|
|
||||||
if (activeFilter === 'unread') {
|
|
||||||
filtered = filtered.filter(m => !m.is_read);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFolderMessagesInBackground(folderId) {
|
||||||
|
if (loadingFolders.has(folderId) || folderMessagesMap[folderId]) return;
|
||||||
|
loadingFolders.add(folderId);
|
||||||
|
renderFolders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mail.php?action=get_folder_messages&folder_id=${encodeURIComponent(folderId)}&limit=30`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && Array.isArray(data.messages)) {
|
||||||
|
folderMessagesMap[folderId] = data.messages;
|
||||||
|
const fObj = mailFolders.find(f => f.id === folderId);
|
||||||
|
if (fObj && typeof data.unread_count === 'number') {
|
||||||
|
fObj.unread_count = data.unread_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fout bij ophalen berichten voor map ' + folderId, e);
|
||||||
|
} finally {
|
||||||
|
loadingFolders.delete(folderId);
|
||||||
|
updateTotalCounters();
|
||||||
|
renderFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFolder(folderId) {
|
||||||
|
if (expandedFolders.has(folderId)) {
|
||||||
|
expandedFolders.delete(folderId);
|
||||||
|
renderFolders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedFolders.add(folderId);
|
||||||
|
|
||||||
|
if (!folderMessagesMap[folderId]) {
|
||||||
|
loadingFolders.add(folderId);
|
||||||
|
renderFolders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mail.php?action=get_folder_messages&folder_id=${encodeURIComponent(folderId)}&limit=30`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && Array.isArray(data.messages)) {
|
||||||
|
folderMessagesMap[folderId] = data.messages;
|
||||||
|
const fObj = mailFolders.find(f => f.id === folderId);
|
||||||
|
if (fObj && typeof data.unread_count === 'number') {
|
||||||
|
fObj.unread_count = data.unread_count;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
folderMessagesMap[folderId] = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
folderMessagesMap[folderId] = [];
|
||||||
|
} finally {
|
||||||
|
loadingFolders.delete(folderId);
|
||||||
|
updateTotalCounters();
|
||||||
|
renderFolders();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
renderFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolders() {
|
||||||
|
const container = document.getElementById('mailFoldersContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const q = (document.getElementById('mailSearchInput')?.value || '').toLowerCase().trim();
|
||||||
|
let totalVisible = 0;
|
||||||
|
let visibleFoldersCount = 0;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
mailFolders.forEach(folder => {
|
||||||
|
if (disabledFolderIds.has(folder.id)) {
|
||||||
|
return; // Deze map is uitgeschakeld/verborgen door de gebruiker
|
||||||
|
}
|
||||||
|
visibleFoldersCount++;
|
||||||
|
|
||||||
|
const isInbox = (folder.id === 'inbox' || folder.is_inbox);
|
||||||
|
const isExpanded = expandedFolders.has(folder.id);
|
||||||
|
const isLoading = loadingFolders.has(folder.id);
|
||||||
|
const msgs = folderMessagesMap[folder.id] || [];
|
||||||
|
|
||||||
|
let unreadCount = 0;
|
||||||
|
if (folderMessagesMap[folder.id]) {
|
||||||
|
unreadCount = msgs.filter(m => !m.is_read).length;
|
||||||
|
} else {
|
||||||
|
unreadCount = folder.unread_count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filteredMsgs = msgs;
|
||||||
|
if (activeFilter === 'unread') {
|
||||||
|
filteredMsgs = filteredMsgs.filter(m => !m.is_read);
|
||||||
|
}
|
||||||
if (q) {
|
if (q) {
|
||||||
filtered = filtered.filter(m => {
|
filteredMsgs = filteredMsgs.filter(m => {
|
||||||
const sub = (m.subject || '').toLowerCase();
|
const sub = (m.subject || '').toLowerCase();
|
||||||
const from = (m.from_name || '').toLowerCase() + ' ' + (m.from_email || '').toLowerCase();
|
const from = (m.from_name || '').toLowerCase() + ' ' + (m.from_email || '').toLowerCase();
|
||||||
const prev = (m.body_preview || '').toLowerCase();
|
const prev = (m.body_preview || '').toLowerCase();
|
||||||
@@ -512,27 +741,76 @@ function applyMailSearch() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMailList(filtered);
|
if (isExpanded) {
|
||||||
|
totalVisible += filteredMsgs.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMailList(list) {
|
const folderTotal = folder.total_count || msgs.length;
|
||||||
const container = document.getElementById('mailListContainer');
|
|
||||||
const countEl = document.getElementById('visibleMailCount');
|
|
||||||
if (countEl) countEl.innerText = list.length;
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
html += `
|
||||||
container.innerHTML = `
|
<div class="rounded-2xl border border-slate-200/80 dark:border-slate-800/80 bg-white/70 dark:bg-slate-900/40 overflow-hidden shadow-2xs">
|
||||||
<div class="text-center py-12 text-slate-400 text-xs">
|
<!-- Folder Header Accordion Knop -->
|
||||||
<i data-lucide="inbox" class="w-8 h-8 text-slate-300 dark:text-slate-700 mx-auto mb-2"></i>
|
<div onclick="toggleFolder('${folder.id}')" class="w-full flex items-center justify-between p-3 select-none transition cursor-pointer ${isInbox ? 'bg-slate-100/90 dark:bg-slate-800/80' : 'bg-slate-50/80 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/70'}">
|
||||||
<p>Geen berichten gevonden.</p>
|
<div class="flex items-center space-x-2.5 min-w-0 ${folder.is_child ? 'pl-3' : ''}">
|
||||||
|
<div class="w-7 h-7 rounded-xl ${isInbox ? 'bg-sky-600 text-white shadow-xs' : (folder.is_child ? 'bg-slate-100 dark:bg-slate-800 text-slate-500' : 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300')} flex items-center justify-center flex-shrink-0">
|
||||||
|
<i data-lucide="${isInbox ? 'inbox' : (folder.is_child ? 'corner-down-right' : 'folder')}" class="w-3.5 h-3.5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1.5 truncate">
|
||||||
|
<span class="text-xs font-bold text-slate-900 dark:text-white truncate">${escapeHtml(folder.name)}</span>
|
||||||
|
<span class="text-[10px] text-slate-400 font-semibold">(${folderTotal})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1.5 flex-shrink-0 ml-2" onclick="event.stopPropagation()">
|
||||||
|
${unreadCount > 0 ? `<span class="px-2 py-0.5 rounded-full text-[10px] font-black bg-sky-100 dark:bg-sky-950/70 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/30">${unreadCount} ongelezen</span>` : ''}
|
||||||
|
${!isInbox ? `
|
||||||
|
<button type="button" onclick="disableFolder('${folder.id}', event)" title="Map uitschakelen (verbergen uit overzicht)" class="p-1 rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 dark:hover:text-rose-400 transition cursor-pointer">
|
||||||
|
<i data-lucide="eye-off" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button type="button" onclick="toggleFolder('${folder.id}')" title="In- of uitklappen" class="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition cursor-pointer">
|
||||||
|
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder Berichten Inhoud -->
|
||||||
|
<div class="${isExpanded ? '' : 'hidden'} p-2 space-y-1.5 bg-slate-50/40 dark:bg-[#0c0f17]/30 border-t border-slate-100 dark:border-slate-800/80">
|
||||||
|
${isLoading ? `
|
||||||
|
<div class="p-4 text-center text-xs text-slate-400 flex items-center justify-center space-x-1.5">
|
||||||
|
<i data-lucide="loader-2" class="w-4 h-4 animate-spin text-sky-500"></i>
|
||||||
|
<span>Berichten ophalen uit Outlook...</span>
|
||||||
|
</div>
|
||||||
|
` : (filteredMsgs.length === 0 ? `
|
||||||
|
<div class="py-4 text-center text-slate-400 text-xs italic">
|
||||||
|
${activeFilter === 'unread' ? 'Geen ongelezen berichten in deze map.' : 'Geen berichten gevonden.'}
|
||||||
|
</div>
|
||||||
|
` : filteredMsgs.map(m => renderMessageCardHtml(m)).join(''))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visibleFoldersCount === 0) {
|
||||||
|
html = `
|
||||||
|
<div class="p-6 text-center text-slate-400 space-y-2">
|
||||||
|
<i data-lucide="folder-x" class="w-8 h-8 mx-auto text-slate-300 dark:text-slate-600"></i>
|
||||||
|
<p class="text-xs font-semibold">Alle mappen zijn momenteel uitgeschakeld of verborgen.</p>
|
||||||
|
<button type="button" onclick="openManageFoldersModal()" class="text-xs text-sky-600 dark:text-sky-400 font-bold hover:underline cursor-pointer">
|
||||||
|
Mappen beheren & herstellen →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
container.innerHTML = html;
|
||||||
list.forEach(m => {
|
|
||||||
|
const countEl = document.getElementById('visibleMailCount');
|
||||||
|
if (countEl) countEl.innerText = totalVisible;
|
||||||
|
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageCardHtml(m) {
|
||||||
const isSelected = selectedMessage && selectedMessage.id === m.id;
|
const isSelected = selectedMessage && selectedMessage.id === m.id;
|
||||||
const isUnread = !m.is_read;
|
const isUnread = !m.is_read;
|
||||||
const initials = (m.from_name || 'NL').split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
const initials = (m.from_name || 'NL').split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
|
||||||
@@ -541,7 +819,7 @@ function renderMailList(list) {
|
|||||||
? 'bg-sky-50 dark:bg-sky-950/40 border-sky-400 dark:border-sky-700 shadow-xs'
|
? 'bg-sky-50 dark:bg-sky-950/40 border-sky-400 dark:border-sky-700 shadow-xs'
|
||||||
: (isUnread ? 'bg-white dark:bg-[#111624] border-slate-200 dark:border-slate-800 hover:border-slate-300 font-semibold' : 'bg-slate-50/60 dark:bg-[#0c0f17]/60 border-slate-100 dark:border-slate-800/60 text-slate-600 dark:text-slate-400');
|
: (isUnread ? 'bg-white dark:bg-[#111624] border-slate-200 dark:border-slate-800 hover:border-slate-300 font-semibold' : 'bg-slate-50/60 dark:bg-[#0c0f17]/60 border-slate-100 dark:border-slate-800/60 text-slate-600 dark:text-slate-400');
|
||||||
|
|
||||||
html += `
|
return `
|
||||||
<div onclick="selectMail('${m.id}')" class="group relative p-3 rounded-2xl border transition cursor-pointer ${cardClass}">
|
<div onclick="selectMail('${m.id}')" class="group relative p-3 rounded-2xl border transition cursor-pointer ${cardClass}">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex items-center space-x-2 truncate">
|
<div class="flex items-center space-x-2 truncate">
|
||||||
@@ -568,18 +846,27 @@ function renderMailList(list) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
function renderMailList() {
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
renderFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMail(id) {
|
async function selectMail(id) {
|
||||||
const found = allMessages.find(m => m.id === id);
|
let found = null;
|
||||||
|
let foundFolderId = null;
|
||||||
|
for (const fId in folderMessagesMap) {
|
||||||
|
const m = (folderMessagesMap[fId] || []).find(x => x.id === id);
|
||||||
|
if (m) {
|
||||||
|
found = m;
|
||||||
|
foundFolderId = fId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
|
|
||||||
selectedMessage = found;
|
selectedMessage = found;
|
||||||
renderMailList(allMessages); // Update actieve styling
|
renderFolders(); // Update actieve styling
|
||||||
|
|
||||||
// Toon detail container
|
// Toon detail container
|
||||||
document.getElementById('noMailSelectedState').classList.add('hidden');
|
document.getElementById('noMailSelectedState').classList.add('hidden');
|
||||||
@@ -607,9 +894,13 @@ async function selectMail(id) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success && data.message) {
|
if (data.success && data.message) {
|
||||||
renderMailBodyContent(bodyEl, data.message.body_html, data.message.body_text || found.body_preview);
|
renderMailBodyContent(bodyEl, data.message.body_html, data.message.body_text || found.body_preview);
|
||||||
|
if (!found.is_read) {
|
||||||
found.is_read = true;
|
found.is_read = true;
|
||||||
updateUnreadCount();
|
const fObj = mailFolders.find(f => f.id === foundFolderId);
|
||||||
renderMailList(allMessages);
|
if (fObj && fObj.unread_count > 0) fObj.unread_count--;
|
||||||
|
updateTotalCounters();
|
||||||
|
renderFolders();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
bodyEl.innerHTML = `<div class="p-4 rounded-xl bg-red-50 text-red-700 text-xs">${data.error || 'Kon bericht niet inladen.'}</div>`;
|
bodyEl.innerHTML = `<div class="p-4 rounded-xl bg-red-50 text-red-700 text-xs">${data.error || 'Kon bericht niet inladen.'}</div>`;
|
||||||
}
|
}
|
||||||
@@ -732,9 +1023,43 @@ function renderMailBodyContent(container, htmlContent, plainContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateUnreadCount() {
|
function updateUnreadCount() {
|
||||||
const unread = allMessages.filter(m => !m.is_read).length;
|
updateTotalCounters();
|
||||||
const el = document.getElementById('countUnread');
|
}
|
||||||
if (el) el.innerText = unread;
|
|
||||||
|
function updateTotalCounters() {
|
||||||
|
let totalUnread = 0;
|
||||||
|
let totalAll = 0;
|
||||||
|
|
||||||
|
mailFolders.forEach(f => {
|
||||||
|
if (disabledFolderIds.has(f.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msgs = folderMessagesMap[f.id];
|
||||||
|
if (msgs) {
|
||||||
|
totalUnread += msgs.filter(m => !m.is_read).length;
|
||||||
|
totalAll += msgs.length;
|
||||||
|
} else {
|
||||||
|
totalUnread += (f.unread_count || 0);
|
||||||
|
totalAll += (f.total_count || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const elUnread = document.getElementById('countUnread');
|
||||||
|
if (elUnread) elUnread.innerText = totalUnread;
|
||||||
|
|
||||||
|
const elAll = document.getElementById('countAll');
|
||||||
|
if (elAll) elAll.innerText = totalAll;
|
||||||
|
|
||||||
|
const indicator = document.getElementById('disabledFoldersIndicator');
|
||||||
|
if (indicator) {
|
||||||
|
const disabledCount = disabledFolderIds.size;
|
||||||
|
if (disabledCount > 0) {
|
||||||
|
indicator.innerText = `${disabledCount} verborgen`;
|
||||||
|
indicator.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
indicator.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMobileDetail() {
|
function closeMobileDetail() {
|
||||||
@@ -833,6 +1158,58 @@ function closeOutsideWorkhoursModalMail(choice) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableUserBoards = <?= json_encode($userBoards, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?> || [];
|
||||||
|
|
||||||
|
function toggleMailBoardOptions(show) {
|
||||||
|
const wrapper = document.getElementById('mailBoardOptionsWrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
if (show) {
|
||||||
|
wrapper.classList.remove('hidden');
|
||||||
|
const boardSelect = document.getElementById('mailPlanBoardSelect');
|
||||||
|
if (boardSelect && boardSelect.value) {
|
||||||
|
onMailBoardSelectChange(boardSelect.value);
|
||||||
|
}
|
||||||
|
const planDate = document.getElementById('planMailDate')?.value || '';
|
||||||
|
const startInput = document.getElementById('mailTimelineStartDate');
|
||||||
|
const endInput = document.getElementById('mailTimelineEndDate');
|
||||||
|
if (startInput && !startInput.value) startInput.value = planDate;
|
||||||
|
if (endInput && !endInput.value) endInput.value = planDate;
|
||||||
|
} else {
|
||||||
|
wrapper.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMailBoardSelectChange(boardId) {
|
||||||
|
const laneSelect = document.getElementById('mailPlanLaneSelect');
|
||||||
|
if (!laneSelect) return;
|
||||||
|
laneSelect.innerHTML = '';
|
||||||
|
|
||||||
|
const board = availableUserBoards.find(b => String(b.id) === String(boardId));
|
||||||
|
if (board && board.lanes && board.lanes.length > 0) {
|
||||||
|
board.lanes.forEach((lane, idx) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = lane.id;
|
||||||
|
opt.textContent = lane.title;
|
||||||
|
if (idx === 0) opt.selected = true;
|
||||||
|
laneSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = 'Geen kolommen beschikbaar';
|
||||||
|
laneSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMailTimelineDates(show) {
|
||||||
|
const wrapper = document.getElementById('mailTimelineDatesWrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
if (show) wrapper.classList.remove('hidden');
|
||||||
|
else wrapper.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openPlanMailModal() {
|
function openPlanMailModal() {
|
||||||
if (!selectedMessage) return;
|
if (!selectedMessage) return;
|
||||||
document.getElementById('planMailId').value = selectedMessage.id;
|
document.getElementById('planMailId').value = selectedMessage.id;
|
||||||
@@ -843,6 +1220,22 @@ function openPlanMailModal() {
|
|||||||
const notice = document.getElementById('mailPlanOpenBlockNotice');
|
const notice = document.getElementById('mailPlanOpenBlockNotice');
|
||||||
if (notice) notice.classList.add('hidden');
|
if (notice) notice.classList.add('hidden');
|
||||||
|
|
||||||
|
const planDate = document.getElementById('planMailDate')?.value || '';
|
||||||
|
const addToBoardCb = document.getElementById('mailPlanAddToBoard');
|
||||||
|
if (addToBoardCb) {
|
||||||
|
addToBoardCb.checked = false;
|
||||||
|
toggleMailBoardOptions(false);
|
||||||
|
}
|
||||||
|
const schedTimelineCb = document.getElementById('mailPlanScheduleOnTimeline');
|
||||||
|
if (schedTimelineCb) {
|
||||||
|
schedTimelineCb.checked = true;
|
||||||
|
toggleMailTimelineDates(true);
|
||||||
|
}
|
||||||
|
const tStart = document.getElementById('mailTimelineStartDate');
|
||||||
|
const tEnd = document.getElementById('mailTimelineEndDate');
|
||||||
|
if (tStart) tStart.value = planDate;
|
||||||
|
if (tEnd) tEnd.value = planDate;
|
||||||
|
|
||||||
updatePlanMailEndTimeBadge();
|
updatePlanMailEndTimeBadge();
|
||||||
|
|
||||||
const modal = document.getElementById('planMailModal');
|
const modal = document.getElementById('planMailModal');
|
||||||
@@ -910,6 +1303,13 @@ async function submitPlanEmail(e, options = {}) {
|
|||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addToBoard = document.getElementById('mailPlanAddToBoard')?.checked || false;
|
||||||
|
const boardId = parseInt(document.getElementById('mailPlanBoardSelect')?.value || '0', 10);
|
||||||
|
const laneId = parseInt(document.getElementById('mailPlanLaneSelect')?.value || '0', 10);
|
||||||
|
const scheduleOnTimeline = document.getElementById('mailPlanScheduleOnTimeline')?.checked || false;
|
||||||
|
const timelineStartDate = document.getElementById('mailTimelineStartDate')?.value || planDate;
|
||||||
|
const timelineEndDate = document.getElementById('mailTimelineEndDate')?.value || timelineStartDate;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/mail.php?action=plan_email', {
|
const res = await fetch('/api/mail.php?action=plan_email', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -922,7 +1322,13 @@ async function submitPlanEmail(e, options = {}) {
|
|||||||
duration_minutes: durationMinutes,
|
duration_minutes: durationMinutes,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
web_link: webLink,
|
web_link: webLink,
|
||||||
sender: sender
|
sender: sender,
|
||||||
|
add_to_board: addToBoard,
|
||||||
|
board_id: boardId,
|
||||||
|
lane_id: laneId,
|
||||||
|
schedule_on_timeline: scheduleOnTimeline,
|
||||||
|
timeline_start_date: timelineStartDate,
|
||||||
|
timeline_end_date: timelineEndDate
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1049,17 +1455,30 @@ async function refreshMailbox() {
|
|||||||
if (icon) icon.classList.add('animate-spin');
|
if (icon) icon.classList.add('animate-spin');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/mail.php?action=get_inbox&refresh=1');
|
const res = await fetch('/api/mail.php?action=get_folders&refresh=1');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success && data.messages) {
|
if (data.success && Array.isArray(data.folders)) {
|
||||||
allMessages = data.messages;
|
mailFolders = data.folders;
|
||||||
applyMailSearch();
|
|
||||||
updateUnreadCount();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promises = [...expandedFolders].map(async (fId) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/mail.php?action=get_folder_messages&folder_id=${encodeURIComponent(fId)}&limit=30&refresh=1`);
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.success && Array.isArray(d.messages)) {
|
||||||
|
folderMessagesMap[fId] = d.messages;
|
||||||
|
const fObj = mailFolders.find(f => f.id === fId);
|
||||||
|
if (fObj && typeof d.unread_count === 'number') fObj.unread_count = d.unread_count;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.warn('Mail refresh error:', err);
|
console.warn('Mail refresh error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
if (icon) icon.classList.remove('animate-spin');
|
if (icon) icon.classList.remove('animate-spin');
|
||||||
|
updateTotalCounters();
|
||||||
|
renderFolders();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1492,115 @@ function escapeHtml(str) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mailmappen beheren & in/uitschakelen
|
||||||
|
function disableFolder(folderId, e) {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
toggleFolderDisabledState(folderId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManageFoldersModal() {
|
||||||
|
renderManageFoldersList();
|
||||||
|
const modal = document.getElementById('manageFoldersModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeManageFoldersModal() {
|
||||||
|
const modal = document.getElementById('manageFoldersModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManageFoldersList() {
|
||||||
|
const container = document.getElementById('manageFoldersList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
mailFolders.forEach(f => {
|
||||||
|
const isInbox = (f.id === 'inbox' || f.is_inbox);
|
||||||
|
const isDisabled = disabledFolderIds.has(f.id);
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-2xl border ${isDisabled ? 'bg-slate-50 dark:bg-slate-900/30 border-dashed border-slate-200 dark:border-slate-800 opacity-70' : 'bg-white dark:bg-slate-900/60 border-slate-200/80 dark:border-slate-800'} transition">
|
||||||
|
<div class="flex items-center space-x-3 min-w-0 ${f.is_child ? 'pl-3' : ''}">
|
||||||
|
<div class="w-8 h-8 rounded-xl ${isInbox ? 'bg-sky-600 text-white' : (isDisabled ? 'bg-slate-100 dark:bg-slate-800 text-slate-400' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300')} flex items-center justify-center flex-shrink-0">
|
||||||
|
<i data-lucide="${isInbox ? 'inbox' : (isDisabled ? 'folder-x' : (f.is_child ? 'corner-down-right' : 'folder'))}" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div class="truncate">
|
||||||
|
<div class="flex items-center space-x-1.5">
|
||||||
|
<span class="text-xs font-bold ${isDisabled ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-900 dark:text-white'} truncate">${escapeHtml(f.name)}</span>
|
||||||
|
${isInbox ? '<span class="text-[10px] font-semibold text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-950/60 px-1.5 py-0.5 rounded-md">Standaard Inbox</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-slate-400">${f.total_count || 0} berichten ${f.unread_count ? `• ${f.unread_count} ongelezen` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-shrink-0 ml-3">
|
||||||
|
${isInbox ? `
|
||||||
|
<span class="text-[11px] font-medium text-slate-400 italic pr-2">Altijd actief</span>
|
||||||
|
` : `
|
||||||
|
<button type="button" onclick="toggleFolderDisabledState('${f.id}', ${!isDisabled})" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer ${isDisabled ? 'bg-sky-50 hover:bg-sky-100 text-sky-700 dark:bg-sky-950/60 dark:hover:bg-sky-900/80 dark:text-sky-300' : 'bg-rose-50 hover:bg-rose-100 text-rose-700 dark:bg-rose-950/40 dark:hover:bg-rose-900/60 dark:text-rose-300'}">
|
||||||
|
<i data-lucide="${isDisabled ? 'eye' : 'eye-off'}" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>${isDisabled ? 'Weer Inschakelen' : 'Uitschakelen'}</span>
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFolderDisabledState(folderId, willDisable) {
|
||||||
|
if (folderId === 'inbox') return;
|
||||||
|
|
||||||
|
if (willDisable) {
|
||||||
|
disabledFolderIds.add(folderId);
|
||||||
|
expandedFolders.delete(folderId);
|
||||||
|
} else {
|
||||||
|
disabledFolderIds.delete(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFolders();
|
||||||
|
updateTotalCounters();
|
||||||
|
renderManageFoldersList();
|
||||||
|
|
||||||
|
await saveDisabledFoldersToServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAllFoldersVisibility() {
|
||||||
|
disabledFolderIds.clear();
|
||||||
|
renderFolders();
|
||||||
|
updateTotalCounters();
|
||||||
|
renderManageFoldersList();
|
||||||
|
|
||||||
|
await saveDisabledFoldersToServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDisabledFoldersToServer() {
|
||||||
|
const arr = Array.from(disabledFolderIds);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mail.php?action=save_disabled_folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ disabled_folders: arr })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
console.warn('Opslaan uitgeschakelde mappen mislukt:', data.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fout bij opslaan mappen status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toetsenbordnavigatie voor alle modals (Escape & Enter)
|
// Toetsenbordnavigatie voor alle modals (Escape & Enter)
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -1084,6 +1612,7 @@ document.addEventListener('keydown', function(e) {
|
|||||||
closePlanMailModal();
|
closePlanMailModal();
|
||||||
closeReplyModal();
|
closeReplyModal();
|
||||||
closeForwardModal();
|
closeForwardModal();
|
||||||
|
closeManageFoldersModal();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
const outsideModal = document.getElementById('outsideWorkhoursModalMail');
|
const outsideModal = document.getElementById('outsideWorkhoursModalMail');
|
||||||
if (outsideModal && !outsideModal.classList.contains('hidden')) {
|
if (outsideModal && !outsideModal.classList.contains('hidden')) {
|
||||||
@@ -1095,9 +1624,11 @@ document.addEventListener('keydown', function(e) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
applyMailSearch();
|
updateTotalCounters();
|
||||||
if (allMessages && allMessages.length > 0) {
|
renderFolders();
|
||||||
selectMail(allMessages[0].id);
|
const inboxMsgs = folderMessagesMap['inbox'] || [];
|
||||||
|
if (inboxMsgs.length > 0) {
|
||||||
|
selectMail(inboxMsgs[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Luister naar live websocket/polling event voor nieuwe inkomende e-mails
|
// Luister naar live websocket/polling event voor nieuwe inkomende e-mails
|
||||||
|
|||||||
+1346
-209
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -136,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
elseif ($action === 'save_personal_ai') {
|
elseif ($action === 'save_personal_ai') {
|
||||||
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
||||||
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
|
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
|
||||||
$aiModel = trim($_POST['ai_model'] ?? 'gemini-1.5-pro');
|
$aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
|
||||||
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
|
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
|
||||||
|
|
||||||
// Haal eventueel bestaande integratie op
|
// Haal eventueel bestaande integratie op
|
||||||
@@ -977,9 +977,9 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
||||||
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
<optgroup label="Google Gemini (Aanbevolen)">
|
<optgroup label="Google Gemini (Aanbevolen)">
|
||||||
<option value="gemini-1.5-pro" <?= ($userAiSettings['ai_model'] === 'gemini-1.5-pro' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Aanbevolen)</option>
|
<option value="gemini-2.0-flash" <?= ($userAiSettings['ai_model'] === 'gemini-2.0-flash' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
|
||||||
<option value="gemini-2.0-flash" <?= $userAiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Razendsnel)</option>
|
|
||||||
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
||||||
|
<option value="gemini-1.5-pro" <?= $userAiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="OpenAI">
|
<optgroup label="OpenAI">
|
||||||
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
||||||
@@ -1284,7 +1284,7 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
Centraal overzicht van alle actieve inlogapparaten conform ISO 27001 en SOC 2 richtlijnen.
|
Centraal overzicht van alle actieve inlogapparaten conform ISO 27001 en SOC 2 richtlijnen (maximaal 20 actieve sessies).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-5
@@ -71,9 +71,7 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
|
|||||||
Beheerder
|
Beheerder
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="boardDescriptionText" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p id="boardDescriptionText" class="hidden text-xs text-slate-500 dark:text-slate-400 mt-0.5"></p>
|
||||||
Gantt-planning, mijlpalen & datumbereiken
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -534,7 +532,14 @@ async function loadBoardData(boardId) {
|
|||||||
if (data.success && data.has_board) {
|
if (data.success && data.has_board) {
|
||||||
currentBoardData = data;
|
currentBoardData = data;
|
||||||
|
|
||||||
document.getElementById('boardDescriptionText').innerText = data.board.description || 'Geen toelichting';
|
const descEl = document.getElementById('boardDescriptionText');
|
||||||
|
if (data.board.description && data.board.description.trim() !== '') {
|
||||||
|
descEl.innerText = data.board.description;
|
||||||
|
descEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
descEl.innerText = '';
|
||||||
|
descEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Beheerdersbadge
|
// Beheerdersbadge
|
||||||
const badge = document.getElementById('boardRoleBadge');
|
const badge = document.getElementById('boardRoleBadge');
|
||||||
@@ -1095,13 +1100,14 @@ function openCardModal(cardId = null) {
|
|||||||
if (hasSched) {
|
if (hasSched) {
|
||||||
document.getElementById('cardStartDate').value = foundCard.start_date || '';
|
document.getElementById('cardStartDate').value = foundCard.start_date || '';
|
||||||
document.getElementById('cardEndDate').value = foundCard.end_date || '';
|
document.getElementById('cardEndDate').value = foundCard.end_date || '';
|
||||||
document.getElementById('cardSyncOutlook').checked = !!foundCard.outlook_event_id;
|
document.getElementById('cardSyncOutlook').checked = foundCard.outlook_event_id ? true : true; // Standaard gesynchroniseerd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
titleEl.innerText = 'Nieuwe Taak Toevoegen';
|
titleEl.innerText = 'Nieuwe Taak Toevoegen';
|
||||||
deleteBtn.classList.add('hidden');
|
deleteBtn.classList.add('hidden');
|
||||||
document.getElementById('cardScheduleToggle').checked = false;
|
document.getElementById('cardScheduleToggle').checked = false;
|
||||||
|
document.getElementById('cardSyncOutlook').checked = true;
|
||||||
toggleScheduleFields(false);
|
toggleScheduleFields(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
Team & Uitnodigingen
|
Team & Uitnodigingen
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
|
||||||
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
|
Beheer collega's, rollen en uitnodigingslinks voor toegang tot de Fociz omgeving.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user