Compare commits

104 Commits

Author SHA1 Message Date
NiekRabelink 091de8bf95 Update composer.json 2026-10-21 16:12:01 +00:00
NiekRabelink 3312c08ea1 Update composer.json 2026-10-21 16:04:13 +00:00
NiekRabelink 39a41fa207 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-10-21 17:06:40 +02:00
NiekRabelink 78369a44c2 Php version e.d. 2026-10-21 17:06:36 +02:00
NiekRabelink 07988b3e1d Rename block 2026-10-05 11:01:25 +02:00
NiekRabelink 0c466972c2 fix(env): lock production hostnames to live environment and live database 2026-10-05 10:39:17 +02:00
NiekRabelink dc6b55e4f9 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-10-05 10:32:44 +02:00
NiekRabelink ef116be9b8 Simple todo 2026-10-05 10:32:30 +02:00
NiekRabelink 65d5a4d4a3 Merge branch 'main' into dev 2026-09-23 10:52:42 +02:00
NiekRabelink a4824bb440 fix(brainstorm): auto-queue and retry post-it generation with fallback starter cards 2026-09-23 10:52:29 +02:00
NiekRabelink e45f565365 Merge branch 'main' into dev (sync AI resiliency & fallbacks) 2026-09-23 10:50:26 +02:00
NiekRabelink 3dc09a11a7 fix(ai): prioritize flash models, prevent high-demand blocks, and add resilient brainstorm fallbacks 2026-09-23 10:49:52 +02:00
NiekRabelink 86041b665d Ensure all fallback AI models are tried before giving up 2026-09-23 10:34:18 +02:00
NiekRabelink f18f7d5794 Merge pull request 'Implement intelligent fallback for AI requests during high demand and rate limiting' (#98) from dev into main
Reviewed-on: #98
2026-09-23 08:24:46 +00:00
NiekRabelink 45600040ea Implement intelligent fallback for AI requests during high demand and rate limiting 2026-09-23 10:23:10 +02:00
NiekRabelink 660f196c60 Merge pull request 'Enhance IP verification process: add email resend functionality and improve error logging' (#97) from dev into main
Reviewed-on: #97
2026-09-23 07:22:40 +00:00
NiekRabelink 32974cdb31 Enhance IP verification process: add email resend functionality and improve error logging 2026-09-23 09:21:17 +02:00
NiekRabelink 8ddae4d883 Merge pull request 'Snelheid' (#96) from dev into main
Reviewed-on: #96
2026-09-16 11:54:05 +00:00
NiekRabelink d4e4b69e02 Snelheid 2026-09-16 13:53:54 +02:00
NiekRabelink 65f81d620c Merge pull request 'Change mail options' (#95) from dev into main
Reviewed-on: #95
2026-09-16 10:39:19 +00:00
NiekRabelink 0b5ca54db9 Change mail options 2026-09-16 12:39:06 +02:00
NiekRabelink 20d8f93f77 Merge pull request 'Fix speed and error' (#94) from dev into main
Reviewed-on: #94
2026-09-16 10:23:33 +00:00
NiekRabelink bda7b311e3 Fix speed and error 2026-09-16 12:22:27 +02:00
NiekRabelink 85850f47ad Merge pull request 'Dev' (#93) from dev into main
Reviewed-on: #93
2026-09-15 22:32:58 +00:00
NiekRabelink 45a883319a Outlook changes 2026-09-16 00:32:29 +02:00
NiekRabelink 460fa967ee Jira 28 2026-09-15 22:46:21 +02:00
NiekRabelink e9f54cbe72 Merge pull request 'Jira 27' (#92) from dev into main
Reviewed-on: #92
2026-09-15 20:39:10 +00:00
NiekRabelink 210ecc7b18 Jira 27 2026-09-15 22:36:59 +02:00
NiekRabelink 2d89e19114 Merge pull request 'Jira 26' (#91) from dev into main
Reviewed-on: #91
2026-09-15 20:32:23 +00:00
NiekRabelink 6bc351aaa4 Jira 26 2026-09-15 22:31:53 +02:00
NiekRabelink 1ad383d27b Merge pull request 'Jira 25' (#90) from dev into main
Reviewed-on: #90
2026-09-15 20:12:27 +00:00
NiekRabelink 8498d82e10 Jira 25 2026-09-15 22:12:14 +02:00
NiekRabelink a038d25489 Merge pull request 'Jira 24' (#89) from dev into main
Reviewed-on: #89
2026-09-15 20:05:01 +00:00
NiekRabelink 97d6085c9d Jira 24 2026-09-15 22:04:51 +02:00
NiekRabelink 4a9ba4d76a Merge pull request 'Fix 23' (#88) from dev into main
Reviewed-on: #88
2026-09-15 19:16:08 +00:00
NiekRabelink b800279cd2 Fix 23 2026-09-15 21:15:42 +02:00
NiekRabelink e08b304c38 Merge pull request 'Jira22' (#87) from dev into main
Reviewed-on: #87
2026-09-15 16:40:43 +00:00
NiekRabelink 687b929170 Jira22 2026-09-15 18:40:16 +02:00
NiekRabelink 830fdf1330 Merge pull request 'Filter changes' (#86) from dev into main
Reviewed-on: #86
2026-09-15 14:26:22 +00:00
NiekRabelink 2750fd0681 Filter changes 2026-09-15 16:26:00 +02:00
NiekRabelink d65463dab8 Merge pull request 'Jira repair' (#85) from dev into main
Reviewed-on: #85
2026-09-15 13:42:38 +00:00
NiekRabelink 8cff08f253 Jira repair 2026-09-15 15:42:23 +02:00
NiekRabelink 843b75334e Merge pull request 'Dev' (#84) from dev into main
Reviewed-on: #84
2026-09-15 13:00:29 +00:00
NiekRabelink 9cd457971b Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-09-15 14:49:35 +02:00
NiekRabelink 1358b6a7e9 Jira fix 2026-09-15 13:41:59 +02:00
NiekRabelink aa23ccdd73 Merge pull request 'Jira en admin aanpassingen' (#83) from dev into main
Reviewed-on: #83
2026-09-15 10:31:57 +00:00
NiekRabelink e3af7c7aa5 Jira en admin aanpassingen 2026-09-15 12:30:58 +02:00
NiekRabelink cb8246dba9 Merge pull request 'Jira aanpassingen' (#82) from dev into main
Reviewed-on: #82
2026-09-14 14:24:42 +00:00
NiekRabelink f992ca1ec9 Jira aanpassingen 2026-09-14 16:24:23 +02:00
NiekRabelink 5c4c53922b Merge pull request 'Header center' (#81) from dev into main
Reviewed-on: #81
2026-09-14 11:16:35 +00:00
NiekRabelink 7a85f612b1 Header center 2026-09-14 13:16:22 +02:00
NiekRabelink ee12d54350 Merge pull request 'Header redesign' (#80) from dev into main
Reviewed-on: #80
2026-09-14 10:15:33 +00:00
NiekRabelink e8a6667abd Header redesign 2026-09-14 12:15:18 +02:00
NiekRabelink 7e7f071f04 Merge pull request 'Dashboard' (#79) from dev into main
Reviewed-on: #79
2026-09-14 09:52:31 +00:00
NiekRabelink 3595d58763 Dashboard 2026-09-14 11:52:18 +02:00
NiekRabelink bf0d2f3bca Merge pull request 'Brainstorm huge refactor' (#78) from dev into main
Reviewed-on: #78
2026-09-14 09:11:25 +00:00
NiekRabelink 24fbfb9d82 Brainstorm huge refactor 2026-09-14 11:10:54 +02:00
NiekRabelink 6a4981744f Merge pull request 'Fix brainstorm delete and plan button' (#77) from dev into main
Reviewed-on: #77
2026-09-14 08:29:13 +00:00
NiekRabelink 5a31f0948a Fix brainstorm delete and plan button 2026-09-14 10:29:01 +02:00
NiekRabelink 740b5343c4 Merge pull request 'Dev' (#76) from dev into main
Reviewed-on: #76
2026-09-14 08:08:27 +00:00
NiekRabelink cd6071902d Fix plan button 2026-09-14 10:07:37 +02:00
NiekRabelink 32d27cd457 Auto synch outlook 2026-09-14 09:21:19 +02:00
NiekRabelink 31caf540a3 Smoothness 2026-09-12 22:28:03 +02:00
NiekRabelink 4eaae64eb6 Settings planner 2026-09-12 22:22:01 +02:00
NiekRabelink 6987cab4f8 Merge pull request 'Verbetering postit' (#75) from dev into main
Reviewed-on: #75
2026-09-12 18:17:15 +00:00
NiekRabelink 89b53f6428 Verbetering postit 2026-09-12 20:16:59 +02:00
NiekRabelink fd59cd69c1 Merge pull request 'Postit' (#74) from dev into main
Reviewed-on: #74
2026-09-12 18:13:19 +00:00
NiekRabelink 35fcde49c3 Postit 2026-09-12 20:12:48 +02:00
NiekRabelink 29874f184b Merge pull request 'Fix' (#73) from dev into main
Reviewed-on: #73
2026-09-12 16:13:16 +00:00
NiekRabelink f63ea740bf Fix 2026-09-12 18:12:30 +02:00
NiekRabelink 0bafe0afe9 Merge pull request 'Postits' (#72) from dev into main
Reviewed-on: #72
2026-09-12 15:24:09 +00:00
NiekRabelink 5f15eb67fe Postits 2026-09-12 17:23:52 +02:00
NiekRabelink 880c1bf9bb Merge pull request 'Dev' (#71) from dev into main
Reviewed-on: #71
2026-09-12 15:15:39 +00:00
NiekRabelink 94d6325693 Postits en editable items. 2026-09-12 17:14:47 +02:00
NiekRabelink 7c27949cc2 Brainstorm aanpassingen 2026-09-12 17:04:16 +02:00
NiekRabelink a0d99914e6 Merge pull request 'Dev nieuw' (#70) from dev into main
Reviewed-on: #70
2026-09-12 14:50:06 +00:00
NiekRabelink af80ffdb14 Dev nieuw 2026-09-12 16:49:51 +02:00
NiekRabelink 1c3541e602 Merge pull request 'Ai aanpassingen en slepen in planner blokken' (#69) from dev into main
Reviewed-on: #69
2026-09-12 12:24:36 +00:00
NiekRabelink 5e35f5152d Ai aanpassingen en slepen in planner blokken 2026-09-12 14:24:24 +02:00
NiekRabelink 845216e554 Merge pull request 'Brainstorm fix' (#68) from dev into main
Reviewed-on: #68
2026-09-12 11:45:39 +00:00
NiekRabelink 797aaa76c7 Brainstorm fix 2026-09-12 13:45:01 +02:00
NiekRabelink 0ad186e95f Merge pull request 'Dev' (#67) from dev into main
Reviewed-on: #67
2026-09-12 11:24:10 +00:00
NiekRabelink 8714038db2 Aanpassingen planner en ai 2026-09-12 13:24:00 +02:00
NiekRabelink bc33c0bd1a Icons 2026-09-12 13:14:38 +02:00
NiekRabelink 582ba9871f Merge pull request 'IT882' (#66) from dev into main
Reviewed-on: #66
2026-09-12 11:10:58 +00:00
NiekRabelink 32632dab91 IT882 2026-09-12 13:09:37 +02:00
NiekRabelink 9442dd7b38 Merge pull request 'AI fix' (#65) from dev into main
Reviewed-on: #65
2026-09-12 11:01:08 +00:00
NiekRabelink ab03552c29 AI fix 2026-09-12 12:57:11 +02:00
NiekRabelink 046d344003 Merge pull request 'Ai fix' (#64) from dev into main
Reviewed-on: #64
2026-09-12 10:51:38 +00:00
NiekRabelink 364fd776c4 Ai fix 2026-09-12 12:51:20 +02:00
NiekRabelink a1e9dd19ae Merge pull request 'Planner wijzigingen' (#63) from dev into main
Reviewed-on: #63
2026-09-12 10:50:24 +00:00
NiekRabelink a514eb6521 Planner wijzigingen 2026-09-12 12:49:58 +02:00
NiekRabelink d87a62e1f2 Merge pull request 'Error 500 fix' (#62) from dev into main
Reviewed-on: #62
2026-09-12 09:22:50 +00:00
NiekRabelink f620f20d35 Error 500 fix 2026-09-12 11:19:03 +02:00
NiekRabelink f389f67774 Merge pull request 'Fix' (#61) from dev into main
Reviewed-on: #61
2026-09-12 09:15:45 +00:00
NiekRabelink bb0bf658ed Fix 2026-09-12 11:12:31 +02:00
NiekRabelink a1f2937504 Merge pull request 'Aanpassing planner' (#60) from dev into main
Reviewed-on: #60
2026-09-12 09:04:16 +00:00
NiekRabelink 09ab527a93 Aanpassing planner 2026-09-12 11:04:02 +02:00
NiekRabelink 51658e0aa9 Merge pull request 'Timzone' (#59) from dev into main
Reviewed-on: #59
2026-09-12 08:57:25 +00:00
NiekRabelink 5a5456ffe0 Timzone 2026-09-12 10:57:11 +02:00
NiekRabelink 8e62e73d46 Merge pull request 'Workdays' (#58) from dev into main
Reviewed-on: #58
2026-09-12 08:52:13 +00:00
NiekRabelink bcd85f131b Workdays 2026-09-12 10:45:31 +02:00
NiekRabelink 41d3733f69 Merge pull request 'Last security check' (#57) from dev into main
Reviewed-on: #57
2026-09-12 08:26:59 +00:00
NiekRabelink 245d09e62c Last security check 2026-09-12 10:25:49 +02:00
40 changed files with 13984 additions and 2165 deletions
+1611 -447
View File
File diff suppressed because it is too large Load Diff
+142 -68
View File
@@ -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,11 +88,68 @@ 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',
exit; '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;
}
}
// 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("
@@ -99,10 +157,7 @@ try {
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;
} }
@@ -351,71 +406,86 @@ try {
// 3d. Test AI Verbinding (Gemini Pro / OpenAI) // 3d. Test AI Verbinding (Gemini Pro / OpenAI)
if ($action === 'test_connection') { if ($action === 'test_connection') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST; try {
$rawInput = file_get_contents('php://input');
$tempProvider = !empty($data['provider']) ? trim($data['provider']) : null; $data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : $_POST;
$tempKey = !empty($data['api_key']) ? trim($data['api_key']) : null;
$tempModel = !empty($data['model']) ? trim($data['model']) : null; $tempProvider = !empty($data['provider']) ? trim((string)$data['provider']) : null;
$tempKey = !empty($data['api_key']) ? trim((string)$data['api_key']) : null;
$tempModel = !empty($data['model']) ? trim((string)$data['model']) : null;
// Als er test credentials zijn meegegeven, test direct met die credentials // Als er test credentials zijn meegegeven, test direct met die credentials
if (!empty($tempKey)) { if (!empty($tempKey) && $tempKey !== '••••••••••••••••') {
$testSettings = [ $testSettings = [
'ai_provider' => $tempProvider ?: 'gemini', 'ai_provider' => $tempProvider ?: 'gemini',
'ai_api_key' => $tempKey, 'ai_api_key' => $tempKey,
'ai_model' => $tempModel ?: ($tempProvider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro'), 'ai_model' => $tempModel ?: ($tempProvider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro'),
];
} else {
// Anders haal de geconfigureerde instellingen op van user of org
$testSettings = get_ai_settings($user['id']);
if (!empty($tempModel)) {
$testSettings['ai_model'] = $tempModel;
}
if (!empty($tempProvider)) {
$testSettings['ai_provider'] = $tempProvider;
}
}
if (empty($testSettings['ai_api_key'])) {
echo json_encode([
'success' => false,
'error' => 'Geen API-sleutel ingevuld of geconfigureerd om te testen.',
]);
exit;
}
$provider = strtolower($testSettings['ai_provider'] ?? 'gemini');
$model = $testSettings['ai_model'] ?? ($provider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro');
$t0 = microtime(true);
$testMessages = [
['role' => 'user', 'content' => 'Fociz connectiviteitstest. Reageer enkel met het woord OK.'],
]; ];
} else {
// Anders haal de geconfigureerde instellingen op van user of org
$testSettings = get_ai_settings($user['id']);
}
if (empty($testSettings['ai_api_key'])) { if ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) {
$aiRes = fociz_ai_chat_gemini($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
} else {
$aiRes = fociz_ai_chat_openai($testMessages, ['max_tokens' => 500, 'temperature' => 0.1], $testSettings);
}
$latency = round((microtime(true) - $t0) * 1000);
if (!$aiRes['success']) {
echo json_encode([
'success' => false,
'error' => $aiRes['error'] ?? 'Verbindingstest mislukt.',
'latency_ms' => $latency,
'provider' => $provider,
'model' => $model,
]);
exit;
}
$providerName = ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) ? 'Google Gemini Pro' : 'OpenAI';
echo json_encode([
'success' => true,
'message' => "Verbinding met {$providerName} is succesvol tot stand gebracht! (Response tijd: {$latency}ms)",
'latency_ms' => $latency,
'provider' => $provider,
'model' => $aiRes['model'] ?? $model,
'zero_leak' => true,
]);
exit;
} catch (Throwable $testEx) {
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => 'Geen API-sleutel ingevuld of geconfigureerd om te testen.', 'error' => 'Fout tijdens verbindingstest: ' . $testEx->getMessage(),
]); ]);
exit; exit;
} }
$provider = strtolower($testSettings['ai_provider'] ?? 'gemini');
$model = $testSettings['ai_model'] ?? ($provider === 'openai' ? 'gpt-4o-mini' : 'gemini-1.5-pro');
$t0 = microtime(true);
$testMessages = [
['role' => 'user', 'content' => 'Fociz connectiviteitstest. Reageer enkel met het woord OK.'],
];
if ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) {
$aiRes = fociz_ai_chat_gemini($testMessages, ['max_tokens' => 20, 'temperature' => 0.1], $testSettings);
} else {
$aiRes = fociz_ai_chat_openai($testMessages, ['max_tokens' => 20, 'temperature' => 0.1], $testSettings);
}
$latency = round((microtime(true) - $t0) * 1000);
if (!$aiRes['success']) {
echo json_encode([
'success' => false,
'error' => $aiRes['error'] ?? 'Verbindingstest mislukt.',
'latency_ms' => $latency,
'provider' => $provider,
'model' => $model,
]);
exit;
}
$providerName = ($provider === 'gemini' || strpos(strtolower($model), 'gemini') !== false) ? 'Google Gemini Pro' : 'OpenAI';
echo json_encode([
'success' => true,
'message' => "Verbinding met {$providerName} is succesvol tot stand gebracht! (Response tijd: {$latency}ms)",
'latency_ms' => $latency,
'provider' => $provider,
'model' => $aiRes['model'] ?? $model,
'zero_leak' => true,
]);
exit;
} }
// 4. Slimme dagindeling berekenen voor taken rondom bezette tijdblokken // 4. Slimme dagindeling berekenen voor taken rondom bezette tijdblokken
@@ -599,7 +669,11 @@ try {
} }
echo json_encode(['error' => 'Onbekende actie']); echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) { } catch (Throwable $e) {
error_log("api/ai.php Throwable: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
http_response_code(500); http_response_code(500);
echo json_encode(['error' => $e->getMessage()]); echo json_encode([
'success' => false,
'error' => 'Serverfout bij AI verwerking: ' . $e->getMessage()
]);
} }
+1 -1
View File
@@ -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 {
+241 -1
View File
@@ -113,7 +113,200 @@ try {
exit; exit;
} }
// 4. Omzetten naar Roadmap! // 3b. Kaart verplaatsen naar andere categorie/lane (Drag & Drop)
if ($action === 'move_card') {
$data = json_decode(file_get_contents('php://input'), true);
$cardId = (int)($data['card_id'] ?? 0);
$category = trim($data['category'] ?? '');
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
if (!$cardId || !in_array($category, $validCategories, true)) {
echo json_encode(['error' => 'Ongeldige kaart of categorie']);
exit;
}
// Bepaal kleur behorend bij de nieuwe categorie
$colorMap = [
'Ideeën' => 'amber',
'Knelpunten' => 'rose',
'Oplossingen' => 'teal',
'Quick Wins' => 'indigo'
];
$newColor = $colorMap[$category] ?? 'amber';
$stmt = $db->prepare("UPDATE brainstorm_cards SET category = ?, color = ? WHERE id = ?");
$stmt->execute([$category, $newColor, $cardId]);
echo json_encode([
'success' => true,
'card_id' => $cardId,
'category' => $category,
'color' => $newColor
]);
exit;
}
// 3c. Kaart bewerken (Inhoud, Categorie en/of Kleur)
if ($action === 'update_card') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$cardId = (int)($data['card_id'] ?? 0);
$content = trim($data['content'] ?? '');
$category = trim($data['category'] ?? '');
if (!$cardId || empty($content)) {
echo json_encode(['error' => 'Kaart ID en inhoud zijn verplicht']);
exit;
}
$colorMap = [
'Ideeën' => 'amber',
'Knelpunten' => 'rose',
'Oplossingen' => 'teal',
'Quick Wins' => 'indigo'
];
$color = $colorMap[$category] ?? 'amber';
if (!empty($category)) {
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ?, category = ?, color = ? WHERE id = ?");
$stmt->execute([$content, $category, $color, $cardId]);
} else {
$stmt = $db->prepare("UPDATE brainstorm_cards SET content = ? WHERE id = ?");
$stmt->execute([$content, $cardId]);
}
echo json_encode(['success' => true, 'card_id' => $cardId, 'content' => $content, 'category' => $category, 'color' => $color]);
exit;
}
// 3d. Kaart toewijzen aan collega en inplannen in agenda (Vrij blok)
if ($action === 'assign_and_schedule') {
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$cardId = (int)($data['card_id'] ?? 0);
$assigneeId = !empty($data['assignee_id']) ? (int)$data['assignee_id'] : null;
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : null;
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : null;
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : null;
$durationMinutes = (int)($data['duration_minutes'] ?? 30);
$syncOutlook = !empty($data['sync_outlook']);
if (!$cardId) {
echo json_encode(['error' => 'Kaart ID ontbreekt']);
exit;
}
$stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?");
$stmtCard->execute([$cardId]);
$card = $stmtCard->fetch();
if (!$card) {
echo json_encode(['error' => 'Kaart niet gevonden']);
exit;
}
// Bereken eindtijd indien nodig
if ($startTime && !$endTime && $durationMinutes > 0) {
$tParts = explode(':', $startTime);
$startMins = ((int)$tParts[0] * 60) + (int)($tParts[1] ?? 0);
$endMins = $startMins + $durationMinutes;
$endTime = sprintf('%02d:%02d', floor($endMins / 60), $endMins % 60);
}
$createdTaskId = null;
$outlookSyncMsg = null;
// Als er een datum & tijd is gekozen én een collega (of huidige gebruiker)
if ($assigneeId && $planDate && $startTime) {
// Zoek of maak daily plan
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$assigneeId, $planDate]);
$planId = (int)$db->lastInsertId();
$taskTitle = "Brainstorm taak: " . mb_substr($card['content'], 0, 75);
$taskDesc = "Afkomstig van Brainstormsessie. Inhoud:\n" . $card['content'];
$source = 'BRAINSTORM';
$externalId = null;
$externalUrl = null;
// Indien Outlook sync gewenst is
if ($syncOutlook) {
require_once __DIR__ . '/../includes/integrations/microsoft.php';
try {
$msResult = microsoft_create_event($assigneeId, [
'subject' => $taskTitle,
'description' => $taskDesc,
'date' => $planDate,
'start_time' => $startTime,
'end_time' => $endTime,
'duration_minutes' => $durationMinutes,
]);
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
$source = 'OUTLOOK';
$externalId = $msResult['event_id'];
$externalUrl = $msResult['web_link'] ?? null;
$outlookSyncMsg = 'Ingepland in Outlook 365 agenda.';
}
} catch (Throwable $t) {
// stil doorgaan
}
}
// Maak of update taak
if (!empty($card['scheduled_task_id'])) {
$stmtUpdateTask = $db->prepare("
UPDATE tasks
SET user_id = ?, daily_plan_id = ?, title = ?, description = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ?
");
$stmtUpdateTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $card['scheduled_task_id']]);
$createdTaskId = (int)$card['scheduled_task_id'];
} else {
$stmtInsertTask = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
VALUES (?, ?, ?, ?, 'P2', ?, ?, ?, 'TODO', ?, ?, ?)
");
$stmtInsertTask->execute([$assigneeId, $planId, $taskTitle, $taskDesc, $startTime, $endTime, $durationMinutes, $source, $externalId, $externalUrl]);
$createdTaskId = (int)$db->lastInsertId();
}
}
// Update brainstorm kaart
$stmtUpdateCard = $db->prepare("
UPDATE brainstorm_cards
SET assignee_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?, scheduled_task_id = ?
WHERE id = ?
");
$stmtUpdateCard->execute([$assigneeId, $planDate, $startTime, $endTime, $createdTaskId, $cardId]);
// Haal assignee naam & avatar op
$assigneeName = null;
$assigneeAvatar = null;
if ($assigneeId) {
$stmtUser = $db->prepare("SELECT name, avatar_url FROM users WHERE id = ?");
$stmtUser->execute([$assigneeId]);
$usr = $stmtUser->fetch();
if ($usr) {
$assigneeName = $usr['name'];
$assigneeAvatar = $usr['avatar_url'];
}
}
echo json_encode([
'success' => true,
'card_id' => $cardId,
'assignee_id' => $assigneeId,
'assignee_name' => $assigneeName,
'assignee_avatar' => $assigneeAvatar,
'scheduled_date' => $planDate,
'scheduled_start_time' => $startTime,
'scheduled_end_time' => $endTime,
'scheduled_task_id' => $createdTaskId,
'outlook_message' => $outlookSyncMsg
]);
exit;
}
if ($action === 'convert_to_roadmap') { if ($action === 'convert_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$cardIds = $data['card_ids'] ?? []; $cardIds = $data['card_ids'] ?? [];
@@ -175,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
View File
@@ -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;
} }
+7 -1
View File
@@ -62,8 +62,14 @@ try {
$relevantIssues = []; $relevantIssues = [];
// Filter issues die specifiek relevant zijn voor deze medewerker // Filter issues die specifiek relevant zijn voor deze medewerker
$userFirstName = explode(' ', trim($user['name'] ?? ''))[0] ?? '';
foreach (array_merge($assignedIssues, $teamIssues) as $iss) { foreach (array_merge($assignedIssues, $teamIssues) as $iss) {
$isAssignedToMe = !empty($iss['assignee_name']) && (stripos($iss['assignee_name'], $user['name']) !== false || stripos($iss['assignee_name'], 'Niek') !== false); $assignee = $iss['assignee_name'] ?? '';
$isAssignedToMe = !empty($assignee) && (
stripos($assignee, $user['name'] ?? '') !== false ||
(!empty($userFirstName) && mb_strlen($userFirstName) >= 2 && stripos($assignee, $userFirstName) !== false) ||
(!empty($user['email']) && stripos($assignee, $user['email']) !== false)
);
$ol = strtolower(trim($iss['onderwerp_logging'] ?? '')); $ol = strtolower(trim($iss['onderwerp_logging'] ?? ''));
$isQueueMatch = false; $isQueueMatch = false;
+114 -20
View File
@@ -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;
} }
@@ -121,14 +147,14 @@ try {
// 6. E-mail direct inplannen in Fociz dagplanning // 6. E-mail direct inplannen in Fociz dagplanning
if ($action === 'plan_email') { if ($action === 'plan_email') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST; $input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$messageId = trim($input['message_id'] ?? ''); $messageId = trim($input['message_id'] ?? $input['mail_id'] ?? '');
$subject = trim($input['subject'] ?? 'E-mail actiepunt'); $subject = trim($input['title'] ?? $input['subject'] ?? 'E-mail actiepunt');
$planDate = trim($input['plan_date'] ?? date('Y-m-d')); $planDate = trim($input['plan_date'] ?? $input['date'] ?? date('Y-m-d'));
$timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null; $timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null;
$durationMinutes = (int)($input['duration_minutes'] ?? 30); $durationMinutes = (int)($input['duration_minutes'] ?? 30);
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2'; $priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
$webLink = trim($input['web_link'] ?? 'https://outlook.office.com/mail/'); $webLink = trim($input['web_link'] ?? 'https://outlook.office.com/mail/');
$sender = trim($input['sender'] ?? ''); $sender = trim($input['sender'] ?? $input['sender_name'] ?? '');
if (empty($subject)) { if (empty($subject)) {
echo json_encode(['success' => false, 'error' => 'Onderwerp is verplicht.']); echo json_encode(['success' => false, 'error' => 'Onderwerp is verplicht.']);
@@ -137,8 +163,8 @@ try {
if ($durationMinutes <= 0) $durationMinutes = 30; if ($durationMinutes <= 0) $durationMinutes = 30;
$timeBlockEnd = null; $timeBlockEnd = !empty($input['time_block_end']) ? trim($input['time_block_end']) : null;
if (!empty($timeBlockStart)) { if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
try { try {
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam')); $dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
$dtEnd->modify("+{$durationMinutes} minutes"); $dtEnd->modify("+{$durationMinutes} minutes");
@@ -155,14 +181,49 @@ try {
$stmtPlan->execute([$userId, $planDate]); $stmtPlan->execute([$userId, $planDate]);
$planId = (int)$db->lastInsertId(); $planId = (int)$db->lastInsertId();
$taskTitle = "✉️ " . $subject; // Als er een custom titel is ingevoerd, gebruik die direct, anders maak een nette titel
if (!empty($sender)) { $taskTitle = $subject;
$taskTitle .= " (van " . $sender . ")"; if (isset($input['subject']) && !isset($input['title'])) {
$taskTitle = "✉️ " . $subject;
if (!empty($sender) && strpos($taskTitle, $sender) === false) {
$taskTitle .= " (van " . $sender . ")";
}
}
$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,
@@ -172,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;
} }
+801 -24
View File
@@ -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,22 +731,75 @@ 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);
if ($newPlanId) { if ($newPlanId) {
$stmtUpdate = $db->prepare(" $stmtUpdate = $db->prepare("
UPDATE tasks UPDATE tasks
SET title = ?, 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, $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 = ?, 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, $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([
@@ -292,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;
} }
@@ -645,6 +1161,47 @@ try {
exit; exit;
} }
// 10b. Volledige taakdetails ophalen inclusief subtaken en dagplan informatie
if ($action === 'get_task_details') {
$taskId = (int)($_GET['task_id'] ?? ($_POST['task_id'] ?? 0));
if (!$taskId) {
echo json_encode(['error' => 'Taak-ID ontbreekt']);
exit;
}
$stmt = $db->prepare("
SELECT t.*, dp.plan_date, dp.focus_goal
FROM tasks t
LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id
WHERE t.id = ? AND t.user_id = ?
");
$stmt->execute([$taskId, $user['id']]);
$task = $stmt->fetch();
if (!$task) {
echo json_encode(['error' => 'Taak niet gevonden']);
exit;
}
// Subtaken ophalen
$stmtSub = $db->prepare("
SELECT id, task_id, title, is_completed, position, created_at
FROM task_subtasks
WHERE task_id = ? AND user_id = ?
ORDER BY position ASC, created_at ASC
");
$stmtSub->execute([$taskId, $user['id']]);
$subtasks = $stmtSub->fetchAll();
echo json_encode([
'success' => true,
'task' => $task,
'subtasks' => $subtasks,
]);
exit;
}
// 11. Zoek eerstvolgende vrije open blok binnen werktijden (Release v2.1) // 11. Zoek eerstvolgende vrije open blok binnen werktijden (Release v2.1)
if ($action === 'find_next_free_slot') { if ($action === 'find_next_free_slot') {
$rawInput = json_decode(file_get_contents('php://input'), true); $rawInput = json_decode(file_get_contents('php://input'), true);
@@ -652,12 +1209,17 @@ try {
$duration = max(5, (int)($data['duration_minutes'] ?? 30)); $duration = max(5, (int)($data['duration_minutes'] ?? 30));
$excludeTaskId = (int)($data['task_id'] ?? 0); $excludeTaskId = (int)($data['task_id'] ?? 0);
$startFromNextWorkday = !empty($data['start_from_next_workday']); $startFromNextWorkday = !empty($data['start_from_next_workday']);
$targetUserId = !empty($data['target_user_id']) ? (int)$data['target_user_id'] : (int)$user['id'];
$workdaySettings = get_workday_settings(); $workdaySettings = get_workday_settings();
$workdayStartStr = $workdaySettings['workday_start'] ?? '08:30'; $workdayStartStr = $workdaySettings['workday_start'] ?? '08:30';
$workdayEndStr = $workdaySettings['workday_end'] ?? '17:00'; $workdayEndStr = $workdaySettings['workday_end'] ?? '17:00';
$lunchStartStr = $workdaySettings['lunch_start'] ?? '12:30'; $lunchStartStr = $workdaySettings['lunch_start'] ?? '12:30';
$lunchEndStr = $workdaySettings['lunch_end'] ?? '13:00'; $lunchEndStr = $workdaySettings['lunch_end'] ?? '13:00';
$activeWorkdays = $workdaySettings['workdays'] ?? [1, 2, 3, 4, 5];
if (!is_array($activeWorkdays) || empty($activeWorkdays)) {
$activeWorkdays = [1, 2, 3, 4, 5];
}
$toMins = function(string $timeStr): int { $toMins = function(string $timeStr): int {
$parts = explode(':', trim($timeStr)); $parts = explode(':', trim($timeStr));
@@ -696,7 +1258,7 @@ try {
$checkDate = clone $now; $checkDate = clone $now;
if ($startFromNextWorkday) { if ($startFromNextWorkday) {
$checkDate->modify('+1 day'); $checkDate->modify('+1 day');
while ((int)$checkDate->format('N') >= 6) { while (!in_array((int)$checkDate->format('N'), $activeWorkdays, true)) {
$checkDate->modify('+1 day'); $checkDate->modify('+1 day');
} }
} }
@@ -710,7 +1272,7 @@ try {
$rangeEndObj->modify('+21 days'); // ruim venster incl. weekenden $rangeEndObj->modify('+21 days'); // ruim venster incl. weekenden
$rangeEndDate = $rangeEndObj->format('Y-m-d'); $rangeEndDate = $rangeEndObj->format('Y-m-d');
// 1. Batch SQL: haal alle bestaande taken van de gebruiker in 1 geoptimaliseerde query op // 1. Batch SQL: haal alle bestaande taken van de doelcollega in 1 geoptimaliseerde query op
$tasksByDate = []; $tasksByDate = [];
$stmtTasks = $db->prepare(" $stmtTasks = $db->prepare("
SELECT t.id, dp.plan_date, t.time_block_start, t.time_block_end, t.duration_minutes SELECT t.id, dp.plan_date, t.time_block_start, t.time_block_end, t.duration_minutes
@@ -722,7 +1284,7 @@ try {
AND t.time_block_start != '' AND t.time_block_start != ''
AND t.status != 'CANCELLED' AND t.status != 'CANCELLED'
"); ");
$stmtTasks->execute([$user['id'], $rangeStartDate, $rangeEndDate]); $stmtTasks->execute([$targetUserId, $rangeStartDate, $rangeEndDate]);
while ($t = $stmtTasks->fetch()) { while ($t = $stmtTasks->fetch()) {
if ($excludeTaskId > 0 && (int)$t['id'] === $excludeTaskId) { if ($excludeTaskId > 0 && (int)$t['id'] === $excludeTaskId) {
continue; continue;
@@ -730,11 +1292,11 @@ try {
$tasksByDate[$t['plan_date']][] = $t; $tasksByDate[$t['plan_date']][] = $t;
} }
// 2. Batch Microsoft Graph: haal alle Outlook events in 1 API call op voor het gehele venster // 2. Batch Microsoft Graph: haal alle Outlook events in 1 API call op voor het gehele venster van de doelcollega
$outlookByDate = []; $outlookByDate = [];
if (function_exists('microsoft_fetch_events_range')) { if (function_exists('microsoft_fetch_events_range')) {
try { try {
$allOutlookEvts = microsoft_fetch_events_range($user['id'], $rangeStartDate, $rangeEndDate, false); $allOutlookEvts = microsoft_fetch_events_range($targetUserId, $rangeStartDate, $rangeEndDate, false);
foreach ($allOutlookEvts as $oe) { foreach ($allOutlookEvts as $oe) {
if (!empty($oe['date'])) { if (!empty($oe['date'])) {
$outlookByDate[$oe['date']][] = $oe; $outlookByDate[$oe['date']][] = $oe;
@@ -743,16 +1305,35 @@ try {
} catch (Throwable $e) {} } catch (Throwable $e) {}
} }
// 3. Batch Calendar Blocks: haal verlof en geblokkeerde vakantiedagen op voor dit venster van de doelcollega
$blockedDates = [];
try {
$stmtBlk = $db->prepare("SELECT start_date, end_date FROM calendar_blocks WHERE user_id = ? AND start_date <= ? AND end_date >= ?");
$stmtBlk->execute([$targetUserId, $rangeEndDate, $rangeStartDate]);
while ($b = $stmtBlk->fetch()) {
$bStart = strtotime($b['start_date']);
$bEnd = strtotime($b['end_date']);
for ($d = $bStart; $d <= $bEnd; $d += 86400) {
$blockedDates[date('Y-m-d', $d)] = true;
}
}
} catch (Throwable $e) {}
while ($checkedDays < $maxDays) { while ($checkedDays < $maxDays) {
$dayOfWeek = (int)$checkDate->format('N'); // 1 = Ma, 7 = Zo $dayOfWeek = (int)$checkDate->format('N'); // 1 = Ma, 7 = Zo
$dateStr = $checkDate->format('Y-m-d');
// Weekend overslaan // Niet-werkdag / weekend overslaan op basis van organisatie-instellingen
if ($dayOfWeek === 6 || $dayOfWeek === 7) { if (!in_array($dayOfWeek, $activeWorkdays, true)) {
$checkDate->modify('+1 day'); $checkDate->modify('+1 day');
continue; continue;
} }
$dateStr = $checkDate->format('Y-m-d'); // Geblokkeerde dagen (bijv. vakantie, verlof) overslaan
if (!empty($blockedDates[$dateStr])) {
$checkDate->modify('+1 day');
continue;
}
$isToday = ($dateStr === $now->format('Y-m-d')) && !$startFromNextWorkday; $isToday = ($dateStr === $now->format('Y-m-d')) && !$startFromNextWorkday;
// Bepaal vroegste startminuut voor deze dag (op hele kwartieren) // Bepaal vroegste startminuut voor deze dag (op hele kwartieren)
@@ -850,6 +1431,202 @@ try {
exit; exit;
} }
// 12. Kalenderblokkades ophalen (Vakanties / Verlof / Afwezigheid)
if ($action === 'get_calendar_blocks') {
$stmt = $db->prepare("
SELECT * FROM calendar_blocks
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 100
");
$stmt->execute([$user['id']]);
$blocks = $stmt->fetchAll() ?: [];
echo json_encode(['success' => true, 'blocks' => $blocks]);
exit;
}
// 13. Kalenderblokkade opslaan (Vakantie / Verlof / Dag(en) blokkeren)
if ($action === 'save_calendar_block') {
$rawInput = json_decode(file_get_contents('php://input'), true);
$data = is_array($rawInput) ? $rawInput : $_POST;
$blockId = (int)($data['id'] ?? 0);
$title = trim($data['title'] ?? '');
$blockType = trim($data['block_type'] ?? 'VACATION');
$startDate = trim($data['start_date'] ?? $today);
$endDate = trim($data['end_date'] ?? $startDate);
$notes = trim($data['notes'] ?? '');
$syncToOutlook = !empty($data['sync_to_outlook']);
if (empty($title)) {
echo json_encode(['success' => false, 'error' => 'Geef een titel op voor de blokkade (bijv. Vakantie of Vrije dag)']);
exit;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
echo json_encode(['success' => false, 'error' => 'Ongeldige datumnotatie']);
exit;
}
if ($endDate < $startDate) {
$temp = $startDate;
$startDate = $endDate;
$endDate = $temp;
}
$outlookEventId = null;
// Indien bewerken en er bestond al een Outlook event, haal het event ID op
if ($blockId > 0) {
$stmtEx = $db->prepare("SELECT outlook_event_id FROM calendar_blocks WHERE id = ? AND user_id = ?");
$stmtEx->execute([$blockId, $user['id']]);
$outlookEventId = $stmtEx->fetchColumn() ?: null;
}
// Optioneel synchroniseren naar Outlook 365
if ($syncToOutlook && function_exists('microsoft_create_event')) {
try {
if (!empty($outlookEventId) && function_exists('microsoft_delete_event')) {
microsoft_delete_event($user['id'], $outlookEventId);
}
$eventDesc = !empty($notes) ? $notes : "Geblokkeerd via Fociz Agenda ({$blockType})";
$msRes = microsoft_create_event($user['id'], [
'subject' => $title,
'description' => $eventDesc,
'date' => $startDate,
'end_date' => $endDate,
'is_all_day' => true,
'show_as' => ($blockType === 'FOCUS') ? 'busy' : 'oof',
]);
if (!empty($msRes['success']) && !empty($msRes['event_id'])) {
$outlookEventId = $msRes['event_id'];
}
} catch (Throwable $e) {
error_log("Fout bij sync calendar_block naar Outlook: " . $e->getMessage());
}
}
if ($blockId > 0) {
$stmt = $db->prepare("
UPDATE calendar_blocks
SET title = ?, block_type = ?, start_date = ?, end_date = ?, notes = ?, outlook_event_id = ?, updated_at = NOW()
WHERE id = ? AND user_id = ?
");
$stmt->execute([$title, $blockType, $startDate, $endDate, $notes, $outlookEventId, $blockId, $user['id']]);
} else {
$stmt = $db->prepare("
INSERT INTO calendar_blocks (user_id, start_date, end_date, title, block_type, notes, outlook_event_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$stmt->execute([$user['id'], $startDate, $endDate, $title, $blockType, $notes, $outlookEventId]);
$blockId = (int)$db->lastInsertId();
}
echo json_encode([
'success' => true,
'message' => 'Agenda-blokkade succesvol opgeslagen',
'block_id' => $blockId,
'start_date'=> $startDate,
'end_date' => $endDate,
'title' => $title
]);
exit;
}
// 14. Kalenderblokkade verwijderen / opheffen
if ($action === 'delete_calendar_block') {
$rawInput = json_decode(file_get_contents('php://input'), true);
$data = is_array($rawInput) ? $rawInput : $_POST;
$blockId = (int)($data['id'] ?? 0);
if ($blockId <= 0) {
echo json_encode(['success' => false, 'error' => 'Ongeldig blok ID']);
exit;
}
$stmtEx = $db->prepare("SELECT outlook_event_id FROM calendar_blocks WHERE id = ? AND user_id = ?");
$stmtEx->execute([$blockId, $user['id']]);
$outlookEventId = $stmtEx->fetchColumn();
if (!empty($outlookEventId) && function_exists('microsoft_delete_event')) {
try {
microsoft_delete_event($user['id'], $outlookEventId);
} catch (Throwable $e) {}
}
$stmtDel = $db->prepare("DELETE FROM calendar_blocks WHERE id = ? AND user_id = ?");
$stmtDel->execute([$blockId, $user['id']]);
echo json_encode(['success' => true, 'message' => 'Blokkade succesvol opgeheven']);
exit;
}
// 15. Outlook Agenda-afspraak verwijderen
if ($action === 'delete_outlook_event') {
$rawInput = json_decode(file_get_contents('php://input'), true);
$data = is_array($rawInput) ? $rawInput : $_POST;
$eventId = trim($data['event_id'] ?? '');
if (empty($eventId)) {
echo json_encode(['success' => false, 'error' => 'Geen event ID opgegeven']);
exit;
}
if (!function_exists('microsoft_delete_event')) {
echo json_encode(['success' => false, 'error' => 'Microsoft integratie niet beschikbaar']);
exit;
}
$ok = microsoft_delete_event((int)$user['id'], $eventId);
if ($ok) {
echo json_encode(['success' => true, 'message' => 'Afspraak succesvol verwijderd uit Outlook']);
} else {
echo json_encode(['success' => false, 'error' => 'Kon afspraak niet verwijderen uit Outlook (mogelijk al verwijderd of geen bevoegdheid)']);
}
exit;
}
// 16. Outlook Agenda-afspraak bijwerken
if ($action === 'update_outlook_event') {
$rawInput = json_decode(file_get_contents('php://input'), true);
$data = is_array($rawInput) ? $rawInput : $_POST;
$eventId = trim($data['event_id'] ?? '');
$subject = trim($data['title'] ?? ($data['subject'] ?? ''));
$date = trim($data['date'] ?? date('Y-m-d'));
$startTime = trim($data['time_block_start'] ?? ($data['start_time'] ?? '09:00'));
$endTime = trim($data['time_block_end'] ?? ($data['end_time'] ?? '09:30'));
$description = trim($data['description'] ?? '');
if (empty($eventId) || empty($subject)) {
echo json_encode(['success' => false, 'error' => 'Titel of event ID ontbreekt']);
exit;
}
if (!function_exists('microsoft_update_event')) {
echo json_encode(['success' => false, 'error' => 'Microsoft integratie niet beschikbaar']);
exit;
}
$updateData = [
'subject' => $subject,
'date' => $date,
'start_time' => $startTime,
'end_time' => $endTime,
];
if ($description !== '') {
$updateData['description'] = $description;
}
$ok = microsoft_update_event((int)$user['id'], $eventId, $updateData);
if ($ok) {
echo json_encode(['success' => true, 'date' => $date, 'message' => 'Afspraak succesvol bijgewerkt in Outlook']);
} else {
echo json_encode(['success' => false, 'error' => 'Kon afspraak niet bijwerken in Microsoft Outlook']);
}
exit;
}
echo json_encode(['error' => 'Onbekende actie']); echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
+24
View File
@@ -53,6 +53,30 @@ try {
$fields[] = "status = ?"; $fields[] = "status = ?";
$params[] = $status; $params[] = $status;
} }
if (isset($data['quarter']) && in_array($data['quarter'], ['Q1', 'Q2', 'Q3', 'Q4'], true)) {
$fields[] = "quarter = ?";
$params[] = $data['quarter'];
}
if (isset($data['title']) && trim($data['title']) !== '') {
$fields[] = "title = ?";
$params[] = trim($data['title']);
}
if (isset($data['description'])) {
$fields[] = "description = ?";
$params[] = trim($data['description']);
}
if (isset($data['type'])) {
$fields[] = "type = ?";
$params[] = $data['type'];
}
if (isset($data['priority'])) {
$fields[] = "priority = ?";
$params[] = $data['priority'];
}
if (array_key_exists('due_date', $data)) {
$fields[] = "due_date = ?";
$params[] = !empty($data['due_date']) ? $data['due_date'] : null;
}
if ($progress !== null) { if ($progress !== null) {
$fields[] = "progress = ?"; $fields[] = "progress = ?";
$params[] = max(0, min(100, $progress)); $params[] = max(0, min(100, $progress));
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+83 -10
View File
@@ -34,7 +34,7 @@ if ($currentUser) {
$error = null; $error = null;
$infoMessage = null; $infoMessage = null;
if (isset($_GET['expired'])) { if (isset($_GET['expired'])) {
$infoMessage = "Uw sessie is beëindigd wegens inactiviteit of een dagwissel. Log opnieuw in om verder te gaan."; $infoMessage = "Uw sessie is beeïndigd";
} }
if (isset($_GET['revoked'])) { if (isset($_GET['revoked'])) {
$infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan."; $infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan.";
@@ -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>
+281 -19
View File
@@ -21,6 +21,63 @@ $isAdmin = ($userRole === 'ADMIN');
max-width: 100% !important; max-width: 100% !important;
width: 100% !important; width: 100% !important;
} }
/* --- Strakke, Moderne & Professionele Post-it Sticky Note Styling --- */
.postit-note {
position: relative;
border-radius: 6px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 4px 10px -2px rgba(0, 0, 0, 0.08),
0 12px 20px -4px rgba(0, 0, 0, 0.06);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease;
}
.postit-note:hover {
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: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
width: 54px;
height: 13px;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(0, 0, 0, 0.06);
backdrop-filter: blur(2px);
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
pointer-events: none;
z-index: 10;
}
.dark .postit-note::before {
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;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04), transparent);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
pointer-events: none;
}
</style> </style>
<div id="boardPageContainer" class="space-y-4 w-full"> <div id="boardPageContainer" class="space-y-4 w-full">
@@ -44,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>
@@ -77,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>
@@ -277,6 +332,25 @@ $isAdmin = ($userRole === 'ADMIN');
</div> </div>
</div> </div>
<!-- Knop Vrij Blok Zoeken voor Assignee -->
<div class="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between gap-2">
<div class="text-[11px]">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3 h-3 text-amber-500"></i>
<span>Vrij agendablok zoeken</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden van de toegewezen collega</span>
</div>
<button type="button" id="btnBoardFindSlot" onclick="findFreeSlotForBoardAssignee()" class="px-2.5 py-1 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-[10px] shadow-xs transition flex items-center space-x-1 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3 h-3"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="boardFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-[11px] text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-emerald-500 flex-shrink-0"></i>
<span id="boardFoundSlotText"></span>
</div>
<div id="scheduleEndDateRow" class="hidden"> <div id="scheduleEndDateRow" class="hidden">
<label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Einddatum herhaling</label> <label class="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">Einddatum herhaling</label>
<input type="date" id="cardModalEndDate" value="<?= date('Y-m-d', strtotime('+7 days')) ?>" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-800 dark:text-slate-200 focus:outline-none"> <input type="date" id="cardModalEndDate" value="<?= date('Y-m-d', strtotime('+7 days')) ?>" class="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-800 dark:text-slate-200 focus:outline-none">
@@ -425,7 +499,39 @@ $isAdmin = ($userRole === 'ADMIN');
<button onclick="submitCreateBoard()" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition">Bord Aanmaken</button> <button onclick="submitCreateBoard()" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition">Bord Aanmaken</button>
</div> </div>
</div> </div>
<!-- Floating Rechtermuisknop Contextmenu voor Bord Kaarten -->
<div id="boardCardContextMenu" class="hidden fixed z-[999] bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl py-1.5 w-64 text-xs font-medium text-slate-700 dark:text-slate-200 select-none animate-in fade-in zoom-in-95 duration-100">
<div class="px-3.5 py-1.5 border-b border-slate-100 dark:border-slate-800/80 mb-1">
<span id="boardCtxTitle" class="text-[11px] font-bold text-slate-900 dark:text-white truncate block"></span>
<span id="boardCtxMeta" class="text-[10px] text-slate-400 truncate block"></span>
</div> </div>
<button onclick="triggerBoardCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Taak bewerken</span>
</button>
<button onclick="triggerBoardCtxQuickSlot()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="zap" class="w-4 h-4 text-amber-500"></i>
<span>⚡ Zoek direct vrij blok</span>
</button>
<button onclick="triggerBoardCtxQuickMove(-1)" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="arrow-left" class="w-4 h-4 text-slate-500"></i>
<span>Verplaats naar vorige kolom</span>
</button>
<button onclick="triggerBoardCtxQuickMove(1)" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="arrow-right" class="w-4 h-4 text-slate-500"></i>
<span>Verplaats naar volgende kolom</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerBoardCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Taak verwijderen</span>
</button>
</div> </div>
<script> <script>
@@ -508,17 +614,19 @@ function onBoardSelectChange(bId) {
} }
// 2. Data van specifiek bord inladen // 2. Data van specifiek bord inladen
async function loadBoardData(boardId) { async function loadBoardData(boardId, showLoader = true) {
currentBoardId = boardId; currentBoardId = boardId;
document.getElementById('linkToTimeline')?.setAttribute('href', `/timeline.php?board_id=${boardId}`); document.getElementById('linkToTimeline')?.setAttribute('href', `/timeline.php?board_id=${boardId}`);
const container = document.getElementById('boardLanesContainer'); const container = document.getElementById('boardLanesContainer');
container.innerHTML = ` if (showLoader) {
<div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm"> container.innerHTML = `
<i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i> <div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm">
<span>Bord laden...</span> <i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i>
</div> <span>Bord laden...</span>
`; </div>
if (typeof lucide !== 'undefined') lucide.createIcons(); `;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try { try {
const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`); const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`);
@@ -527,7 +635,14 @@ async function loadBoardData(boardId) {
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');
@@ -567,9 +682,13 @@ function onCardDragStart(e, cardId) {
function onCardDragEnd(e) { function onCardDragEnd(e) {
if (draggedCardId) { if (draggedCardId) {
const el = document.getElementById(`card-${draggedCardId}`); const el = document.getElementById(`card-${draggedCardId}`);
if (el) el.classList.remove('opacity-40'); if (el) el.classList.remove('opacity-40', 'pointer-events-none');
draggedCardId = null; draggedCardId = null;
} }
// Verwijder eventuele achtergebleven opacity op alle kaarten
document.querySelectorAll('[id^="card-"].opacity-40').forEach(el => {
el.classList.remove('opacity-40', 'pointer-events-none');
});
setTimeout(() => { setTimeout(() => {
isDraggingCard = false; isDraggingCard = false;
}, 150); }, 150);
@@ -599,6 +718,23 @@ async function onLaneDrop(e, targetLaneId) {
const rawId = draggedCardId || e.dataTransfer.getData('text/plain'); const rawId = draggedCardId || e.dataTransfer.getData('text/plain');
const cardId = parseInt(rawId, 10); const cardId = parseInt(rawId, 10);
// Direct re-activeren van het gesleepte element
const activeEl = cardId ? document.getElementById(`card-${cardId}`) : null;
if (activeEl) {
activeEl.classList.remove('opacity-40', 'pointer-events-none');
// Optimistic DOM append als dropZone en element bestaan
const emptyNotice = dropZone.querySelector('.pointer-events-none');
if (emptyNotice && emptyNotice.innerText.includes('Geen taken')) {
emptyNotice.remove();
}
dropZone.appendChild(activeEl);
}
draggedCardId = null;
setTimeout(() => {
isDraggingCard = false;
}, 150);
if (!cardId) return; if (!cardId) return;
try { try {
@@ -611,7 +747,7 @@ async function onLaneDrop(e, targetLaneId) {
target_position: 999 target_position: 999
}) })
}); });
await loadBoardData(currentBoardId); await loadBoardData(currentBoardId, false);
} catch (err) { } catch (err) {
console.error('Fout bij verplaatsen taak:', err); console.error('Fout bij verplaatsen taak:', err);
} }
@@ -715,7 +851,8 @@ function renderCards() {
ondragstart="onCardDragStart(event, ${card.id})" ondragstart="onCardDragStart(event, ${card.id})"
ondragend="onCardDragEnd(event)" ondragend="onCardDragEnd(event)"
onclick="onCardClick(event, ${card.id})" onclick="onCardClick(event, ${card.id})"
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5" oncontextmenu="openBoardCardContextMenu(event, ${card.id}); return false;"
class="group/card relative p-3 rounded-xl border ${colorClass} shadow-2xs hover:shadow-md hover:-translate-y-0.5 transition cursor-grab active:cursor-grabbing mb-2.5 postit-note"
> >
<div class="flex items-start justify-between gap-1.5 mb-1.5"> <div class="flex items-start justify-between gap-1.5 mb-1.5">
<h4 class="text-xs font-bold leading-snug line-clamp-2 break-words"> <h4 class="text-xs font-bold leading-snug line-clamp-2 break-words">
@@ -1030,6 +1167,131 @@ async function quickMoveCard(cardId, direction) {
} }
} }
// Rechtermuisknop Context Menu Logica voor Bord
let activeBoardCtxCardId = null;
function openBoardCardContextMenu(e, cardId) {
e.preventDefault();
e.stopPropagation();
activeBoardCtxCardId = cardId;
const menu = document.getElementById('boardCardContextMenu');
if (!menu) return;
menu.classList.remove('hidden');
const x = Math.min(e.clientX, window.innerWidth - 240);
const y = Math.min(e.clientY, window.innerHeight - 240);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeBoardCardContextMenu() {
const menu = document.getElementById('boardCardContextMenu');
if (menu) menu.classList.add('hidden');
activeBoardCtxCardId = null;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#boardCardContextMenu')) {
closeBoardCardContextMenu();
}
});
function triggerBoardCtxEdit() {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
openCardModal(cid);
}
async function triggerBoardCtxQuickSlot() {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
openCardModal(cid);
setTimeout(() => {
findFreeSlotForBoardAssignee();
}, 150);
}
function triggerBoardCtxQuickMove(dir) {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
quickMoveCard(cid, dir);
}
async function triggerBoardCtxDelete() {
if (!activeBoardCtxCardId) return;
const cid = activeBoardCtxCardId;
closeBoardCardContextMenu();
if (!confirm('Weet je zeker dat je deze taak wilt verwijderen?')) return;
try {
const res = await fetch('/api/board.php?action=delete_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cid })
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verwijderen');
}
} catch (e) {
alert('Netwerkfout bij verwijderen taak');
}
}
// Zoek vrij agendablok voor de in de modal geselecteerde assignee
async function findFreeSlotForBoardAssignee() {
const sel = document.getElementById('cardModalAssignee');
const targetUserId = sel.value ? parseInt(sel.value, 10) : currentUserId;
const schedCheckbox = document.getElementById('cardModalScheduleEnabled');
if (!schedCheckbox.checked) {
schedCheckbox.checked = true;
toggleScheduleFields();
}
const btn = document.getElementById('btnBoardFindSlot');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i><span>Zoeken...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: 60,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('cardModalStartDate').value = slot.date;
document.getElementById('cardModalEndDate').value = slot.date;
document.getElementById('cardModalStartTime').value = slot.start;
document.getElementById('cardModalEndTime').value = slot.end;
const notice = document.getElementById('boardFoundSlotNotice');
const noticeText = document.getElementById('boardFoundSlotText');
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden voor deze collega.');
}
} catch (err) {
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
// 8. Beheer Modal: Lanes & Leden Autorisatie // 8. Beheer Modal: Lanes & Leden Autorisatie
function openManageBoardModal() { function openManageBoardModal() {
if (!currentBoardData) return; if (!currentBoardData) return;
+204 -33
View File
@@ -4,15 +4,22 @@
*/ */
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/settings.php';
$currentUser = require_auth(); $currentUser = require_auth();
$db = get_db_connection(); $db = get_db_connection();
$brainstormCategories = get_brainstorm_categories();
// Verwerk nieuwe sessie aanmaken // Verwerk nieuwe sessie aanmaken
$error = null; $error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') {
$title = trim($_POST['title'] ?? ''); $title = trim($_POST['title'] ?? '');
$theme = trim($_POST['theme'] ?? 'Product & Strategie'); $theme = trim($_POST['category'] ?? ($_POST['theme'] ?? ''));
if (empty($theme) && !empty($brainstormCategories)) {
$firstActive = array_values(array_filter($brainstormCategories, fn($c) => !empty($c['is_active'])))[0] ?? null;
$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("
@@ -21,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.";
@@ -88,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']) ?>
@@ -110,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">
<span>Open canvas</span> <?php if ($canDelete): ?>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i> <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>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</a>
</div> </div>
</div> </div>
</a> </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>
@@ -141,31 +168,78 @@ 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">
</div> Onderwerp / Titel <span class="text-rose-500">*</span>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <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>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label> <label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
<select name="theme" 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">
<option value="Product & Strategie">Product & Strategie</option> <?php
<option value="Procesverbetering">Procesverbetering</option> $hasActiveCategory = false;
<option value="Klant & Service">Klant & Service</option> foreach ($brainstormCategories as $bCat):
<option value="Technologie & IT">Technologie & IT</option> if (isset($bCat['is_active']) && empty($bCat['is_active'])) continue;
<option value="Marketing & Groei">Marketing & Groei</option> $hasActiveCategory = true;
?>
<option value="<?= htmlspecialchars($bCat['name']) ?>"><?= htmlspecialchars($bCat['name']) ?></option>
<?php endforeach; ?>
<?php if (!$hasActiveCategory): ?>
<option value="Product & Strategie">Product & Strategie</option>
<option value="Procesverbetering">Procesverbetering</option>
<option value="Klant & Service">Klant & Service</option>
<option value="Technologie & IT">Technologie & IT</option>
<option value="Marketing & Groei">Marketing & Groei</option>
<?php endif; ?>
</select> </select>
</div> </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 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> </div>
<textarea
name="description"
rows="6"
placeholder="Voer hier alle achtergrondinformatie, context en doelen in waar de AI post-its van gaat maken. Bijvoorbeeld:&#10;&#10;• Achtergrond: Klanten klagen over lange wachttijden bij onboarding.&#10;• Doel: Binnen 3 maanden de doorlooptijd met 50% verkorten.&#10;• Bekende knelpunten: Handmatige validatie, ontbrekende API-koppeling met CRM.&#10;• 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
@@ -175,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>
+1032 -121
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+103 -25
View File
@@ -32,6 +32,7 @@ function get_db_connection(): PDO {
"); ");
} }
$tzOffset = date('P'); // Dynamische offset conform Europe/Amsterdam (+02:00 in zomertijd, +01:00 in wintertijd)
$options = [ $options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
@@ -41,6 +42,9 @@ function get_db_connection(): PDO {
if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) { if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
$options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = true; $options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = true;
} }
if (defined('PDO::MYSQL_ATTR_INIT_COMMAND')) {
$options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET time_zone = '{$tzOffset}'";
}
$dsnWithDb = sprintf( $dsnWithDb = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s', 'mysql:host=%s;port=%s;dbname=%s;charset=%s',
@@ -53,6 +57,8 @@ function get_db_connection(): PDO {
// 2. CHECK: Bestaat de database al? // 2. CHECK: Bestaat de database al?
try { try {
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options); $pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
// Garandeer dat MariaDB sessietijd 1-op-1 synchroon loopt met PHP tijd (Europe/Amsterdam)
$pdo->exec("SET time_zone = '{$tzOffset}'");
ensure_tables_exist($pdo); ensure_tables_exist($pdo);
return $pdo; return $pdo;
@@ -70,6 +76,7 @@ function get_db_connection(): PDO {
)); ));
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options); $pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
$pdo->exec("SET time_zone = '{$tzOffset}'");
ensure_tables_exist($pdo); ensure_tables_exist($pdo);
return $pdo; return $pdo;
@@ -119,8 +126,14 @@ function ensure_tables_exist(PDO $pdo): void {
if ($tablesChecked) return; if ($tablesChecked) return;
$tablesChecked = true; $tablesChecked = true;
$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 { try {
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
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)));
@@ -155,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
@@ -163,6 +178,20 @@ function ensure_tables_exist(PDO $pdo): void {
"CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// SOC 2 & ISO 27001: Onwijzigbare Audit Trail // SOC 2 & ISO 27001: Onwijzigbare Audit Trail
"CREATE TABLE IF NOT EXISTS `audit_logs` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NULL, `user_email` VARCHAR(191) NULL, `action` VARCHAR(64) NOT NULL, `resource_type` VARCHAR(64) NULL, `resource_id` INT NULL, `details` TEXT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`user_id`), INDEX (`action`), INDEX (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS `audit_logs` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NULL, `user_email` VARCHAR(191) NULL, `action` VARCHAR(64) NOT NULL, `resource_type` VARCHAR(64) NULL, `resource_id` INT NULL, `details` TEXT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`user_id`), INDEX (`action`), INDEX (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
"CREATE TABLE IF NOT EXISTS `calendar_blocks` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `start_date` DATE NOT NULL, `end_date` DATE NOT NULL, `title` VARCHAR(255) NOT NULL, `block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION', `notes` TEXT NULL, `outlook_event_id` VARCHAR(255) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `start_date`, `end_date`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// Centrale Systeeminstellingen (AI, Werkdagen, Sessiebeveiliging)
"CREATE TABLE IF NOT EXISTS `system_settings` (`setting_key` VARCHAR(64) PRIMARY KEY, `setting_value` LONGTEXT NULL, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"ALTER TABLE `system_settings` MODIFY COLUMN `setting_value` LONGTEXT NULL",
// Brainstorm kaarten toewijzen aan collega's en agenda-inplanning
"ALTER TABLE brainstorm_cards ADD COLUMN assignee_id INT NULL AFTER color",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_date DATE NULL AFTER assignee_id",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_start_time VARCHAR(10) NULL AFTER scheduled_date",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
// 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 {
@@ -172,30 +201,61 @@ 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 = [
$insertFilterStmt = $pdo->prepare(" [
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order) 'code' => 'SD',
VALUES (?, ?, ?, ?, ?, 1, ?) 'name' => 'SD — Servicedesk',
"); 'project' => 'SD',
$insertFilterStmt->execute([ 'jql' => 'project = "SD"',
'DX', 'desc' => 'Servicedesk supportvragen en operationele meldingen.',
'DX — Koppelingen, SSO & Migratie', 'order' => 1
'SD', ],
'"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)', [
'Servicedesk tickets met betrekking tot koppelingen, endpoints en datamigraties.', 'code' => 'IN',
1 'name' => 'IN — Interfaces',
]); 'project' => 'IN',
$insertFilterStmt->execute([ 'jql' => 'project = "IN"',
'SD', 'desc' => 'Interface-koppelingen, API-endpoints en data-uitwisselingen.',
'SD — Regulier Servicedesk', 'order' => 2
'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.', 'code' => 'AAN',
2 '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("
INSERT INTO jira_queue_filters (filter_code, filter_name, project_key, jql_clause, description, is_active, sort_order)
VALUES (?, ?, ?, ?, ?, 1, ?)
");
foreach ($defaultFilters as $df) {
if (!in_array($df['code'], $existingCodes)) {
$insertFilterStmt->execute([
$df['code'], $df['name'], $df['project'], $df['jql'], $df['desc'], $df['order']
]);
}
} }
} catch (Throwable $e) { } catch (Throwable $e) {
// Stil negeren indien tabel nog in opbouw is // Stil negeren indien tabel nog in opbouw is
@@ -229,8 +289,26 @@ function ensure_tables_exist(PDO $pdo): void {
} }
} }
} catch (Throwable $e) { } catch (Throwable $e) {
// Stil negeren indien tabellen nog in opbouw zijn // Stil negeren indien borden tabel nog niet bestaat
} }
// Zorg dat eerdere audit logs die in UTC (+00:00) zijn weggeschreven worden gesynchroniseerd naar Europe/Amsterdam (+2 uur)
try {
$stmtCheck = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'audit_logs_tz_fixed' LIMIT 1");
$fixed = $stmtCheck ? $stmtCheck->fetchColumn() : null;
if (!$fixed) {
// Pas bestaande audit logs eenmalig aan zodat de weergave klopt met de lokale tijd
$pdo->exec("UPDATE audit_logs SET created_at = DATE_ADD(created_at, INTERVAL 2 HOUR)");
$pdo->prepare("
INSERT INTO system_settings (setting_key, setting_value, updated_at)
VALUES ('audit_logs_tz_fixed', '1', NOW())
ON DUPLICATE KEY UPDATE setting_value = '1', updated_at = NOW()
")->execute();
}
} catch (Throwable $e) {
// 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());
} }
+22
View File
@@ -101,12 +101,18 @@ CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
`content` TEXT NOT NULL, `content` TEXT NOT NULL,
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën', `category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
`color` VARCHAR(20) NOT NULL DEFAULT 'amber', `color` VARCHAR(20) NOT NULL DEFAULT 'amber',
`assignee_id` INT NULL,
`scheduled_date` DATE NULL,
`scheduled_start_time` VARCHAR(10) NULL,
`scheduled_end_time` VARCHAR(10) NULL,
`scheduled_task_id` INT NULL,
`votes` INT NOT NULL DEFAULT 0, `votes` INT NOT NULL DEFAULT 0,
`author_name` VARCHAR(120) NULL, `author_name` VARCHAR(120) NULL,
`converted_to_roadmap_item_id` INT NULL, `converted_to_roadmap_item_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`session_id`), INDEX (`session_id`),
INDEX (`assignee_id`),
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -313,3 +319,19 @@ CREATE TABLE IF NOT EXISTS `user_sessions` (
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;
-- Fociz Agenda & Verlof: Hele of meerdere dagen blokkeren (Vakantie, Vrije dag, Ziekte)
CREATE TABLE IF NOT EXISTS `calendar_blocks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`title` VARCHAR(255) NOT NULL,
`block_type` VARCHAR(50) NOT NULL DEFAULT 'VACATION',
`notes` TEXT NULL,
`outlook_event_id` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`user_id`, `start_date`, `end_date`),
CONSTRAINT `fk_calblocks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+476 -41
View File
@@ -82,12 +82,19 @@ function fociz_ai_enforce_privacy_firewall(array &$messages): array {
* @return array * @return array
*/ */
function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array { function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array {
$apiKey = $aiSettings['ai_api_key']; $apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gemini-1.5-pro'; $model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gemini-2.0-flash';
$temperature = $options['temperature'] ?? 0.3; $temperature = $options['temperature'] ?? 0.3;
$maxTokens = $options['max_tokens'] ?? 2048; $maxTokens = max(512, (int)($options['max_tokens'] ?? 2048));
$jsonMode = !empty($options['json_mode']); $jsonMode = !empty($options['json_mode']);
if (empty($apiKey)) {
return [
'success' => false,
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
];
}
// Google Generative Language API structuur opbouwen // Google Generative Language API structuur opbouwen
$systemInstructions = []; $systemInstructions = [];
$contents = []; $contents = [];
@@ -131,19 +138,215 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
$payload['generationConfig']['responseMimeType'] = 'application/json'; $payload['generationConfig']['responseMimeType'] = 'application/json';
} }
$endpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . urlencode($apiKey); $cleanModel = preg_replace('#^models/#', '', $model);
$response = fociz_http_request($endpoint, [ // Helper om tekst uit Gemini candidate te extraheren
'method' => 'POST', $extractCandidateText = function(?array $candidate): string {
'headers' => [ if (!$candidate || empty($candidate['content']['parts'])) {
'Content-Type' => 'application/json', return '';
], }
'body' => $payload, $txt = '';
'timeout' => 45, foreach ($candidate['content']['parts'] as $part) {
]); if (!empty($part['text']) && empty($part['thought'])) {
$txt .= $part['text'];
}
}
if ($txt === '' && !empty($candidate['content']['parts'][0]['text'])) {
$txt = $candidate['content']['parts'][0]['text'];
}
return trim($txt);
};
if ($response['status'] !== 200 || empty($response['json']['candidates'][0]['content']['parts'][0]['text'])) { // Normaliseer modelnaam en stel geldige kandidatenlijst op
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Gemini API aanroep mislukt (HTTP {$response['status']})"); $candidateUrls = [];
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
$m = preg_replace('#^models/#', '', trim($m));
if ($m === '') return;
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
if (!in_array($v1beta, $candidateUrls, true)) {
$candidateUrls[] = $v1beta;
}
};
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
$addCandidatesFor($cleanModel);
}
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
$addCandidatesFor('gemini-2.0-flash');
$addCandidatesFor('gemini-1.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;
$lastError = null;
$successfulModel = $cleanModel;
$finalText = '';
for ($i = 0; $i < count($candidateUrls); $i++) {
$baseUrl = $candidateUrls[$i];
$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, [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
'x-goog-api-key' => $apiKey,
],
'body' => $payload,
'timeout' => 25,
]);
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
$extracted = $extractCandidateText($res['json']['candidates'][0]);
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
$response = $res;
$finalText = ($extracted !== '') ? $extracted : 'OK';
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
$successfulModel = $mName[1];
}
break 2; // Volledig succes, beëindig beide loops
}
}
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
$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")
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
$recModel = $recMatch[1];
$recUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$recModel}:generateContent";
if (!in_array($recUrl, $candidateUrls, true)) {
$candidateUrls[] = $recUrl;
}
}
// 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')) {
continue;
}
// 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;
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
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
$listRes = fociz_http_request($listUrl, [
'method' => 'GET',
'headers' => ['x-goog-api-key' => $apiKey],
'timeout' => 10,
]);
if ($listRes['status'] === 200 && !empty($listRes['json']['models'])) {
$availModels = [];
foreach ($listRes['json']['models'] as $mItem) {
$mMethods = $mItem['supportedGenerationMethods'] ?? [];
if (in_array('generateContent', $mMethods, true)) {
$mClean = preg_replace('#^models/#', '', $mItem['name'] ?? '');
if ($mClean) $availModels[] = $mClean;
}
}
// Zoek een passend gemini model
$fallbackModel = null;
foreach ($availModels as $am) {
if (str_contains($am, 'flash')) {
$fallbackModel = $am;
break;
}
}
if (!$fallbackModel) {
foreach ($availModels as $am) {
if (str_contains($am, 'gemini')) {
$fallbackModel = $am;
break;
}
}
}
if ($fallbackModel) {
$dynEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/{$fallbackModel}:generateContent?key=" . urlencode($apiKey);
$dynRes = fociz_http_request($dynEndpoint, [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
'x-goog-api-key' => $apiKey,
],
'body' => $payload,
'timeout' => 20,
]);
if ($dynRes['status'] === 200 && !empty($dynRes['json']['candidates'][0])) {
$extracted = $extractCandidateText($dynRes['json']['candidates'][0]);
if ($extracted !== '' || !empty($dynRes['json']['candidates'][0]['finishReason'])) {
$response = $dynRes;
$finalText = ($extracted !== '') ? $extracted : 'OK';
$successfulModel = $fallbackModel;
}
} else {
$response = $dynRes;
}
}
}
}
if (!$response || $response['status'] !== 200 || $finalText === '') {
$errMsg = null;
if (!empty($response['json']['error']['message'])) {
$errMsg = $response['json']['error']['message'];
} elseif (!empty($response['error'])) {
$errMsg = $response['error'];
} elseif (!empty($response['body'])) {
$errMsg = "Gemini fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
} else {
$errMsg = $lastError ?: "Gemini API aanroep mislukt";
}
error_log("fociz_ai_chat_gemini fout: {$errMsg}"); error_log("fociz_ai_chat_gemini fout: {$errMsg}");
return [ return [
'success' => false, 'success' => false,
@@ -151,7 +354,7 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
]; ];
} }
$rawContent = trim($response['json']['candidates'][0]['content']['parts'][0]['text']); $rawContent = $finalText;
// Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op // Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op
if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) { if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) {
@@ -167,14 +370,33 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
// Poging 2: json decode met raw // Poging 2: json decode met raw
$parsedJson = json_decode($rawContent, true); $parsedJson = json_decode($rawContent, true);
} }
// Poging 3: extraheer JSON substring {...} of [...] indien AI conversatietekst heeft toegevoegd
if ($parsedJson === null) {
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $cleanContent, $matchJson)) {
$parsedJson = json_decode($matchJson[0], true);
} elseif (preg_match('/\[(?:[^\[\]]|(?R))*\]/s', $cleanContent, $matchJson)) {
$parsedJson = json_decode($matchJson[0], true);
}
}
}
$promptTokens = (int)($response['json']['usageMetadata']['promptTokenCount'] ?? 0);
$completionTokens = (int)($response['json']['usageMetadata']['candidatesTokenCount'] ?? 0);
if ($promptTokens === 0 && $completionTokens === 0) {
// Schatting obv lengte indien niet geretourneerd
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
$completionTokens = max(1, (int)(strlen($cleanContent) / 4));
} }
return [ return [
'success' => true, 'success' => true,
'content' => $cleanContent, 'content' => $cleanContent,
'json' => $parsedJson, 'json' => $parsedJson,
'model' => $model, 'model' => $successfulModel,
'provider' => 'gemini', 'provider' => 'gemini',
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => ($promptTokens + $completionTokens),
]; ];
} }
@@ -187,12 +409,19 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
* @return array * @return array
*/ */
function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array { function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array {
$apiKey = $aiSettings['ai_api_key']; $apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini'; $model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gpt-4o-mini';
$temperature = $options['temperature'] ?? 0.3; $temperature = $options['temperature'] ?? 0.3;
$maxTokens = $options['max_tokens'] ?? 1500; $maxTokens = $options['max_tokens'] ?? 1500;
$jsonMode = !empty($options['json_mode']); $jsonMode = !empty($options['json_mode']);
if (empty($apiKey)) {
return [
'success' => false,
'error' => 'Geen OpenAI API-sleutel ingevuld of geconfigureerd.',
];
}
$payload = [ $payload = [
'model' => $model, 'model' => $model,
'messages' => $messages, 'messages' => $messages,
@@ -211,11 +440,20 @@ function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
], ],
'body' => $payload, 'body' => $payload,
'timeout' => 35, 'timeout' => 20,
]); ]);
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) { if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "OpenAI aanroep mislukt (HTTP {$response['status']})"); $errMsg = null;
if (!empty($response['json']['error']['message'])) {
$errMsg = $response['json']['error']['message'];
} elseif (!empty($response['error'])) {
$errMsg = $response['error'];
} elseif (!empty($response['body'])) {
$errMsg = "OpenAI fout (HTTP {$response['status']}): " . strip_tags(substr((string)$response['body'], 0, 200));
} else {
$errMsg = "OpenAI API aanroep mislukt (HTTP {$response['status']})";
}
error_log("fociz_ai_chat_openai fout: {$errMsg}"); error_log("fociz_ai_chat_openai fout: {$errMsg}");
return [ return [
'success' => false, 'success' => false,
@@ -230,12 +468,22 @@ function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings
$parsedJson = json_decode($rawContent, true); $parsedJson = json_decode($rawContent, true);
} }
$promptTokens = (int)($response['json']['usage']['prompt_tokens'] ?? 0);
$completionTokens = (int)($response['json']['usage']['completion_tokens'] ?? 0);
if ($promptTokens === 0 && $completionTokens === 0) {
$promptTokens = max(1, (int)(strlen(json_encode($payload)) / 4));
$completionTokens = max(1, (int)(strlen($rawContent) / 4));
}
return [ return [
'success' => true, 'success' => true,
'content' => $rawContent, 'content' => $rawContent,
'json' => $parsedJson, 'json' => $parsedJson,
'model' => $response['json']['model'] ?? $model, 'model' => $response['json']['model'] ?? $model,
'provider' => 'openai', 'provider' => 'openai',
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => ($promptTokens + $completionTokens),
]; ];
} }
@@ -268,10 +516,21 @@ function fociz_ai_chat(array $messages, array $options = []): array {
// Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini // Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini
if ($provider === 'gemini' || strpos($model, 'gemini') !== false) { if ($provider === 'gemini' || strpos($model, 'gemini') !== false) {
return fociz_ai_chat_gemini($messages, $options, $aiSettings); $res = fociz_ai_chat_gemini($messages, $options, $aiSettings);
} else {
$res = fociz_ai_chat_openai($messages, $options, $aiSettings);
} }
return fociz_ai_chat_openai($messages, $options, $aiSettings); // Verbruiksstatistieken registreren bij geslaagde aanroep
if (!empty($res['success'])) {
$usedProvider = $res['provider'] ?? $provider;
$usedModel = $res['model'] ?? $model;
$pTokens = (int)($res['prompt_tokens'] ?? 0);
$cTokens = (int)($res['completion_tokens'] ?? 0);
record_ai_usage($usedProvider, $usedModel, $pTokens, $cTokens, $userId);
}
return $res;
} }
/** /**
@@ -324,17 +583,114 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
'user_id' => $userId, 'user_id' => $userId,
]); ]);
if (!$aiRes['success'] || empty($aiRes['json']['items'])) { if (!$aiRes['success']) {
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,
];
}
$parsed = $aiRes['json'] ?? null;
$items = [];
$summary = '';
if (is_array($parsed)) {
if (!empty($parsed['items']) && is_array($parsed['items'])) {
$items = $parsed['items'];
} elseif (!empty($parsed['tasks']) && is_array($parsed['tasks'])) {
$items = $parsed['tasks'];
} elseif (!empty($parsed['taken']) && is_array($parsed['taken'])) {
$items = $parsed['taken'];
} elseif (!empty($parsed['cards']) && is_array($parsed['cards'])) {
$items = $parsed['cards'];
} elseif (array_is_list($parsed)) {
$items = $parsed;
}
$summary = (string)($parsed['summary'] ?? '');
}
// Als JSON parsing toch leeg was, probeer regex fallback op ruwe content
if (empty($items) && !empty($aiRes['content'])) {
if (preg_match('/\[\s*\{.*\}\s*\]/s', $aiRes['content'], $mArr)) {
$decodedArr = json_decode($mArr[0], true);
if (is_array($decodedArr)) {
$items = $decodedArr;
}
}
}
if (empty($items)) {
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 [
'success' => true,
'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,
]; ];
} }
return [ return [
'success' => true, 'success' => true,
'summary' => $aiRes['json']['summary'] ?? '', 'summary' => $summary ?: 'Dit idee is opgesplitst in behapbare deeltaken.',
'items' => $aiRes['json']['items'], 'items' => $items,
'model' => $aiRes['model'] ?? 'Gemini Pro', 'model' => $aiRes['model'] ?? 'Gemini Pro',
]; ];
} }
@@ -386,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
*/ */
@@ -422,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']];
+93 -12
View File
@@ -34,7 +34,7 @@ function is_https_request(): bool {
/** /**
* AES-256-GCM / AES-256-CBC encryptie helper voor gevoelige tokens (OAuth, API keys) * AES-256-GCM / AES-256-CBC encryptie helper voor gevoelige tokens (OAuth, API keys)
*/ */
function fociz_encrypt(string $plaintext): string { function fociz_encrypt(?string $plaintext): string {
if (empty($plaintext)) return ''; if (empty($plaintext)) return '';
$key = hash('sha256', defined('APP_SECRET') ? APP_SECRET : 'fociz-maria-db-super-secret-key-998877', true); $key = hash('sha256', defined('APP_SECRET') ? APP_SECRET : 'fociz-maria-db-super-secret-key-998877', true);
$iv = random_bytes(16); $iv = random_bytes(16);
@@ -46,7 +46,7 @@ function fociz_encrypt(string $plaintext): string {
/** /**
* AES-256-CBC decryptie helper; decodeert indien versleuteld, laat legacy plaintext ongemoeid * AES-256-CBC decryptie helper; decodeert indien versleuteld, laat legacy plaintext ongemoeid
*/ */
function fociz_decrypt(string $ciphertext): string { function fociz_decrypt(?string $ciphertext): string {
if (empty($ciphertext)) return ''; if (empty($ciphertext)) return '';
if (!str_starts_with($ciphertext, 'ENC:')) { if (!str_starts_with($ciphertext, 'ENC:')) {
return $ciphertext; // Plaintext legacy waarde return $ciphertext; // Plaintext legacy waarde
@@ -112,9 +112,10 @@ function fociz_log_audit(?int $userId, string $action, string $resourceType = ''
$statusNormalized = 'SUCCESS'; $statusNormalized = 'SUCCESS';
} }
$createdAt = date('Y-m-d H:i:s');
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO audit_logs (user_id, user_email, action, resource_type, resource_id, details, ip_address, user_agent, status, created_at) INSERT INTO audit_logs (user_id, user_email, action, resource_type, resource_id, details, ip_address, user_agent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"); ");
$stmt->execute([ $stmt->execute([
$userId ?: null, $userId ?: null,
@@ -125,8 +126,15 @@ function fociz_log_audit(?int $userId, string $action, string $resourceType = ''
$detailsJson, $detailsJson,
$ip, $ip,
$ua, $ua,
$statusNormalized $statusNormalized,
$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());
@@ -283,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());
@@ -339,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) {
@@ -352,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
} }
@@ -366,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` (
@@ -386,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());
} }
@@ -417,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)
*/ */
@@ -445,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());
} }
@@ -503,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
} }
@@ -565,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 {
@@ -573,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 [];
} }
@@ -906,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) {
@@ -956,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) {
+160
View File
@@ -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!'
];
}
+2 -6
View File
@@ -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']);
?> ?>
@@ -56,7 +52,7 @@ $isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod'
<span>Development</span> <span>Development</span>
</span> </span>
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-red-500/20 text-red-300 border border-red-500/30"> <span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-red-500/20 text-red-300 border border-red-500/30">
DEV DEV
</span> </span>
</div> </div>
+75 -6
View File
@@ -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
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+536 -27
View File
@@ -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;
} }
@@ -485,8 +485,24 @@ function microsoft_create_event(int $userId, array $eventData): array {
} }
} }
$startDateTimeStr = "{$date}T{$startTime}:00"; $isAllDay = !empty($eventData['is_all_day']);
$endDateTimeStr = "{$date}T{$endTime}:00"; if ($isAllDay) {
$startDate = $date;
$endDate = !empty($eventData['end_date']) ? trim($eventData['end_date']) : $date;
// In MS Graph API is de einddatum van een all-day event exclusief (dus +1 dag om hele periode te beslaan)
$endPlusOne = date('Y-m-d', strtotime('+1 day', strtotime($endDate)));
$startDateTimeStr = "{$startDate}T00:00:00";
$endDateTimeStr = "{$endPlusOne}T00:00:00";
$showAs = !empty($eventData['show_as']) ? $eventData['show_as'] : 'oof'; // Out of Office / Afwezig
$isReminderOn = false;
$startTime = '00:00';
$endTime = '23:59';
} else {
$startDateTimeStr = "{$date}T{$startTime}:00";
$endDateTimeStr = "{$date}T{$endTime}:00";
$showAs = 'busy';
$isReminderOn = true;
}
$payload = [ $payload = [
'subject' => $subject, 'subject' => $subject,
@@ -502,8 +518,9 @@ function microsoft_create_event(int $userId, array $eventData): array {
'dateTime' => $endDateTimeStr, 'dateTime' => $endDateTimeStr,
'timeZone' => 'Europe/Amsterdam', 'timeZone' => 'Europe/Amsterdam',
], ],
'showAs' => 'busy', 'isAllDay' => $isAllDay,
'isReminderOn' => true, 'showAs' => $showAs,
'isReminderOn' => $isReminderOn,
'reminderMinutesBeforeStart' => 15, 'reminderMinutesBeforeStart' => 15,
]; ];
@@ -601,6 +618,12 @@ function microsoft_update_event(int $userId, string $eventId, array $eventData):
if (!empty($eventData['subject'])) { if (!empty($eventData['subject'])) {
$payload['subject'] = $eventData['subject']; $payload['subject'] = $eventData['subject'];
} }
if (isset($eventData['description']) && $eventData['description'] !== '') {
$payload['body'] = [
'contentType' => 'HTML',
'content' => nl2br(htmlspecialchars($eventData['description'])),
];
}
$date = !empty($eventData['date']) ? $eventData['date'] : date('Y-m-d'); $date = !empty($eventData['date']) ? $eventData['date'] : date('Y-m-d');
$startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00'; $startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00';
@@ -917,19 +940,164 @@ 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 Niek, 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 Niek,</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>',
'from_name' => 'Directie UWV Integraties', 'from_name' => 'Directie UWV Integraties',
'from_email' => 'koppelingen@uwv-overheid.nl', 'from_email' => 'koppelingen@uwv-overheid.nl',
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@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,
@@ -939,14 +1107,15 @@ 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>',
'from_name' => 'Financiële Administratie', 'from_name' => 'Financiële Administratie',
'from_email' => 'facturatie@fociz.nl', 'from_email' => 'facturatie@fociz.nl',
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@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,
@@ -956,14 +1125,15 @@ 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>',
'from_name' => 'DevOps Systeembeheer', 'from_name' => 'DevOps Systeembeheer',
'from_email' => 'infra@fociz.nl', 'from_email' => 'infra@fociz.nl',
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@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,
@@ -973,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>',
@@ -980,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,
@@ -990,14 +1161,15 @@ 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>',
'from_name' => 'Fociz Support Team', 'from_name' => 'Fociz Support Team',
'from_email' => 'no-reply@fociz.nl', 'from_email' => 'no-reply@fociz.nl',
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@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,
@@ -1009,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)) {
@@ -1027,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),
@@ -1045,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,
]; ];
@@ -1060,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' => [
@@ -1068,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),
@@ -1130,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,
@@ -1155,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),
@@ -1163,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
*/ */
+52 -12
View File
@@ -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
+161 -14
View File
@@ -45,8 +45,17 @@ function get_all_system_settings(bool $forceRefresh = false): array {
$stmt = $db->query("SELECT setting_key, setting_value FROM system_settings"); $stmt = $db->query("SELECT setting_key, setting_value FROM system_settings");
while ($row = $stmt->fetch()) { while ($row = $stmt->fetch()) {
$val = $row['setting_value']; $val = $row['setting_value'];
$decoded = json_decode($val, true); if ($val === null) {
$settingsCache[$row['setting_key']] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val; $settingsCache[$row['setting_key']] = null;
continue;
}
$trimmed = trim((string)$val);
if (str_starts_with($trimmed, '{') || str_starts_with($trimmed, '[')) {
$decoded = json_decode($val, true);
$settingsCache[$row['setting_key']] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val;
} else {
$settingsCache[$row['setting_key']] = $val;
}
} }
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("get_all_system_settings error: " . $e->getMessage()); error_log("get_all_system_settings error: " . $e->getMessage());
@@ -60,7 +69,10 @@ function get_all_system_settings(bool $forceRefresh = false): array {
*/ */
function get_system_setting(string $key, $default = null) { function get_system_setting(string $key, $default = null) {
$all = get_all_system_settings(); $all = get_all_system_settings();
return array_key_exists($key, $all) ? $all[$key] : $default; if (array_key_exists($key, $all) && $all[$key] !== null && $all[$key] !== '') {
return $all[$key];
}
return $default;
} }
/** /**
@@ -93,6 +105,11 @@ function set_system_setting(string $key, $value): bool {
* Haalt alle organisatie werkdag standaarden op (Release v1.4) * Haalt alle organisatie werkdag standaarden op (Release v1.4)
*/ */
function get_workday_settings(): array { function get_workday_settings(): array {
$rawDays = get_system_setting('workdays', '1,2,3,4,5');
$workdays = array_values(array_filter(array_map('intval', explode(',', $rawDays)), fn($d) => $d >= 1 && $d <= 7));
if (empty($workdays)) {
$workdays = [1, 2, 3, 4, 5];
}
return [ return [
'workday_start' => get_system_setting('workday_start', '08:30'), 'workday_start' => get_system_setting('workday_start', '08:30'),
'workday_end' => get_system_setting('workday_end', '17:00'), 'workday_end' => get_system_setting('workday_end', '17:00'),
@@ -102,6 +119,8 @@ function get_workday_settings(): array {
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50), 'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5), 'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
'default_task_duration' => (int)get_system_setting('default_task_duration', 30), 'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
'workdays' => $workdays,
'workdays_str' => implode(',', $workdays),
]; ];
} }
@@ -116,6 +135,8 @@ function get_ai_settings(?int $userId = null): array {
$apiKey = ''; $apiKey = '';
$isPersonal = false; $isPersonal = false;
$personalProvider = null;
$personalModel = null;
// 1. Controleer persoonlijke sleutel van de ingelogde gebruiker // 1. Controleer persoonlijke sleutel van de ingelogde gebruiker
if ($userId !== null && $userId > 0) { if ($userId !== null && $userId > 0) {
@@ -127,12 +148,12 @@ function get_ai_settings(?int $userId = null): array {
if ($row) { if ($row) {
// Sleutel kan in access_token staan of in settings['api_key'] // Sleutel kan in access_token staan of in settings['api_key']
if (!empty($row['access_token'])) { if (!empty($row['access_token'])) {
$apiKey = fociz_decrypt($row['access_token']); $apiKey = fociz_decrypt((string)$row['access_token']);
$isPersonal = true; $isPersonal = true;
} elseif (!empty($row['settings'])) { } elseif (!empty($row['settings'])) {
$userSettings = json_decode($row['settings'], true) ?: []; $userSettings = json_decode($row['settings'], true) ?: [];
if (!empty($userSettings['api_key'])) { if (!empty($userSettings['api_key'])) {
$apiKey = fociz_decrypt($userSettings['api_key']); $apiKey = fociz_decrypt((string)$userSettings['api_key']);
$isPersonal = true; $isPersonal = true;
} }
} }
@@ -140,10 +161,10 @@ function get_ai_settings(?int $userId = null): array {
if (!empty($row['settings'])) { if (!empty($row['settings'])) {
$userSettings = json_decode($row['settings'], true) ?: []; $userSettings = json_decode($row['settings'], true) ?: [];
if (!empty($userSettings['provider'])) { if (!empty($userSettings['provider'])) {
$personalProvider = $userSettings['provider']; $personalProvider = (string)$userSettings['provider'];
} }
if (!empty($userSettings['model'])) { if (!empty($userSettings['model'])) {
$personalModel = $userSettings['model']; $personalModel = (string)$userSettings['model'];
} }
} }
} }
@@ -156,24 +177,34 @@ function get_ai_settings(?int $userId = null): array {
if (empty($apiKey)) { if (empty($apiKey)) {
$savedKeyRaw = get_system_setting('ai_api_key', ''); $savedKeyRaw = get_system_setting('ai_api_key', '');
if (!empty($savedKeyRaw)) { if (!empty($savedKeyRaw)) {
$apiKey = fociz_decrypt($savedKeyRaw); $apiKey = fociz_decrypt((string)$savedKeyRaw);
} }
} }
// 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY) // 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY)
if (empty($apiKey)) { if (empty($apiKey)) {
$apiKey = fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', ''); $apiKey = (string)(fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', ''));
} }
$provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini'); $provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini');
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-1.5-pro'); if (empty($provider)) {
$provider = 'gemini';
}
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
if (empty($model)) {
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
}
$apiKey = trim((string)$apiKey);
$hasKey = ($apiKey !== '');
return [ return [
'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)), 'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
'ai_provider' => $provider, 'ai_provider' => (string)$provider,
'ai_api_key' => $apiKey, 'ai_api_key' => $apiKey,
'ai_model' => $model, 'ai_model' => (string)$model,
'has_key' => !empty($apiKey), 'has_key' => $hasKey,
'is_personal_key'=> $isPersonal, 'is_personal_key'=> $isPersonal,
]; ];
} }
@@ -188,3 +219,119 @@ function get_session_idle_timeout(): int {
return in_array($timeout, $validTimeouts, true) ? $timeout : 1800; return in_array($timeout, $validTimeouts, true) ? $timeout : 1800;
} }
/**
* Haalt de beheerbare dagblokkade-redenen op (bijv. Vakantie, Verlof, Ziek, Focusdag, ADV).
* Standaard voorzien van een duidelijke titel, emoji-icoon en actief-status.
*/
function get_calendar_block_reasons(): array {
$defaultReasons = [
['code' => 'VACATION', 'title' => 'Vakantie', 'icon' => '🏖️', 'is_active' => 1],
['code' => 'LEAVE', 'title' => 'Verlof / Vrij', 'icon' => '✈️', 'is_active' => 1],
['code' => 'SICK', 'title' => 'Ziek / Afwezig', 'icon' => '🤒', 'is_active' => 1],
['code' => 'FOCUS', 'title' => 'Focusdag', 'icon' => '🎯', 'is_active' => 1],
['code' => 'ADV', 'title' => 'ADV Dag', 'icon' => '📋', 'is_active' => 1],
];
$saved = get_system_setting('calendar_block_reasons', null);
if (is_array($saved) && !empty($saved)) {
return $saved;
}
return $defaultReasons;
}
/**
* Haalt de beheerbare brainstorm categorieën op.
* Standaard voorzien van een duidelijke categorienaam, beschrijving en actief-status.
*/
function get_brainstorm_categories(): array {
$defaultCategories = [
['name' => 'Product & Strategie', 'description' => 'Strategische richting, nieuwe producten en visie', 'is_active' => 1],
['name' => 'Procesverbetering', 'description' => 'Optimalisatie van werkprocessen en efficiëntie', 'is_active' => 1],
['name' => 'Klant & Service', 'description' => 'Klanttevredenheid, support en klantervaring', 'is_active' => 1],
['name' => 'Technologie & IT', 'description' => 'Architectuur, tooling, infrastructuur en tech stack','is_active' => 1],
['name' => 'Marketing & Groei', 'description' => 'Leadgeneratie, branding en marktpositie', 'is_active' => 1],
];
$saved = get_system_setting('brainstorm_categories', null);
if (is_array($saved) && !empty($saved)) {
return $saved;
}
return $defaultCategories;
}
/**
* Registreert AI verbruiksstatistieken (aanroepen, tokens, provider/model)
*/
function record_ai_usage(string $provider, string $model, int $promptTokens = 0, int $completionTokens = 0, ?int $userId = null): void {
try {
$stats = get_system_setting('ai_usage_stats', [
'total_requests' => 0,
'total_prompt_tokens' => 0,
'total_completion_tokens' => 0,
'total_tokens' => 0,
'last_used_at' => null,
'by_provider' => [],
'by_model' => [],
'by_date' => [],
]);
$stats['total_requests'] = (int)($stats['total_requests'] ?? 0) + 1;
$stats['total_prompt_tokens'] = (int)($stats['total_prompt_tokens'] ?? 0) + $promptTokens;
$stats['total_completion_tokens'] = (int)($stats['total_completion_tokens'] ?? 0) + $completionTokens;
$stats['total_tokens'] = (int)($stats['total_tokens'] ?? 0) + ($promptTokens + $completionTokens);
$stats['last_used_at'] = date('Y-m-d H:i:s');
// Per provider
if (!isset($stats['by_provider'][$provider])) {
$stats['by_provider'][$provider] = 0;
}
$stats['by_provider'][$provider]++;
// Per model
if (!isset($stats['by_model'][$model])) {
$stats['by_model'][$model] = 0;
}
$stats['by_model'][$model]++;
// Per datum
$today = date('Y-m-d');
if (!isset($stats['by_date'][$today])) {
$stats['by_date'][$today] = ['requests' => 0, 'tokens' => 0];
}
$stats['by_date'][$today]['requests']++;
$stats['by_date'][$today]['tokens'] += ($promptTokens + $completionTokens);
// Maximaal 30 dagen historiek
if (count($stats['by_date']) > 30) {
ksort($stats['by_date']);
$stats['by_date'] = array_slice($stats['by_date'], -30, 30, true);
}
set_system_setting('ai_usage_stats', $stats);
} catch (Throwable $e) {
error_log("record_ai_usage fout: " . $e->getMessage());
}
}
/**
* Haalt de AI verbruiksstatistieken op
*/
function get_ai_usage_stats(): array {
$stats = get_system_setting('ai_usage_stats', null);
if (!is_array($stats)) {
return [
'total_requests' => 0,
'total_prompt_tokens' => 0,
'total_completion_tokens' => 0,
'total_tokens' => 0,
'last_used_at' => null,
'by_provider' => [],
'by_model' => [],
'by_date' => [],
];
}
return $stats;
}
+1761 -409
View File
File diff suppressed because it is too large Load Diff
+622 -91
View File
@@ -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">
@@ -122,11 +153,11 @@ $jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_Q
<div class="flex items-center space-x-1.5 bg-slate-100/90 dark:bg-slate-800/60 p-1 rounded-xl border border-slate-200/70 dark:border-slate-700/60 text-xs font-bold w-full sm:w-auto"> <div class="flex items-center space-x-1.5 bg-slate-100/90 dark:bg-slate-800/60 p-1 rounded-xl border border-slate-200/70 dark:border-slate-700/60 text-xs font-bold w-full sm:w-auto">
<button onclick="setMailFilter('all')" id="filterBtn_all" class="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"> <button onclick="setMailFilter('all')" id="filterBtn_all" class="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">
<span>Alle Berichten</span> <span>Alle Berichten</span>
<span class="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400" id="countAll"><?= count($messages) ?></span> <span class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400" id="countAll"><?= count($messages) ?></span>
</button> </button>
<button onclick="setMailFilter('unread')" id="filterBtn_unread" class="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"> <button onclick="setMailFilter('unread')" id="filterBtn_unread" class="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">
<span>Ongelezen</span> <span>Ongelezen</span>
<span class="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-sky-100 dark:bg-sky-900/60 text-sky-700 dark:text-sky-300" id="countUnread"><?= $unreadCount ?></span> <span class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] bg-sky-100 dark:bg-sky-900/60 text-sky-700 dark:text-sky-300" id="countUnread"><?= $unreadCount ?></span>
</button> </button>
</div> </div>
@@ -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 &amp; 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 &amp; 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,101 +613,260 @@ 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";
}
applyMailSearch(); // Bij ongelezen filter: klap automatisch alle actieve mappen met ongelezen mails open!
} mailFolders.forEach(f => {
if (disabledFolderIds.has(f.id)) return;
function applyMailSearch() { const msgs = folderMessagesMap[f.id];
const q = (document.getElementById('mailSearchInput')?.value || '').toLowerCase().trim(); const hasUnread = msgs ? msgs.some(m => !m.is_read) : ((f.unread_count || 0) > 0);
let filtered = allMessages; if (hasUnread) {
expandedFolders.add(f.id);
if (activeFilter === 'unread') { if (!folderMessagesMap[f.id]) {
filtered = filtered.filter(m => !m.is_read); loadFolderMessagesInBackground(f.id);
} }
}
if (q) {
filtered = filtered.filter(m => {
const sub = (m.subject || '').toLowerCase();
const from = (m.from_name || '').toLowerCase() + ' ' + (m.from_email || '').toLowerCase();
const prev = (m.body_preview || '').toLowerCase();
return sub.includes(q) || from.includes(q) || prev.includes(q);
}); });
} }
renderMailList(filtered); renderFolders();
} }
function renderMailList(list) { function applyMailSearch() {
const container = document.getElementById('mailListContainer'); renderFolders();
const countEl = document.getElementById('visibleMailCount'); }
if (countEl) countEl.innerText = list.length;
if (!list || list.length === 0) { async function loadFolderMessagesInBackground(folderId) {
container.innerHTML = ` if (loadingFolders.has(folderId) || folderMessagesMap[folderId]) return;
<div class="text-center py-12 text-slate-400 text-xs"> loadingFolders.add(folderId);
<i data-lucide="inbox" class="w-8 h-8 text-slate-300 dark:text-slate-700 mx-auto mb-2"></i> renderFolders();
<p>Geen berichten gevonden.</p>
</div> try {
`; const res = await fetch(`/api/mail.php?action=get_folder_messages&folder_id=${encodeURIComponent(folderId)}&limit=30`);
if (typeof lucide !== 'undefined') lucide.createIcons(); 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; return;
} }
let html = ''; expandedFolders.add(folderId);
list.forEach(m => {
const isSelected = selectedMessage && selectedMessage.id === m.id;
const isUnread = !m.is_read;
const initials = (m.from_name || 'NL').split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
let cardClass = isSelected if (!folderMessagesMap[folderId]) {
? 'bg-sky-50 dark:bg-sky-950/40 border-sky-400 dark:border-sky-700 shadow-xs' loadingFolders.add(folderId);
: (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'); 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) {
filteredMsgs = filteredMsgs.filter(m => {
const sub = (m.subject || '').toLowerCase();
const from = (m.from_name || '').toLowerCase() + ' ' + (m.from_email || '').toLowerCase();
const prev = (m.body_preview || '').toLowerCase();
return sub.includes(q) || from.includes(q) || prev.includes(q);
});
}
if (isExpanded) {
totalVisible += filteredMsgs.length;
}
const folderTotal = folder.total_count || msgs.length;
html += ` html += `
<div onclick="selectMail('${m.id}')" class="group relative p-3 rounded-2xl border transition cursor-pointer ${cardClass}"> <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="flex items-start justify-between gap-2"> <!-- Folder Header Accordion Knop -->
<div class="flex items-center space-x-2 truncate"> <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'}">
<div class="w-6 h-6 rounded-lg ${isUnread ? 'bg-sky-600 text-white' : 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400'} text-[10px] font-bold flex items-center justify-center flex-shrink-0"> <div class="flex items-center space-x-2.5 min-w-0 ${folder.is_child ? 'pl-3' : ''}">
${initials} <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>
<span class="text-xs ${isUnread ? 'font-black text-slate-900 dark:text-white' : 'font-medium text-slate-700 dark:text-slate-300'} truncate">
${escapeHtml(m.from_name)}
</span>
</div> </div>
<div class="flex items-center space-x-1.5 flex-shrink-0"> <div class="flex items-center space-x-1.5 flex-shrink-0 ml-2" onclick="event.stopPropagation()">
${isUnread ? '<span class="w-2 h-2 rounded-full bg-sky-500"></span>' : ''} ${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>` : ''}
${m.has_attachments ? '<i data-lucide="paperclip" class="w-3 h-3 text-slate-400"></i>' : ''} ${!isInbox ? `
<span class="text-[10px] text-slate-400 font-medium">${escapeHtml(m.received_human || '')}</span> <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>
</div> </div>
<div class="mt-1 text-xs ${isUnread ? 'font-bold text-slate-900 dark:text-white' : 'text-slate-800 dark:text-slate-200'} truncate"> <!-- Folder Berichten Inhoud -->
${escapeHtml(m.subject)} <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">
</div> ${isLoading ? `
<div class="p-4 text-center text-xs text-slate-400 flex items-center justify-center space-x-1.5">
<div class="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500 line-clamp-1 leading-snug"> <i data-lucide="loader-2" class="w-4 h-4 animate-spin text-sky-500"></i>
${escapeHtml(m.body_preview || '')} <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>
</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 &amp; herstellen &rarr;
</button>
</div>
`;
}
container.innerHTML = html; container.innerHTML = html;
const countEl = document.getElementById('visibleMailCount');
if (countEl) countEl.innerText = totalVisible;
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
} }
function renderMessageCardHtml(m) {
const isSelected = selectedMessage && selectedMessage.id === m.id;
const isUnread = !m.is_read;
const initials = (m.from_name || 'NL').split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
let cardClass = isSelected
? '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');
return `
<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-center space-x-2 truncate">
<div class="w-6 h-6 rounded-lg ${isUnread ? 'bg-sky-600 text-white' : 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400'} text-[10px] font-bold flex items-center justify-center flex-shrink-0">
${initials}
</div>
<span class="text-xs ${isUnread ? 'font-black text-slate-900 dark:text-white' : 'font-medium text-slate-700 dark:text-slate-300'} truncate">
${escapeHtml(m.from_name)}
</span>
</div>
<div class="flex items-center space-x-1.5 flex-shrink-0">
${isUnread ? '<span class="w-2 h-2 rounded-full bg-sky-500"></span>' : ''}
${m.has_attachments ? '<i data-lucide="paperclip" class="w-3 h-3 text-slate-400"></i>' : ''}
<span class="text-[10px] text-slate-400 font-medium">${escapeHtml(m.received_human || '')}</span>
</div>
</div>
<div class="mt-1 text-xs ${isUnread ? 'font-bold text-slate-900 dark:text-white' : 'text-slate-800 dark:text-slate-200'} truncate">
${escapeHtml(m.subject)}
</div>
<div class="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500 line-clamp-1 leading-snug">
${escapeHtml(m.body_preview || '')}
</div>
</div>
`;
}
function renderMailList() {
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);
found.is_read = true; if (!found.is_read) {
updateUnreadCount(); found.is_read = true;
renderMailList(allMessages); const fObj = mailFolders.find(f => f.id === foundFolderId);
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, '&#039;'); .replace(/'/g, '&#039;');
} }
// 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 ? `&bull; ${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
+3703 -398
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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>
+633 -4
View File
@@ -38,6 +38,21 @@ try {
} }
$quarters = ['Q1', 'Q2', 'Q3', 'Q4']; $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
$currentUserId = (int)($currentUser['id'] ?? 0);
// Haal alle actieve teamleden op voor toewijzing & agendablokkades
$teamMembersStmt = $db->query("SELECT id, name, email, role, avatar_url FROM users ORDER BY name ASC");
$teamMembers = $teamMembersStmt->fetchAll();
$outlookUsers = [];
$intStmt = $db->query("SELECT user_id FROM integrations WHERE provider = 'OUTLOOK' AND is_enabled = 1 AND access_token IS NOT NULL");
foreach ($intStmt->fetchAll() as $row) {
$outlookUsers[(int)$row['user_id']] = true;
}
foreach ($teamMembers as &$tm) {
$tm['has_outlook'] = !empty($outlookUsers[(int)$tm['id']]);
}
unset($tm);
?> ?>
<div class="space-y-6 max-w-7xl mx-auto"> <div class="space-y-6 max-w-7xl mx-auto">
@@ -64,13 +79,21 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<!-- Filters: Quarters & Status --> <!-- Filters: Quarters & Status -->
<div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3"> <div class="fociz-card p-4 flex flex-wrap items-center justify-between gap-3">
<!-- Quarter Tabs --> <!-- Quarter Tabs (tevens drop targets) -->
<div class="flex items-center space-x-1 text-xs"> <div class="flex items-center space-x-1 text-xs">
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-semibold <?= $selectedQuarter === 'ALL' ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>"> <a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-semibold <?= $selectedQuarter === 'ALL' ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>">
Alle Quarters Alle Quarters
</a> </a>
<?php foreach ($quarters as $q): ?> <?php foreach ($quarters as $q): ?>
<a href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-semibold <?= $selectedQuarter === $q ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>"> <a
href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>"
class="roadmap-quarter-tab px-3.5 py-1.5 rounded-lg transition-all font-semibold <?= $selectedQuarter === $q ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/80' ?>"
data-quarter="<?= $q ?>"
ondragover="handleRoadmapQuarterDragOver(event)"
ondragleave="handleRoadmapQuarterDragLeave(event)"
ondrop="handleRoadmapQuarterDrop(event, '<?= $q ?>')"
title="Sleep een roadmap item hierheen om het kwartaal direct te wijzigen"
>
<?= $q ?> <?= $q ?>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
@@ -108,7 +131,23 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700', default => 'border-l-4 border-l-slate-300 dark:border-l-slate-700',
}; };
?> ?>
<div class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?>" id="roadmap-item-<?= $item['id'] ?>"> <div
id="roadmap-item-<?= $item['id'] ?>"
draggable="true"
ondragstart="handleRoadmapItemDragStart(event, <?= $item['id'] ?>)"
ondragend="handleRoadmapItemDragEnd(event)"
oncontextmenu="openRoadmapContextMenu(event, <?= $item['id'] ?>); return false;"
data-title="<?= htmlspecialchars($item['title'], ENT_QUOTES) ?>"
data-description="<?= htmlspecialchars($item['description'] ?? '', ENT_QUOTES) ?>"
data-quarter="<?= htmlspecialchars($item['quarter']) ?>"
data-type="<?= htmlspecialchars($item['type']) ?>"
data-priority="<?= htmlspecialchars($item['priority']) ?>"
data-status="<?= htmlspecialchars($item['status']) ?>"
data-due-date="<?= htmlspecialchars($item['due_date'] ?? '') ?>"
data-jira-key="<?= htmlspecialchars($item['jira_issue_key'] ?? '') ?>"
class="fociz-card-interactive p-5 space-y-3.5 <?= $statusBorder ?> cursor-grab active:cursor-grabbing transition-all duration-150"
title="Sleep naar een ander kwartaal (Q1, Q2, Q3, Q4) of klik met rechtermuisknop"
>
<!-- Top header van item --> <!-- Top header van item -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
@@ -278,10 +317,241 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer"> <button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren Annuleren
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal: Roadmap Item Bewerken -->
<div id="editRoadmapModal" onclick="if(event.target === this) closeEditRoadmapModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Roadmap Item Bewerken</span>
</h3>
<button onclick="closeEditRoadmapModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<input type="hidden" id="editRoadmapId">
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="editRoadmapTitle" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving</label>
<textarea id="editRoadmapDescription" rows="3" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none shadow-2xs"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
<select id="editRoadmapQuarter" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3">Q3</option>
<option value="Q4">Q4</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select id="editRoadmapType" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="FEATURE">Feature</option>
<option value="EPIC">Epic</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
<select id="editRoadmapPriority" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="CRITICAL">Kritiek</option>
<option value="HIGH">Hoog</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Status</label>
<select id="editRoadmapStatus" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<option value="PLANNED">Gepland</option>
<option value="IN_PROGRESS">In Uitvoering</option>
<option value="REVIEW">In Review</option>
<option value="COMPLETED">Voltooid</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key</label>
<input type="text" id="editRoadmapJiraKey" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="editRoadmapDueDate" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button id="btnSaveEditRoadmap" onclick="submitEditRoadmapItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Opslaan
</button>
<button onclick="closeEditRoadmapModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Roadmap Item Toewijzen & Inplannen in Vrij Blok -->
<div id="roadmapScheduleModal" onclick="if(event.target === this) closeRoadmapScheduleModal()" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="fociz-card p-6 sm:p-7 shadow-2xl max-w-lg w-full space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Inplannen in Agenda van Collega</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Plant dit item direct in een vrij tijdblok van de collega</p>
</div>
</div>
<button onclick="closeRoadmapScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<input type="hidden" id="roadmapScheduleItemId">
<div class="space-y-4">
<!-- Item titel preview -->
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs">
<span class="text-[10px] text-slate-400 font-bold uppercase tracking-wider block mb-0.5">Roadmap Item</span>
<span id="roadmapScheduleTitlePreview" class="font-bold text-slate-800 dark:text-slate-200"></span>
</div>
<!-- Collega Selector -->
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Toewijzen aan collega</label>
<select id="roadmapAssigneeSelect" onchange="onRoadmapAssigneeChange()" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
<?php foreach ($teamMembers as $tm): ?>
<option value="<?= $tm['id'] ?>" data-has-outlook="<?= $tm['has_outlook'] ? '1' : '0' ?>" <?= $tm['id'] === $currentUserId ? 'selected' : '' ?>>
<?= htmlspecialchars($tm['name']) ?> (<?= $tm['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>) <?= $tm['has_outlook'] ? '— 📅 Outlook Actief' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- Outlook melding -->
<div id="roadmapAssignOutlookNotice" class="p-2 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center space-x-2 text-[11px] text-sky-700 dark:text-sky-300">
<i data-lucide="calendar-check" class="w-4 h-4 text-sky-500 flex-shrink-0"></i>
<span>Deze collega heeft Microsoft Outlook gekoppeld. Er wordt gekeken naar echte vrije blokken.</span>
</div>
<!-- Vrij Blok Zoeker -->
<div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-amber-500"></i>
<span>Automatisch vrij blok vinden</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden (09:00 - 17:00)</span>
</div>
<button type="button" id="btnRoadmapFindSlot" onclick="findFreeSlotForRoadmapAssignee()" class="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-xs shadow-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3.5 h-3.5"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="roadmapFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-xs text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 flex-shrink-0"></i>
<span id="roadmapFoundSlotText"></span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Datum</label>
<input type="date" id="roadmapPlanDate" value="<?= date('Y-m-d') ?>" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
<input type="time" id="roadmapStartTime" value="10:00" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Duur</label>
<select id="roadmapDurationMinutes" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
<option value="30">30 minuten</option>
<option value="45">45 minuten</option>
<option value="60" selected>1 uur</option>
<option value="90">1.5 uur</option>
<option value="120">2 uur</option>
</select>
</div>
<div class="flex items-center pt-5">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="roadmapSyncOutlook" checked class="rounded text-indigo-600 focus:ring-indigo-500">
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200">Sync met Outlook 365</span>
</label>
</div>
</div>
<div class="flex space-x-2 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<button id="btnSubmitRoadmapSchedule" onclick="submitRoadmapSchedule()" class="flex-1 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs flex items-center justify-center space-x-1.5">
<i data-lucide="check-circle" class="w-4 h-4"></i>
<span>Inplannen in Agenda</span>
</button>
<button onclick="closeRoadmapScheduleModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-semibold transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Rechtermuisknop Contextmenu voor Roadmap Items -->
<div id="roadmapContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
<button onclick="triggerRoadmapCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Item bewerken</span>
</button>
<button onclick="triggerRoadmapCtxSchedule()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="calendar-plus" class="w-4 h-4 text-amber-500"></i>
<span>👤 Toewijzen & vrij blok inplannen</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<div class="px-3.5 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Verplaats naar Kwartaal</div>
<div class="grid grid-cols-4 gap-1 px-3 py-1">
<button onclick="triggerRoadmapCtxMoveQuarter('Q1')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q1</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q2')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q2</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q3')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q3</button>
<button onclick="triggerRoadmapCtxMoveQuarter('Q4')" class="py-1 text-center font-mono font-bold rounded hover:bg-indigo-50 dark:hover:bg-indigo-950/50 text-indigo-600 cursor-pointer">Q4</button>
</div>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerRoadmapCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Item verwijderen</span>
</button>
</div>
<script> <script>
async function updateRoadmapStatus(id, status) { async function updateRoadmapStatus(id, status) {
await fetch('/api/roadmap.php?action=update_item', { await fetch('/api/roadmap.php?action=update_item', {
@@ -333,10 +603,369 @@ async function submitManualItem() {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} }
}
// --- Drag & Drop van Roadmap Items naar ander Kwartaal (Q1, Q2, Q3, Q4) ---
let draggedRoadmapItemId = null;
function handleRoadmapItemDragStart(e, itemId) {
draggedRoadmapItemId = itemId;
try {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(itemId));
} catch (err) {}
setTimeout(() => {
const el = document.getElementById(`roadmap-item-${itemId}`);
if (el) el.classList.add('opacity-40', 'scale-[0.99]');
}, 0);
}
function handleRoadmapItemDragEnd(e) {
if (draggedRoadmapItemId) {
const el = document.getElementById(`roadmap-item-${draggedRoadmapItemId}`);
if (el) el.classList.remove('opacity-40', 'scale-[0.99]');
draggedRoadmapItemId = null;
}
document.querySelectorAll('.roadmap-quarter-tab').forEach(tab => {
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
});
}
function handleRoadmapQuarterDragOver(e) {
if (!draggedRoadmapItemId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const tab = e.currentTarget;
if (tab && !tab.classList.contains('ring-2')) {
tab.classList.add('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
}
}
function handleRoadmapQuarterDragLeave(e) {
const tab = e.currentTarget;
if (tab && !tab.contains(e.relatedTarget)) {
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
}
}
async function handleRoadmapQuarterDrop(e, targetQuarter) {
e.preventDefault();
const tab = e.currentTarget;
if (tab) {
tab.classList.remove('ring-2', 'ring-indigo-400', 'scale-105', 'bg-indigo-100', 'dark:bg-indigo-900/60');
}
const itemId = draggedRoadmapItemId || parseInt(e.dataTransfer.getData('text/plain'), 10);
draggedRoadmapItemId = null;
if (itemId) {
const el = document.getElementById(`roadmap-item-${itemId}`);
if (el) el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
}
document.querySelectorAll('[id^="roadmap-item-"].opacity-40').forEach(el => {
el.classList.remove('opacity-40', 'scale-[0.99]', 'pointer-events-none');
});
if (!itemId || !targetQuarter) return;
try {
const res = await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
});
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert(data.error || 'Kon item niet verplaatsen');
}
} catch (err) {
console.error('Fout bij verplaatsen roadmap item:', err);
alert('Netwerkfout bij verplaatsen');
}
}
// --- Rechtermuisknop Contextmenu & Acties voor Roadmap Items ---
let activeRoadmapCtxItemId = null;
function openRoadmapContextMenu(e, itemId) {
e.preventDefault();
e.stopPropagation();
activeRoadmapCtxItemId = itemId;
const menu = document.getElementById('roadmapContextMenu');
if (!menu) return;
menu.classList.remove('hidden');
const x = Math.min(e.clientX, window.innerWidth - 240);
const y = Math.min(e.clientY, window.innerHeight - 240);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeRoadmapContextMenu() {
const menu = document.getElementById('roadmapContextMenu');
if (menu) menu.classList.add('hidden');
activeRoadmapCtxItemId = null;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#roadmapContextMenu')) {
closeRoadmapContextMenu();
}
});
function triggerRoadmapCtxEdit() {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
const el = document.getElementById(`roadmap-item-${itemId}`);
if (!el) return;
document.getElementById('editRoadmapId').value = itemId;
document.getElementById('editRoadmapTitle').value = el.getAttribute('data-title') || '';
document.getElementById('editRoadmapDescription').value = el.getAttribute('data-description') || '';
document.getElementById('editRoadmapQuarter').value = el.getAttribute('data-quarter') || 'Q3';
document.getElementById('editRoadmapType').value = el.getAttribute('data-type') || 'FEATURE';
document.getElementById('editRoadmapPriority').value = el.getAttribute('data-priority') || 'HIGH';
document.getElementById('editRoadmapStatus').value = el.getAttribute('data-status') || 'PLANNED';
document.getElementById('editRoadmapDueDate').value = el.getAttribute('data-due-date') || '';
document.getElementById('editRoadmapJiraKey').value = el.getAttribute('data-jira-key') || '';
const modal = document.getElementById('editRoadmapModal');
modal.classList.remove('hidden');
if (typeof lucide !== 'undefined') lucide.createIcons();
document.getElementById('editRoadmapTitle')?.focus();
}
function closeEditRoadmapModal() {
document.getElementById('editRoadmapModal')?.classList.add('hidden');
}
async function submitEditRoadmapItem() {
const id = parseInt(document.getElementById('editRoadmapId').value, 10);
const title = document.getElementById('editRoadmapTitle').value.trim();
const description = document.getElementById('editRoadmapDescription').value.trim();
const quarter = document.getElementById('editRoadmapQuarter').value;
const type = document.getElementById('editRoadmapType').value;
const priority = document.getElementById('editRoadmapPriority').value;
const status = document.getElementById('editRoadmapStatus').value;
const due_date = document.getElementById('editRoadmapDueDate').value;
const jira_key = document.getElementById('editRoadmapJiraKey').value.trim();
if (!title) {
alert('Titel is verplicht');
return;
}
const btn = document.getElementById('btnSaveEditRoadmap');
btn.disabled = true;
btn.innerText = 'Opslaan...';
try {
const res = await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
id,
title,
description,
quarter,
type,
priority,
status,
due_date,
jira_key
})
});
const data = await res.json();
if (data.success) {
closeEditRoadmapModal();
location.reload();
} else {
alert(data.error || 'Kon item niet opslaan');
}
} catch(e) {
alert('Netwerkfout bij opslaan');
} finally {
btn.disabled = false;
btn.innerText = 'Opslaan';
}
}
function triggerRoadmapCtxSchedule() {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
const el = document.getElementById(`roadmap-item-${itemId}`);
if (!el) return;
document.getElementById('roadmapScheduleItemId').value = itemId;
document.getElementById('roadmapScheduleTitlePreview').innerText = el.getAttribute('data-title') || 'Roadmap item';
document.getElementById('roadmapFoundSlotNotice').classList.add('hidden');
onRoadmapAssigneeChange();
const modal = document.getElementById('roadmapScheduleModal');
modal.classList.remove('hidden');
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
findFreeSlotForRoadmapAssignee();
}, 150);
}
function closeRoadmapScheduleModal() {
document.getElementById('roadmapScheduleModal')?.classList.add('hidden');
}
function onRoadmapAssigneeChange() {
const sel = document.getElementById('roadmapAssigneeSelect');
const opt = sel.options[sel.selectedIndex];
const notice = document.getElementById('roadmapAssignOutlookNotice');
if (opt && opt.getAttribute('data-has-outlook') === '1') {
notice.classList.remove('hidden');
} else {
notice.classList.add('hidden');
}
}
async function findFreeSlotForRoadmapAssignee() {
const sel = document.getElementById('roadmapAssigneeSelect');
const targetUserId = sel.value ? parseInt(sel.value, 10) : <?= $currentUserId ?>;
const duration = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
const btn = document.getElementById('btnRoadmapFindSlot');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Zoeken...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: duration,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('roadmapPlanDate').value = slot.date;
document.getElementById('roadmapStartTime').value = slot.start;
const notice = document.getElementById('roadmapFoundSlotNotice');
const noticeText = document.getElementById('roadmapFoundSlotText');
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden.');
}
} catch(err) {
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
async function submitRoadmapSchedule() {
const itemId = parseInt(document.getElementById('roadmapScheduleItemId').value, 10);
const assigneeId = document.getElementById('roadmapAssigneeSelect').value ? parseInt(document.getElementById('roadmapAssigneeSelect').value, 10) : null;
const planDate = document.getElementById('roadmapPlanDate').value;
const startTime = document.getElementById('roadmapStartTime').value;
const durationMinutes = parseInt(document.getElementById('roadmapDurationMinutes').value, 10) || 60;
const syncOutlook = document.getElementById('roadmapSyncOutlook').checked;
const el = document.getElementById(`roadmap-item-${itemId}`);
const title = el ? el.getAttribute('data-title') : 'Roadmap Item';
const btn = document.getElementById('btnSubmitRoadmapSchedule');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Inplannen...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
// Maak agenda taak aan via api/planner.php
const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
date: planDate,
title: title,
start_time: startTime,
duration: durationMinutes,
type: 'PROJECT',
priority: 'MEDIUM',
sync_to_microsoft: syncOutlook,
target_user_id: assigneeId
})
});
const data = await res.json();
if (data.success) {
closeRoadmapScheduleModal();
let msg = `Roadmap item is succesvol ingepland op ${planDate} van ${startTime} tot ${data.task?.end_time || ''}!`;
if (data.outlook_message) {
msg += `\n${data.outlook_message}`;
}
alert(msg);
} else {
alert(data.error || 'Kon afspraak niet inplannen');
}
} catch(err) {
alert('Netwerkfout bij inplannen: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
async function triggerRoadmapCtxMoveQuarter(targetQuarter) {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
try {
const res = await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id: itemId, quarter: targetQuarter })
});
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert(data.error || 'Kon item niet verplaatsen');
}
} catch (err) {
alert('Netwerkfout bij verplaatsen item');
}
}
function triggerRoadmapCtxDelete() {
if (!activeRoadmapCtxItemId) return;
const itemId = activeRoadmapCtxItemId;
closeRoadmapContextMenu();
deleteRoadmapItem(itemId);
}
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
const modal = document.getElementById('newItemModal'); const modal = document.getElementById('newItemModal');
if (modal) modal.classList.add('hidden'); if (modal && !modal.classList.contains('hidden')) modal.classList.add('hidden');
closeEditRoadmapModal();
closeRoadmapScheduleModal();
closeRoadmapContextMenu();
} }
}); });
</script> </script>
+284 -10
View File
@@ -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>
@@ -164,13 +162,18 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
<!-- LINKERPANEEL: 'Werk' Takenlijst & Snelle Invoer --> <!-- LINKERPANEEL: 'Werk' Takenlijst & Snelle Invoer -->
<div class="w-80 sm:w-96 border-r border-slate-200 dark:border-slate-800/80 flex flex-col flex-shrink-0 bg-white dark:bg-[#0c0f17]/90 z-20"> <div class="w-80 sm:w-96 border-r border-slate-200 dark:border-slate-800/80 flex flex-col flex-shrink-0 bg-white dark:bg-[#0c0f17]/90 z-20">
<!-- Werk Paneel Header --> <!-- Werk Paneel Header met Lane Drop Targets -->
<div class="h-12 px-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between bg-slate-50/70 dark:bg-slate-900/40"> <div class="h-12 px-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between bg-slate-50/70 dark:bg-slate-900/40">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-xs font-bold text-slate-800 dark:text-slate-200 uppercase tracking-wider">Werk</span> <span class="text-xs font-bold text-slate-800 dark:text-slate-200 uppercase tracking-wider">Werk</span>
<span id="workItemsCountBadge" class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">0</span> <span id="workItemsCountBadge" class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">0</span>
</div> </div>
<div class="text-[11px] text-slate-400">Status & Datum</div> <div class="text-[11px] text-slate-400">Sleep taak naar lane / datum</div>
</div>
<!-- Snelle Lane Drop Zone Balk (verschijnt subtiel of is altijd zichtbaar) -->
<div id="timelineLaneDropBar" class="px-2.5 py-1.5 bg-slate-100/70 dark:bg-slate-900/60 border-b border-slate-200/60 dark:border-slate-800/60 flex items-center gap-1.5 overflow-x-auto text-[11px]">
<!-- Dynamisch gevuld via renderTimelineLaneDropBar() -->
</div> </div>
<!-- Inline Snelle Taakinvoer: ⚡ Wat moet er gedaan worden? (Exact zoals Jira) --> <!-- Inline Snelle Taakinvoer: ⚡ Wat moet er gedaan worden? (Exact zoals Jira) -->
@@ -348,6 +351,25 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
</div> </div>
</div> </div>
<!-- Knop Vrij Blok Zoeken voor Assignee -->
<div class="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between gap-2">
<div class="text-[11px]">
<span class="font-bold text-amber-900 dark:text-amber-200 flex items-center space-x-1">
<i data-lucide="sparkles" class="w-3 h-3 text-amber-500"></i>
<span>Vrij agendablok zoeken</span>
</span>
<span class="text-[10px] text-amber-700 dark:text-amber-400">Zoekt binnen werktijden van de toegewezen collega</span>
</div>
<button type="button" id="btnTimelineFindSlot" onclick="findFreeSlotForTimelineAssignee()" class="px-2.5 py-1 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-bold text-[10px] shadow-xs transition flex items-center space-x-1 flex-shrink-0 cursor-pointer">
<i data-lucide="search" class="w-3 h-3"></i>
<span>Zoek vrij blok</span>
</button>
</div>
<div id="timelineFoundSlotNotice" class="hidden p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-800 text-[11px] text-emerald-800 dark:text-emerald-300 font-medium flex items-center space-x-1.5">
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-emerald-500 flex-shrink-0"></i>
<span id="timelineFoundSlotText"></span>
</div>
<!-- Outlook Agenda Synchronisatie --> <!-- Outlook Agenda Synchronisatie -->
<div class="flex items-center justify-between pt-2 border-t border-slate-200/50 dark:border-slate-800/50"> <div class="flex items-center justify-between pt-2 border-t border-slate-200/50 dark:border-slate-800/50">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -388,6 +410,26 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
</div> </div>
</div> </div>
<!-- Rechtermuisknop Contextmenu voor Tijdlijn Items -->
<div id="timelineContextMenu" class="hidden fixed z-50 w-56 bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-800 py-1.5 text-xs text-slate-700 dark:text-slate-200">
<button onclick="triggerTimelineCtxEdit()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-indigo-50 dark:hover:bg-indigo-950/50 hover:text-indigo-600 dark:hover:text-indigo-400 transition cursor-pointer">
<i data-lucide="edit-3" class="w-4 h-4 text-indigo-500"></i>
<span>Taak bewerken</span>
</button>
<button onclick="triggerTimelineCtxQuickSlot()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 hover:bg-amber-50 dark:hover:bg-amber-950/40 hover:text-amber-600 dark:hover:text-amber-400 transition cursor-pointer">
<i data-lucide="zap" class="w-4 h-4 text-amber-500"></i>
<span>⚡ Zoek direct vrij blok</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800/80"></div>
<button onclick="triggerTimelineCtxDelete()" class="w-full px-3.5 py-2 text-left flex items-center space-x-2.5 text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Taak verwijderen</span>
</button>
</div>
<script> <script>
// --- Globale Status voor Tijdlijn --- // --- Globale Status voor Tijdlijn ---
let currentBoardId = null; let currentBoardId = null;
@@ -490,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');
@@ -528,6 +577,60 @@ function populateLaneFilter() {
sel.appendChild(opt); sel.appendChild(opt);
}); });
} }
renderTimelineLaneDropBar();
}
// Render drop targets voor snelle statuswijziging in het werkpaneel
function renderTimelineLaneDropBar() {
const bar = document.getElementById('timelineLaneDropBar');
if (!bar) return;
bar.innerHTML = '';
const lanes = (currentBoardData && currentBoardData.lanes) ? currentBoardData.lanes : [];
if (lanes.length === 0) {
bar.classList.add('hidden');
return;
}
bar.classList.remove('hidden');
lanes.forEach(lane => {
const dotColor = LANE_COLORS[lane.color] || 'bg-slate-500';
const dropBadge = document.createElement('div');
dropBadge.className = 'timeline-lane-target px-2.5 py-1 rounded-lg border border-slate-200/80 dark:border-slate-800 bg-white/80 dark:bg-slate-800/70 hover:bg-indigo-50 dark:hover:bg-indigo-950/40 text-slate-700 dark:text-slate-200 font-semibold text-[11px] flex items-center space-x-1.5 cursor-pointer transition flex-shrink-0 select-none shadow-2xs';
dropBadge.setAttribute('data-lane-id', lane.id);
dropBadge.title = `Sleep een taak hierheen om te verplaatsen naar: ${lane.title}`;
dropBadge.innerHTML = `
<span class="w-2 h-2 rounded-full ${dotColor}"></span>
<span class="truncate max-w-[90px]">${escapeHtml(lane.title)}</span>
`;
dropBadge.ondragover = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
dropBadge.classList.add('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
};
dropBadge.ondragleave = () => {
dropBadge.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
};
dropBadge.ondrop = async (e) => {
e.preventDefault();
dropBadge.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-100', 'dark:bg-indigo-900/60');
const cardIdStr = e.dataTransfer.getData('text/plain');
const cardId = parseInt(cardIdStr, 10);
if (cardId) {
const el = document.querySelector(`.timeline-row[data-card-id="${cardId}"]`);
if (el) el.classList.remove('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30', 'pointer-events-none');
}
document.querySelectorAll('.timeline-row.opacity-50').forEach(el => {
el.classList.remove('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30', 'pointer-events-none');
});
if (cardId && lane.id) {
await moveTimelineCardToLane(cardId, lane.id);
}
};
bar.appendChild(dropBadge);
});
} }
function populateModalDropdowns() { function populateModalDropdowns() {
@@ -668,10 +771,31 @@ function renderTimeline() {
const laneDotColor = LANE_COLORS[card.lane_color] || 'bg-slate-500'; const laneDotColor = LANE_COLORS[card.lane_color] || 'bg-slate-500';
const hasSchedule = (card.start_date && card.end_date); const hasSchedule = (card.start_date && card.end_date);
// 1. Linkerpaneel Item // 1. Linkerpaneel Item (draggable naar lane drop zones)
const leftItem = document.createElement('div'); const leftItem = document.createElement('div');
leftItem.className = 'timeline-row h-12 px-3.5 flex items-center justify-between transition cursor-pointer hover:bg-slate-100/70 dark:hover:bg-slate-800/50'; leftItem.className = 'timeline-row h-12 px-3.5 flex items-center justify-between transition cursor-grab active:cursor-grabbing hover:bg-slate-100/70 dark:hover:bg-slate-800/50 group select-none';
leftItem.onclick = () => openCardModal(card.id); leftItem.setAttribute('draggable', 'true');
leftItem.setAttribute('data-card-id', card.id);
leftItem.oncontextmenu = (e) => {
openTimelineContextMenu(e, card.id);
return false;
};
leftItem.ondragstart = (e) => {
e.dataTransfer.setData('text/plain', String(card.id));
e.dataTransfer.effectAllowed = 'move';
leftItem.classList.add('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30');
};
leftItem.ondragend = () => {
leftItem.classList.remove('opacity-50', 'bg-indigo-50/50', 'dark:bg-indigo-950/30');
};
leftItem.onclick = (e) => {
// Voorkom trigger als er net gedragged werd
if (e.defaultPrevented) return;
openCardModal(card.id);
};
// Date badge // Date badge
let dateBadge = '<span class="text-[10px] text-slate-400 italic">Niet gepland</span>'; let dateBadge = '<span class="text-[10px] text-slate-400 italic">Niet gepland</span>';
@@ -704,6 +828,11 @@ function renderTimeline() {
rightRow.className = 'timeline-task-row timeline-row h-12 relative flex items-center'; rightRow.className = 'timeline-task-row timeline-row h-12 relative flex items-center';
rightRow.setAttribute('data-card-id', card.id); rightRow.setAttribute('data-card-id', card.id);
rightRow.oncontextmenu = (e) => {
openTimelineContextMenu(e, card.id);
return false;
};
// Klikken op een lege plek in de rij om direct in te plannen // Klikken op een lege plek in de rij om direct in te plannen
rightRow.onclick = (e) => { rightRow.onclick = (e) => {
if (e.target.closest('.timeline-bar')) return; if (e.target.closest('.timeline-bar')) return;
@@ -728,6 +857,10 @@ function renderTimeline() {
barEl.style.left = `${leftPx}px`; barEl.style.left = `${leftPx}px`;
barEl.style.width = `${widthPx}px`; barEl.style.width = `${widthPx}px`;
barEl.title = `${escapeHtml(card.title)} (${card.start_date} t/m ${card.end_date})`; barEl.title = `${escapeHtml(card.title)} (${card.start_date} t/m ${card.end_date})`;
barEl.oncontextmenu = (ev) => {
openTimelineContextMenu(ev, card.id);
return false;
};
barEl.onclick = (ev) => { barEl.onclick = (ev) => {
ev.stopPropagation(); ev.stopPropagation();
openCardModal(card.id); openCardModal(card.id);
@@ -858,6 +991,29 @@ async function updateCardDates(cardId, startDate, endDate) {
} }
} }
// API call om een taak direct naar een andere lane / statuscategorie te verplaatsen
async function moveTimelineCardToLane(cardId, targetLaneId) {
try {
const res = await fetch('/api/board.php?action=move_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
card_id: cardId,
target_lane_id: targetLaneId,
target_position: 999
})
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verplaatsen');
}
} catch (e) {
console.error('Fout bij verplaatsen naar lane:', e);
}
}
// Verslepen van tijdlijnbalken // Verslepen van tijdlijnbalken
function enableBarDragging(barEl, cardId, originalStart, originalEnd) { function enableBarDragging(barEl, cardId, originalStart, originalEnd) {
let isDragging = false; let isDragging = false;
@@ -944,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);
} }
@@ -1074,9 +1231,126 @@ function escapeHtml(str) {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
// --- Rechtermuisknop Context Menu voor Tijdlijn ---
let activeTimelineCtxCardId = null;
function openTimelineContextMenu(e, cardId) {
e.preventDefault();
e.stopPropagation();
activeTimelineCtxCardId = cardId;
const menu = document.getElementById('timelineContextMenu');
if (!menu) return;
menu.classList.remove('hidden');
const x = Math.min(e.clientX, window.innerWidth - 240);
const y = Math.min(e.clientY, window.innerHeight - 240);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeTimelineContextMenu() {
const menu = document.getElementById('timelineContextMenu');
if (menu) menu.classList.add('hidden');
activeTimelineCtxCardId = null;
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#timelineContextMenu')) {
closeTimelineContextMenu();
}
});
function triggerTimelineCtxEdit() {
if (!activeTimelineCtxCardId) return;
const cid = activeTimelineCtxCardId;
closeTimelineContextMenu();
openCardModal(cid);
}
function triggerTimelineCtxQuickSlot() {
if (!activeTimelineCtxCardId) return;
const cid = activeTimelineCtxCardId;
closeTimelineContextMenu();
openCardModal(cid);
setTimeout(() => {
findFreeSlotForTimelineAssignee();
}, 150);
}
async function triggerTimelineCtxDelete() {
if (!activeTimelineCtxCardId) return;
const cid = activeTimelineCtxCardId;
closeTimelineContextMenu();
if (!confirm('Weet je zeker dat je deze taak wilt verwijderen?')) return;
try {
const res = await fetch('/api/board.php?action=delete_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cid })
});
const data = await res.json();
if (data.success) {
await loadBoardData(currentBoardId);
} else {
alert(data.error || 'Kon taak niet verwijderen');
}
} catch(e) {
alert('Netwerkfout bij verwijderen');
}
}
// Zoek vrij agendablok voor de in de tijdlijn-modal geselecteerde assignee
async function findFreeSlotForTimelineAssignee() {
const sel = document.getElementById('cardAssigneeSelect');
const targetUserId = sel.value ? parseInt(sel.value, 10) : currentUserId;
const schedToggle = document.getElementById('cardScheduleToggle');
if (!schedToggle.checked) {
schedToggle.checked = true;
toggleScheduleFields(true);
}
const btn = document.getElementById('btnTimelineFindSlot');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i><span>Zoeken...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
try {
const res = await fetch('/api/planner.php?action=find_next_free_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
duration_minutes: 60,
target_user_id: targetUserId
})
});
const data = await res.json();
if (data.success && data.slot) {
const slot = data.slot;
document.getElementById('cardStartDate').value = slot.date;
document.getElementById('cardEndDate').value = slot.date;
const notice = document.getElementById('timelineFoundSlotNotice');
const noticeText = document.getElementById('timelineFoundSlotText');
noticeText.innerHTML = `Vrij agendablok gevonden: <strong>${slot.formatted_date}</strong> om <strong>${slot.start} – ${slot.end}</strong>`;
notice.classList.remove('hidden');
} else {
alert(data.error || 'Geen beschikbaar tijdblok gevonden binnen werktijden voor deze collega.');
}
} catch (err) {
alert('Netwerkfout bij zoeken van vrij agendablok: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCardModal(); closeCardModal();
closeTimelineContextMenu();
} }
}); });
</script> </script>
+1 -1
View File
@@ -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>