Compare commits
144 Commits
bf944e063b
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 091de8bf95 | |||
| 3312c08ea1 | |||
| 39a41fa207 | |||
| 78369a44c2 | |||
| 07988b3e1d | |||
| 0c466972c2 | |||
| dc6b55e4f9 | |||
| ef116be9b8 | |||
| 65d5a4d4a3 | |||
| a4824bb440 | |||
| e45f565365 | |||
| 3dc09a11a7 | |||
| 86041b665d | |||
| f18f7d5794 | |||
| 45600040ea | |||
| 660f196c60 | |||
| 32974cdb31 | |||
| 8ddae4d883 | |||
| d4e4b69e02 | |||
| 65f81d620c | |||
| 0b5ca54db9 | |||
| 20d8f93f77 | |||
| bda7b311e3 | |||
| 85850f47ad | |||
| 45a883319a | |||
| 460fa967ee | |||
| e9f54cbe72 | |||
| 210ecc7b18 | |||
| 2d89e19114 | |||
| 6bc351aaa4 | |||
| 1ad383d27b | |||
| 8498d82e10 | |||
| a038d25489 | |||
| 97d6085c9d | |||
| 4a9ba4d76a | |||
| b800279cd2 | |||
| e08b304c38 | |||
| 687b929170 | |||
| 830fdf1330 | |||
| 2750fd0681 | |||
| d65463dab8 | |||
| 8cff08f253 | |||
| 843b75334e | |||
| 9cd457971b | |||
| 1358b6a7e9 | |||
| aa23ccdd73 | |||
| e3af7c7aa5 | |||
| cb8246dba9 | |||
| f992ca1ec9 | |||
| 5c4c53922b | |||
| 7a85f612b1 | |||
| ee12d54350 | |||
| e8a6667abd | |||
| 7e7f071f04 | |||
| 3595d58763 | |||
| bf0d2f3bca | |||
| 24fbfb9d82 | |||
| 6a4981744f | |||
| 5a31f0948a | |||
| 740b5343c4 | |||
| cd6071902d | |||
| 32d27cd457 | |||
| 31caf540a3 | |||
| 4eaae64eb6 | |||
| 6987cab4f8 | |||
| 89b53f6428 | |||
| fd59cd69c1 | |||
| 35fcde49c3 | |||
| 29874f184b | |||
| f63ea740bf | |||
| 0bafe0afe9 | |||
| 5f15eb67fe | |||
| 880c1bf9bb | |||
| 94d6325693 | |||
| 7c27949cc2 | |||
| a0d99914e6 | |||
| af80ffdb14 | |||
| 1c3541e602 | |||
| 5e35f5152d | |||
| 845216e554 | |||
| 797aaa76c7 | |||
| 0ad186e95f | |||
| 8714038db2 | |||
| bc33c0bd1a | |||
| 582ba9871f | |||
| 32632dab91 | |||
| 9442dd7b38 | |||
| ab03552c29 | |||
| 046d344003 | |||
| 364fd776c4 | |||
| a1e9dd19ae | |||
| a514eb6521 | |||
| d87a62e1f2 | |||
| f620f20d35 | |||
| f389f67774 | |||
| bb0bf658ed | |||
| a1f2937504 | |||
| 09ab527a93 | |||
| 51658e0aa9 | |||
| 5a5456ffe0 | |||
| 8e62e73d46 | |||
| bcd85f131b | |||
| 41d3733f69 | |||
| 245d09e62c | |||
| 8095a63f31 | |||
| 987bcf5684 | |||
| 30be1dfee9 | |||
| fc1a211c73 | |||
| 38701bf06a | |||
| ad06c67235 | |||
| deba8463b8 | |||
| 7fe7c0f51a | |||
| 7461231879 | |||
| 6ebaf027bf | |||
| d870e643dc | |||
| d15303ff6c | |||
| bc6fdd249e | |||
| 35ce20cd59 | |||
| e3a4d862bd | |||
| e012d6d21c | |||
| 2dc0afc7a0 | |||
| dc740fb94d | |||
| 0942a94edd | |||
| e841d29b63 | |||
| 88ec1418cf | |||
| 0436c076e3 | |||
| 6a9935e5cf | |||
| cc5d4a027f | |||
| 0092cc17ef | |||
| b3dfbf4406 | |||
| 939e0518df | |||
| 61043cd61c | |||
| 4f7db2ec83 | |||
| 795e41561c | |||
| 29b8ce9211 | |||
| 411fa79d7e | |||
| cc439c8d0c | |||
| 6060f39566 | |||
| cf0ceeca82 | |||
| 4026efc816 | |||
| 55618ffaed | |||
| c3001cdd0a | |||
| 29adb48dd9 | |||
| 5ce08443cc |
+8
-2
@@ -1,6 +1,8 @@
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
/.env
|
||||
/.env.local
|
||||
*.local
|
||||
!config/.env
|
||||
|
||||
# Systeem & Editor
|
||||
.DS_Store
|
||||
@@ -10,3 +12,7 @@ Thumbs.db
|
||||
.idea/
|
||||
.continue/
|
||||
.continueignore
|
||||
|
||||
# Uploads
|
||||
/assets/uploads/*
|
||||
!/assets/uploads/**/.gitkeep
|
||||
|
||||
+485
-18
@@ -43,7 +43,7 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards);
|
||||
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -72,6 +72,7 @@ try {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
$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) {
|
||||
echo json_encode(['error' => 'Sessie ID ontbreekt']);
|
||||
@@ -87,22 +88,76 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count);
|
||||
$authorName = 'AI Co-Pilot';
|
||||
$createdCards = [];
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
$colorMap = [
|
||||
'Ideeën' => 'amber',
|
||||
'Knelpunten' => 'rose',
|
||||
'Oplossingen' => 'teal',
|
||||
'Quick Wins' => 'indigo',
|
||||
];
|
||||
|
||||
if ($fullBoard || !empty($session['description'])) {
|
||||
// Gebruik de volledige board generator die alle 4 categorieën vult aan de hand van de briefing
|
||||
$aiRes = fociz_ai_generate_session_board(
|
||||
$session['title'],
|
||||
$session['theme'] ?? 'Algemeen',
|
||||
$session['description'] ?? '',
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if ($aiRes['success'] && !empty($aiRes['cards'])) {
|
||||
$insertStmt = $db->prepare("
|
||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
");
|
||||
|
||||
foreach ($aiRes['cards'] as $cardItem) {
|
||||
$cText = trim($cardItem['content'] ?? '');
|
||||
$cCat = trim($cardItem['category'] ?? 'Ideeën');
|
||||
if (!isset($colorMap[$cCat])) {
|
||||
$cCat = 'Ideeën';
|
||||
}
|
||||
$cColor = $colorMap[$cCat];
|
||||
|
||||
if (!empty($cText)) {
|
||||
$insertStmt->execute([$sessionId, $cText, $cCat, $cColor, $authorName]);
|
||||
$createdCards[] = [
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
'content' => $cText,
|
||||
'category' => $cCat,
|
||||
'color' => $cColor,
|
||||
'author_name' => $authorName,
|
||||
'votes' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'cards' => $createdCards, 'mode' => 'full_board']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 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("
|
||||
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
|
||||
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
||||
");
|
||||
|
||||
$createdCards = [];
|
||||
$authorName = 'AI Co-Pilot';
|
||||
|
||||
foreach ($aiRes['ideas'] as $ideaText) {
|
||||
foreach ($ideas as $ideaText) {
|
||||
$ideaText = trim($ideaText);
|
||||
if (!empty($ideaText)) {
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -132,7 +187,7 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle);
|
||||
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -143,7 +198,298 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Slimme dagindeling berekenen voor taken rondom Outlook afspraken
|
||||
// 3b. AI Brainstorm Idee Uitwerken tot concrete behapbare items
|
||||
if ($action === 'breakdown_idea') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$content = trim($data['content'] ?? '');
|
||||
$sessionId = (int)($data['session_id'] ?? 0);
|
||||
$sessionTitle = trim($data['session_title'] ?? '');
|
||||
$theme = trim($data['theme'] ?? '');
|
||||
|
||||
if (empty($content)) {
|
||||
echo json_encode(['error' => 'Geen inhoud opgegeven om uit te werken.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($sessionId > 0 && (empty($sessionTitle) || empty($theme))) {
|
||||
$stmtS = $db->prepare("SELECT title, theme FROM brainstorm_sessions WHERE id = ?");
|
||||
$stmtS->execute([$sessionId]);
|
||||
$sRow = $stmtS->fetch();
|
||||
if ($sRow) {
|
||||
if (empty($sessionTitle)) $sessionTitle = $sRow['title'] ?? '';
|
||||
if (empty($theme)) $theme = $sRow['theme'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_breakdown_idea($content, $sessionTitle, $theme, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'summary' => $aiRes['summary'],
|
||||
'items' => $aiRes['items'],
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3c. Opgesplitste AI items exporteren naar Kanban Bord, Tijdlijn of Dagplanner
|
||||
if ($action === 'save_breakdown_items') {
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$destination = trim($data['destination'] ?? 'kanban'); // 'kanban', 'timeline', 'planner'
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
if (empty($items) || !is_array($items)) {
|
||||
echo json_encode(['error' => 'Geen items geselecteerd om op te slaan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
$createdCount = 0;
|
||||
|
||||
if ($destination === 'kanban' || $destination === 'timeline') {
|
||||
$boardId = (int)($data['board_id'] ?? 0);
|
||||
$laneId = (int)($data['lane_id'] ?? 0);
|
||||
|
||||
if ($boardId <= 0) {
|
||||
// Pak het eerste actieve bord van de gebruiker
|
||||
if ($isAdmin) {
|
||||
$boardId = (int)$db->query("SELECT id FROM boards WHERE is_archived = 0 ORDER BY id ASC LIMIT 1")->fetchColumn();
|
||||
} else {
|
||||
$stmtB = $db->prepare("SELECT b.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 LIMIT 1");
|
||||
$stmtB->execute([$user['id']]);
|
||||
$boardId = (int)$stmtB->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
if ($boardId <= 0) {
|
||||
echo json_encode(['error' => 'Geen actief Kanban-bord gevonden om taken aan toe te voegen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Toegangscontrole
|
||||
if (!$isAdmin) {
|
||||
$stmtCheck = $db->prepare("SELECT COUNT(*) FROM board_members WHERE board_id = ? AND user_id = ?");
|
||||
$stmtCheck->execute([$boardId, $user['id']]);
|
||||
if ((int)$stmtCheck->fetchColumn() === 0) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Geen toegang tot dit bord.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Als lane_id niet geldig is, pak de eerste kolom (bijv. 'To Do' of 'Backlog')
|
||||
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) {
|
||||
echo json_encode(['error' => 'Het geselecteerde bord heeft geen kolommen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Datumbereik voor Tijdlijn
|
||||
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : date('Y-m-d');
|
||||
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : date('Y-m-d', strtotime('+3 days'));
|
||||
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtInsertCard = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$stmtSchedule = $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', ?, ?, '09:00', '17:00', '1,2,3,4,5')
|
||||
");
|
||||
|
||||
$priorityMap = ['P1' => 'HIGH', 'P2' => 'MEDIUM', 'P3' => 'LOW'];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = trim($item['title'] ?? '');
|
||||
if (empty($title)) continue;
|
||||
|
||||
$desc = trim($item['description'] ?? '');
|
||||
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
|
||||
$priority = $priorityMap[$rawPri] ?? ($rawPri === 'HIGH' || $rawPri === 'LOW' ? $rawPri : 'MEDIUM');
|
||||
$color = trim($item['color'] ?? 'indigo');
|
||||
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
|
||||
$stmtInsertCard->execute([
|
||||
$boardId,
|
||||
$laneId,
|
||||
$title,
|
||||
$desc,
|
||||
$user['id'],
|
||||
$priority,
|
||||
$color,
|
||||
$maxPos + 1,
|
||||
$user['id'],
|
||||
]);
|
||||
|
||||
$cardId = (int)$db->lastInsertId();
|
||||
|
||||
if ($destination === 'timeline' && $cardId > 0) {
|
||||
$stmtSchedule->execute([$cardId, $startDate, $endDate]);
|
||||
}
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'destination' => $destination,
|
||||
'created_count' => $createdCount,
|
||||
'board_id' => $boardId,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($destination === 'planner') {
|
||||
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : date('Y-m-d');
|
||||
|
||||
// Zoek of maak daily plan
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$stmtPlan->fetchColumn();
|
||||
|
||||
if ($planId <= 0) {
|
||||
$stmtNewPlan = $db->prepare("INSERT INTO daily_plans (user_id, plan_date, focus_goal) VALUES (?, ?, 'Brainstorm actiepunten oppakken')");
|
||||
$stmtNewPlan->execute([$user['id'], $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
$stmtInsertTask = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, duration_minutes, status, source)
|
||||
VALUES (?, ?, ?, ?, ?, 'TODO', 'BRAINSTORM')
|
||||
");
|
||||
|
||||
foreach ($items as $item) {
|
||||
$title = trim($item['title'] ?? '');
|
||||
if (empty($title)) continue;
|
||||
|
||||
$rawPri = strtoupper(trim($item['priority'] ?? 'P2'));
|
||||
$priority = in_array($rawPri, ['P1', 'P2', 'P3']) ? $rawPri : 'P2';
|
||||
$duration = max(15, min(240, (int)($item['estimated_minutes'] ?? 30)));
|
||||
|
||||
$stmtInsertTask->execute([
|
||||
$user['id'],
|
||||
$planId,
|
||||
$title,
|
||||
$priority,
|
||||
$duration,
|
||||
]);
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'destination' => 'planner',
|
||||
'created_count' => $createdCount,
|
||||
'plan_date' => $planDate,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende bestemming: ' . htmlspecialchars($destination)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3d. Test AI Verbinding (Gemini Pro / OpenAI)
|
||||
if ($action === 'test_connection') {
|
||||
try {
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : $_POST;
|
||||
|
||||
$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
|
||||
if (!empty($tempKey) && $tempKey !== '••••••••••••••••') {
|
||||
$testSettings = [
|
||||
'ai_provider' => $tempProvider ?: 'gemini',
|
||||
'ai_api_key' => $tempKey,
|
||||
'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.'],
|
||||
];
|
||||
|
||||
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([
|
||||
'success' => false,
|
||||
'error' => 'Fout tijdens verbindingstest: ' . $testEx->getMessage(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Slimme dagindeling berekenen voor taken rondom bezette tijdblokken
|
||||
// ZERO-DATA LEAKAGE: Geen meetingsdetails of externe tickets worden verzonden!
|
||||
if ($action === 'smart_schedule') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
|
||||
@@ -154,27 +500,46 @@ try {
|
||||
$plan = $stmtPlan->fetch();
|
||||
$planId = $plan['id'] ?? 0;
|
||||
|
||||
// Haal openstaande taken op voor de dag (inclusief fallback voor ongebonden taken op de huidige dag)
|
||||
// ZERO-DATA LEAKAGE: Sluit expliciet alle taken uit die afkomstig zijn van Jira, Mail of Teams!
|
||||
$isToday = ($date === date('Y-m-d'));
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT t.id, t.title, t.priority, t.duration_minutes
|
||||
FROM tasks t
|
||||
WHERE t.user_id = ? AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ") AND t.is_completed = 0
|
||||
WHERE t.user_id = ?
|
||||
AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ")
|
||||
AND t.is_completed = 0
|
||||
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND t.external_id IS NULL
|
||||
ORDER BY t.priority ASC, t.created_at ASC
|
||||
");
|
||||
$stmtTasks->execute([$user['id'], $planId]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
if (empty($tasks)) {
|
||||
echo json_encode(['error' => 'Geen openstaande taken gevonden om in te delen']);
|
||||
echo json_encode(['error' => 'Geen openstaande Fociz taken gevonden om in te delen']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ZERO-DATA LEAKAGE: Haal meetings op maar geef UITSLUITEND start/eindtijd door (geen titel/deelnemers)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$events = microsoft_fetch_today_events($user['id'], $date);
|
||||
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
|
||||
foreach ($rawEvents as $ev) {
|
||||
if (!empty($ev['start']) && !empty($ev['end'])) {
|
||||
$busyBlocks[] = [
|
||||
'start' => substr($ev['start'], 0, 5),
|
||||
'end' => substr($ev['end'], 0, 5),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Microsoft niet gekoppeld
|
||||
}
|
||||
|
||||
$workdaySettings = get_workday_settings();
|
||||
|
||||
$aiRes = fociz_ai_smart_schedule($tasks, $events, $workdaySettings);
|
||||
$aiRes = fociz_ai_smart_schedule($tasks, $busyBlocks, $workdaySettings, $user['id']);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
@@ -205,8 +570,110 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Persoonlijke AI Dagbriefing genereren
|
||||
// ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail of Teams data!
|
||||
if ($action === 'daily_briefing') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// Haal openstaande taken van vandaag op (ZERO-DATA LEAKAGE: GEEN externe Jira/Mail taken)
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtPlan->execute([$user['id'], $date]);
|
||||
$planId = $stmtPlan->fetchColumn();
|
||||
|
||||
$isToday = ($date === $today);
|
||||
$taskFilter = $planId
|
||||
? "t.daily_plan_id = " . (int)$planId . ($isToday ? " OR t.daily_plan_id IS NULL" : "")
|
||||
: ($isToday ? "t.daily_plan_id IS NULL" : "1=0");
|
||||
|
||||
$stmtTasks = $db->prepare("
|
||||
SELECT id, title, priority, time_block_start, duration_minutes
|
||||
FROM tasks t
|
||||
WHERE t.user_id = ?
|
||||
AND t.is_completed = 0
|
||||
AND ({$taskFilter})
|
||||
AND (t.source IS NULL OR t.source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND t.external_id IS NULL
|
||||
ORDER BY t.priority ASC, t.time_block_start ASC
|
||||
LIMIT 15
|
||||
");
|
||||
$stmtTasks->execute([$user['id']]);
|
||||
$tasks = $stmtTasks->fetchAll();
|
||||
|
||||
// Haal gisteren niet-voltooide interne taken op
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day', strtotime($date)));
|
||||
$stmtYest = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
$stmtYest->execute([$user['id'], $yesterday]);
|
||||
$yesterdayPlanId = $stmtYest->fetchColumn();
|
||||
$unfinishedYest = [];
|
||||
if ($yesterdayPlanId) {
|
||||
$stmtUnf = $db->prepare("
|
||||
SELECT title FROM tasks
|
||||
WHERE user_id = ? AND daily_plan_id = ? AND is_completed = 0
|
||||
AND (source IS NULL OR source NOT IN ('JIRA', 'MAIL', 'EMAIL', 'OUTLOOK', 'TEAMS'))
|
||||
AND external_id IS NULL
|
||||
LIMIT 5
|
||||
");
|
||||
$stmtUnf->execute([$user['id'], $yesterdayPlanId]);
|
||||
$unfinishedYest = $stmtUnf->fetchAll();
|
||||
}
|
||||
|
||||
// ZERO-DATA LEAKAGE: Alleen geanonimiseerde bezette tijdblokken (geen subject/afzender)
|
||||
$busyBlocks = [];
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
$rawEvents = microsoft_fetch_today_events($user['id'], $date);
|
||||
foreach ($rawEvents as $ev) {
|
||||
if (!empty($ev['start']) && !empty($ev['end'])) {
|
||||
$busyBlocks[] = [
|
||||
'start' => substr($ev['start'], 0, 5),
|
||||
'end' => substr($ev['end'], 0, 5),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Geen agenda beschikbaar
|
||||
}
|
||||
|
||||
$aiSettings = get_ai_settings($user['id']);
|
||||
if (empty($aiSettings['ai_enabled']) || empty($aiSettings['has_key'])) {
|
||||
echo json_encode(['error' => 'AI Co-Pilot is niet geconfigureerd']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aiRes = fociz_ai_daily_briefing(
|
||||
$user['name'] ?? 'Jij',
|
||||
$date,
|
||||
$tasks,
|
||||
$busyBlocks,
|
||||
$unfinishedYest,
|
||||
$user['id']
|
||||
);
|
||||
|
||||
if (!$aiRes['success']) {
|
||||
echo json_encode(['error' => $aiRes['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'greeting' => $aiRes['greeting'],
|
||||
'focus_task' => $aiRes['focus_task'],
|
||||
'tip' => $aiRes['tip'],
|
||||
'warnings' => $aiRes['warnings'],
|
||||
'date' => $date,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
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);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Serverfout bij AI verwerking: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
+28
-7
@@ -274,7 +274,9 @@ try {
|
||||
$stmt = $db->prepare("UPDATE board_lanes SET title = ?, color = ? WHERE id = ? AND board_id = ?");
|
||||
$stmt->execute([$title, $color, $laneId, $boardId]);
|
||||
} else {
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = {$boardId}")->fetchColumn();
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = ?");
|
||||
$stmtMax->execute([$boardId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$boardId, $title, $color, $maxPos + 1]);
|
||||
$laneId = (int)$db->lastInsertId();
|
||||
@@ -296,7 +298,9 @@ try {
|
||||
}
|
||||
|
||||
// Controleer of er kaarten in staan
|
||||
$cardCount = (int)$db->query("SELECT COUNT(*) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn();
|
||||
$stmtCardCnt = $db->prepare("SELECT COUNT(*) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtCardCnt->execute([$laneId]);
|
||||
$cardCount = (int)$stmtCardCnt->fetchColumn();
|
||||
if ($cardCount > 0) {
|
||||
echo json_encode(['success' => false, 'error' => 'Verplaats of verwijder eerst de taken in deze kolom alvorens de kolom te verwijderen.']);
|
||||
exit;
|
||||
@@ -337,7 +341,9 @@ try {
|
||||
");
|
||||
$stmt->execute([$title, $description, $assigneeId, $priority, $color, $laneId, $cardId, $boardId]);
|
||||
} else {
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn();
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -385,7 +391,7 @@ try {
|
||||
|
||||
$resCreate = microsoft_create_event($assigneeId, $eventPayload);
|
||||
if (!empty($resCreate['success'])) {
|
||||
$outlookEventId = $resCreate['id'] ?? null;
|
||||
$outlookEventId = $resCreate['event_id'] ?? ($resCreate['id'] ?? null);
|
||||
$outlookSyncedAt = date('Y-m-d H:i:s');
|
||||
$outlookSyncMsg = 'Taak succesvol ingepland in de Outlook agenda van de collega!';
|
||||
} else {
|
||||
@@ -431,7 +437,9 @@ try {
|
||||
$targetLaneId = (int)($input['target_lane_id'] ?? 0);
|
||||
$targetPosition = (int)($input['target_position'] ?? 0);
|
||||
|
||||
$card = $db->query("SELECT * FROM board_cards WHERE id = {$cardId}")->fetch();
|
||||
$stmtCard = $db->prepare("SELECT * FROM board_cards WHERE id = ?");
|
||||
$stmtCard->execute([$cardId]);
|
||||
$card = $stmtCard->fetch();
|
||||
if (!$card) {
|
||||
echo json_encode(['success' => false, 'error' => 'Taak niet gevonden.']);
|
||||
exit;
|
||||
@@ -443,6 +451,15 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Valideer of de doellane daadwerkelijk bij hetzelfde bord hoort (voorkom IDOR/lane hijacking)
|
||||
$stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE id = ? AND board_id = ?");
|
||||
$stmtLane->execute([$targetLaneId, (int)$card['board_id']]);
|
||||
if (!$stmtLane->fetch()) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldige doellane voor dit bord.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE board_cards SET lane_id = ?, position = ? WHERE id = ?");
|
||||
$stmt->execute([$targetLaneId, $targetPosition, $cardId]);
|
||||
|
||||
@@ -552,10 +569,14 @@ try {
|
||||
|
||||
// Als geen lane_id is meegegeven, pak de 1e lane van het bord
|
||||
if ($laneId <= 0) {
|
||||
$laneId = (int)$db->query("SELECT id FROM board_lanes WHERE board_id = {$boardId} ORDER BY position ASC, id ASC LIMIT 1")->fetchColumn();
|
||||
$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();
|
||||
}
|
||||
|
||||
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn();
|
||||
$stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
|
||||
$stmtMax->execute([$laneId]);
|
||||
$maxPos = (int)$stmtMax->fetchColumn();
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
|
||||
VALUES (?, ?, ?, '', NULL, 'P2', 'yellow', ?, ?)
|
||||
|
||||
+281
-3
@@ -47,11 +47,25 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Stemmen / Upvote op kaart
|
||||
// 2. Stemmen / Upvote op kaart (met bescherming tegen oneindig stemmen)
|
||||
if ($action === 'vote_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
|
||||
if (!isset($_SESSION['voted_cards'])) {
|
||||
$_SESSION['voted_cards'] = [];
|
||||
}
|
||||
|
||||
if (in_array($cardId, $_SESSION['voted_cards'])) {
|
||||
$stmtVotes = $db->prepare("SELECT votes FROM brainstorm_cards WHERE id = ?");
|
||||
$stmtVotes->execute([$cardId]);
|
||||
$votes = (int)$stmtVotes->fetchColumn();
|
||||
echo json_encode(['success' => false, 'error' => 'U heeft al op deze kaart gestemd.', 'votes' => $votes]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$_SESSION['voted_cards'][] = $cardId;
|
||||
|
||||
$stmt = $db->prepare("UPDATE brainstorm_cards SET votes = votes + 1 WHERE id = ?");
|
||||
$stmt->execute([$cardId]);
|
||||
|
||||
@@ -63,11 +77,35 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Kaart verwijderen
|
||||
// 3. Kaart verwijderen (met autorisatiecontrole: auteur, sessie-eigenaar of beheerder)
|
||||
if ($action === 'delete_card') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardId = (int)($data['card_id'] ?? 0);
|
||||
|
||||
$stmtCheck = $db->prepare("
|
||||
SELECT c.*, s.created_by_id as session_owner_id
|
||||
FROM brainstorm_cards c
|
||||
JOIN brainstorm_sessions s ON c.session_id = s.id
|
||||
WHERE c.id = ?
|
||||
");
|
||||
$stmtCheck->execute([$cardId]);
|
||||
$card = $stmtCheck->fetch();
|
||||
|
||||
if (!$card) {
|
||||
echo json_encode(['error' => 'Kaart niet gevonden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$isAuthor = ($card['author_name'] === $user['name']);
|
||||
$isSessionOwner = ((int)$card['session_owner_id'] === (int)$user['id']);
|
||||
$isAdmin = ($user['role'] === 'ADMIN');
|
||||
|
||||
if (!$isAuthor && !$isSessionOwner && !$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Geen toestemming om deze kaart te verwijderen.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM brainstorm_cards WHERE id = ?");
|
||||
$stmt->execute([$cardId]);
|
||||
|
||||
@@ -75,7 +113,200 @@ try {
|
||||
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') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$cardIds = $data['card_ids'] ?? [];
|
||||
@@ -137,6 +368,53 @@ try {
|
||||
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']);
|
||||
} catch (Exception $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
|
||||
+50
-7
@@ -28,7 +28,7 @@ try {
|
||||
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
||||
$stmt->execute([$user['id']]);
|
||||
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
|
||||
$apiToken = $saved['jira_api_token'] ?? '';
|
||||
$apiToken = fociz_decrypt($saved['jira_api_token'] ?? '');
|
||||
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
|
||||
if (empty($email)) $email = $saved['jira_email'] ?? '';
|
||||
}
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$settings = json_encode([
|
||||
'jira_domain' => jira_normalize_domain($domain),
|
||||
'jira_email' => $email,
|
||||
'jira_api_token' => $apiToken,
|
||||
'jira_api_token' => fociz_encrypt($apiToken),
|
||||
'account_name' => $jiraUser['displayName'] ?? '',
|
||||
'account_id' => $jiraUser['accountId'] ?? '',
|
||||
]);
|
||||
@@ -98,7 +98,8 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$title = "[{$key}] {$summary}";
|
||||
$customTitle = trim($input['title'] ?? '');
|
||||
$title = !empty($customTitle) ? $customTitle : "[{$key}] {$summary}";
|
||||
|
||||
// Zoek of maak daily plan aan voor deze datum
|
||||
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
|
||||
@@ -111,10 +112,50 @@ try {
|
||||
$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
|
||||
$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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', 'JIRA', ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$user['id'],
|
||||
@@ -124,15 +165,17 @@ try {
|
||||
$timeBlockStart,
|
||||
$timeBlockEnd,
|
||||
$durationMinutes,
|
||||
$key,
|
||||
$url
|
||||
$source,
|
||||
$externalId,
|
||||
$externalUrl
|
||||
]);
|
||||
$taskId = (int)$db->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'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;
|
||||
}
|
||||
|
||||
+7
-1
@@ -62,8 +62,14 @@ try {
|
||||
$relevantIssues = [];
|
||||
|
||||
// Filter issues die specifiek relevant zijn voor deze medewerker
|
||||
$userFirstName = explode(' ', trim($user['name'] ?? ''))[0] ?? '';
|
||||
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'] ?? ''));
|
||||
|
||||
$isQueueMatch = false;
|
||||
|
||||
+109
-15
@@ -6,6 +6,7 @@
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||
require_once __DIR__ . '/../includes/boards.php';
|
||||
|
||||
$user = require_auth();
|
||||
$userId = (int)$user['id'];
|
||||
@@ -21,13 +22,38 @@ header('Content-Type: application/json; charset=utf-8');
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
|
||||
|
||||
try {
|
||||
// 1. Inbox berichten ophalen
|
||||
if ($action === 'get_inbox') {
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
|
||||
// 0. Mail mappen ophalen
|
||||
if ($action === 'get_folders') {
|
||||
$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';
|
||||
$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);
|
||||
exit;
|
||||
}
|
||||
@@ -121,14 +147,14 @@ try {
|
||||
// 6. E-mail direct inplannen in Fociz dagplanning
|
||||
if ($action === 'plan_email') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$messageId = trim($input['message_id'] ?? '');
|
||||
$subject = trim($input['subject'] ?? 'E-mail actiepunt');
|
||||
$planDate = trim($input['plan_date'] ?? date('Y-m-d'));
|
||||
$messageId = trim($input['message_id'] ?? $input['mail_id'] ?? '');
|
||||
$subject = trim($input['title'] ?? $input['subject'] ?? 'E-mail actiepunt');
|
||||
$planDate = trim($input['plan_date'] ?? $input['date'] ?? date('Y-m-d'));
|
||||
$timeBlockStart = !empty($input['time_block_start']) ? trim($input['time_block_start']) : null;
|
||||
$durationMinutes = (int)($input['duration_minutes'] ?? 30);
|
||||
$priority = in_array($input['priority'] ?? '', ['P1', 'P2', 'P3']) ? $input['priority'] : 'P2';
|
||||
$webLink = trim($input['web_link'] ?? 'https://outlook.office.com/mail/');
|
||||
$sender = trim($input['sender'] ?? '');
|
||||
$sender = trim($input['sender'] ?? $input['sender_name'] ?? '');
|
||||
|
||||
if (empty($subject)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Onderwerp is verplicht.']);
|
||||
@@ -137,8 +163,8 @@ try {
|
||||
|
||||
if ($durationMinutes <= 0) $durationMinutes = 30;
|
||||
|
||||
$timeBlockEnd = null;
|
||||
if (!empty($timeBlockStart)) {
|
||||
$timeBlockEnd = !empty($input['time_block_end']) ? trim($input['time_block_end']) : null;
|
||||
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||
try {
|
||||
$dtEnd = new DateTime("{$planDate} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||
@@ -155,14 +181,49 @@ try {
|
||||
$stmtPlan->execute([$userId, $planDate]);
|
||||
$planId = (int)$db->lastInsertId();
|
||||
|
||||
// Als er een custom titel is ingevoerd, gebruik die direct, anders maak een nette titel
|
||||
$taskTitle = $subject;
|
||||
if (isset($input['subject']) && !isset($input['title'])) {
|
||||
$taskTitle = "✉️ " . $subject;
|
||||
if (!empty($sender)) {
|
||||
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("
|
||||
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([
|
||||
$userId,
|
||||
@@ -172,16 +233,49 @@ try {
|
||||
$timeBlockStart,
|
||||
$timeBlockEnd,
|
||||
$durationMinutes,
|
||||
$messageId,
|
||||
$webLink
|
||||
$source,
|
||||
$externalId,
|
||||
$externalUrl
|
||||
]);
|
||||
$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([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'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;
|
||||
}
|
||||
|
||||
+1159
-18
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,30 @@ try {
|
||||
$fields[] = "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) {
|
||||
$fields[] = "progress = ?";
|
||||
$params[] = max(0, min(100, $progress));
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Session Guard API (Release v1.6)
|
||||
* Ondersteunt realtime session keepalive, inactiviteitstimeout status en veilige logout
|
||||
* Conform NIST SP 800-63B, OWASP ASVS v4.0 en ISO 27001
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/settings.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$currentUser = require_auth(true);
|
||||
$userId = (int)($currentUser['id'] ?? 0);
|
||||
|
||||
$action = $_GET['action'] ?? ($_POST['action'] ?? 'keepalive');
|
||||
$idleTimeout = get_session_idle_timeout();
|
||||
$now = time();
|
||||
|
||||
if ($action === 'keepalive' || $action === 'ping') {
|
||||
// Verleng de activiteitstimer van de sessie
|
||||
$_SESSION['last_activity'] = $now;
|
||||
$_SESSION['last_db_activity_update'] = $now;
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
||||
$stmtAct->execute([$userId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'remaining' => $idleTimeout,
|
||||
'timeout' => $idleTimeout,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'status') {
|
||||
$lastActivity = (int)($_SESSION['last_activity'] ?? $now);
|
||||
$elapsed = $now - $lastActivity;
|
||||
$remaining = max(0, $idleTimeout - $elapsed);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'remaining' => $remaining,
|
||||
'timeout' => $idleTimeout,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($userId, 'LOGOUT_SESSION_GUARD', 'users', $userId, ['ip' => fociz_get_client_ip()], 'SUCCESS', $currentUser['email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Session guard logout error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
clear_day_session_cookie();
|
||||
$_SESSION = [];
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
@session_destroy();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Succesvol uitgelogd.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Haal actieve apparaten en sessies op (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if ($action === 'get_active_sessions') {
|
||||
$sessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'sessions' => $sessions,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig alle overige actieve sessies (SOC 2 CC6.1)
|
||||
if ($action === 'revoke_other_sessions') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'revoked_count' => $count,
|
||||
'message' => "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd."
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig specifieke sessie op afstand
|
||||
if ($action === 'revoke_session') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sessionDbId = (int)($_POST['session_id'] ?? 0);
|
||||
$ok = fociz_revoke_session_by_id($userId, $sessionDbId);
|
||||
echo json_encode([
|
||||
'success' => $ok,
|
||||
'message' => $ok ? 'Sessie succesvol op afstand beëindigd.' : 'Kan sessie niet intrekken.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende sessieactie.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
@@ -9,15 +9,19 @@ require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Beveiligingscontrole: Verifieer geheime webhook token indien geconfigureerd in .env
|
||||
// Beveiligingscontrole: Verifieer geheime webhook token. Weiger toegang als secret ontbreekt.
|
||||
$configuredSecret = fociz_env('JIRA_WEBHOOK_SECRET', '');
|
||||
if (!empty($configuredSecret)) {
|
||||
$incomingSecret = $_GET['secret'] ?? ($_SERVER['HTTP_X_ATLASSIAN_WEBHOOK_SECRET'] ?? '');
|
||||
if (!hash_equals($configuredSecret, $incomingSecret)) {
|
||||
if (empty($configuredSecret)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Jira webhooks zijn niet ingeschakeld of geconfigureerd op deze server.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$incomingSecret = $_GET['secret'] ?? ($_SERVER['HTTP_X_ATLASSIAN_WEBHOOK_SECRET'] ?? '');
|
||||
if (empty($incomingSecret) || !hash_equals($configuredSecret, $incomingSecret)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Onbevoegde webhook aanroep']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
|
||||
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 |
@@ -253,7 +253,10 @@
|
||||
if (isFetchingEvents) return;
|
||||
isFetchingEvents = true;
|
||||
try {
|
||||
const res = await fetch('/api/notifications.php?action=get_events', { cache: 'no-store' });
|
||||
const res = await fetch('/api/notifications.php?action=get_events', {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (res.status === 401) {
|
||||
console.warn('Notificatiesessie niet geauthenticeerd.');
|
||||
return;
|
||||
@@ -420,7 +423,10 @@
|
||||
isLiveSyncRunning = true;
|
||||
try {
|
||||
const url = `/api/live_sync.php?last_mail_id=${encodeURIComponent(lastKnownMailId)}&last_jira_key=${encodeURIComponent(lastKnownJiraKey)}&calendar_hash=${encodeURIComponent(lastCalendarHash)}`;
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Background-Poll': '1' }
|
||||
});
|
||||
if (res.status === 401) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Fociz Session Guard (Release v1.6.1)
|
||||
* Realtime beveiligingsbeheer voor sessie-inactiviteit conform NIST SP 800-63B / ISO 27001
|
||||
* - Waarschuwt de gebruiker 2 minuten voor het verlopen met aftelklok
|
||||
* - Throttled keepalive pings tijdens actieve interactie
|
||||
* - Synchronisatie over meerdere open browsertabbladen via localStorage
|
||||
* - Beschermd tegen oneindige herlaad- en redirect-loops
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Script configuratie uitlezen
|
||||
var scriptTag = document.currentScript || document.querySelector('script[src*="session-guard.js"]');
|
||||
var rawTimeout = scriptTag ? parseInt(scriptTag.getAttribute('data-timeout'), 10) : 1800;
|
||||
var idleTimeout = (!isNaN(rawTimeout) && rawTimeout > 0) ? rawTimeout : 1800;
|
||||
|
||||
// Waarschuwing 120 seconden voor afloop (of de helft indien timeout kort is)
|
||||
var warningSeconds = idleTimeout > 240 ? 120 : Math.max(10, Math.floor(idleTimeout / 2));
|
||||
|
||||
var now = Date.now();
|
||||
var lastActivity = now;
|
||||
var lastPingTime = now;
|
||||
var isWarningShowing = false;
|
||||
var keepaliveInFlight = false;
|
||||
var hasRedirected = false;
|
||||
|
||||
// Synchronisatie met andere tabbladen:
|
||||
// Alleen een timestamp overnemen als deze GELDIG is en NIET al verlopen is!
|
||||
// Aangezien DEZE pagina zojuist succesvol geladen is, is de sessie NU actief.
|
||||
var storedActivity = localStorage.getItem('fociz_session_last_activity');
|
||||
if (storedActivity) {
|
||||
var parsed = parseInt(storedActivity, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= now && (now - parsed) < (idleTimeout * 1000)) {
|
||||
lastActivity = Math.max(now, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct opslaan zodat alle tabbladen weten dat er zojuist activiteit was
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', lastActivity.toString());
|
||||
localStorage.removeItem('fociz_session_logged_out');
|
||||
} catch (e) {}
|
||||
|
||||
// Veilige eenmalige redirect helper om redirect loops te voorkomen
|
||||
function redirectToExpired() {
|
||||
if (hasRedirected) return;
|
||||
hasRedirected = true;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_logged_out', Date.now().toString());
|
||||
} catch (e) {}
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
|
||||
// Modal DOM elementen
|
||||
var modalEl = null;
|
||||
var countdownEl = null;
|
||||
var keepaliveBtn = null;
|
||||
var logoutBtn = null;
|
||||
|
||||
function initDom() {
|
||||
modalEl = document.getElementById('focizSessionModal');
|
||||
countdownEl = document.getElementById('focizSessionCountdown');
|
||||
keepaliveBtn = document.getElementById('focizSessionKeepaliveBtn');
|
||||
logoutBtn = document.getElementById('focizSessionLogoutBtn');
|
||||
|
||||
if (keepaliveBtn) {
|
||||
keepaliveBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
sendKeepalive(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
performLogout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Luister naar tabblad synchronisatie
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === 'fociz_session_last_activity') {
|
||||
var updated = parseInt(e.newValue, 10);
|
||||
if (!isNaN(updated) && updated > lastActivity) {
|
||||
lastActivity = updated;
|
||||
if (isWarningShowing) {
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'fociz_session_logged_out') {
|
||||
if (!hasRedirected) {
|
||||
var loggedOutTime = parseInt(e.newValue, 10);
|
||||
if (!isNaN(loggedOutTime) && Math.abs(Date.now() - loggedOutTime) < 10000) {
|
||||
hasRedirected = true;
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Formatteer seconden naar mm:ss
|
||||
function formatTime(seconds) {
|
||||
var m = Math.floor(seconds / 60);
|
||||
var s = seconds % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
// Toon modal
|
||||
function showModal(remainingSeconds) {
|
||||
if (!modalEl) initDom();
|
||||
if (!modalEl) return;
|
||||
|
||||
isWarningShowing = true;
|
||||
modalEl.classList.remove('hidden');
|
||||
modalEl.classList.add('flex');
|
||||
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = formatTime(remainingSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
// Verberg modal
|
||||
function hideModal() {
|
||||
if (!modalEl) return;
|
||||
isWarningShowing = false;
|
||||
modalEl.classList.add('hidden');
|
||||
modalEl.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Verzend keepalive naar de backend
|
||||
function sendKeepalive(manualClick) {
|
||||
if (hasRedirected || keepaliveInFlight) return;
|
||||
keepaliveInFlight = true;
|
||||
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = true;
|
||||
keepaliveBtn.textContent = 'Verlengen...';
|
||||
}
|
||||
|
||||
fetch('/api/session.php?action=keepalive', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 401) {
|
||||
// Reeds verlopen op de server
|
||||
redirectToExpired();
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
keepaliveInFlight = false;
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = false;
|
||||
keepaliveBtn.textContent = 'Blijf Ingelogd';
|
||||
}
|
||||
|
||||
if (data && data.success) {
|
||||
var currentNow = Date.now();
|
||||
lastActivity = currentNow;
|
||||
lastPingTime = currentNow;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', currentNow.toString());
|
||||
} catch (e) {}
|
||||
hideModal();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
keepaliveInFlight = false;
|
||||
if (manualClick && keepaliveBtn) {
|
||||
keepaliveBtn.disabled = false;
|
||||
keepaliveBtn.textContent = 'Blijf Ingelogd';
|
||||
}
|
||||
console.error('Session keepalive error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Direct uitloggen
|
||||
function performLogout() {
|
||||
if (hasRedirected) return;
|
||||
hasRedirected = true;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_logged_out', Date.now().toString());
|
||||
} catch (e) {}
|
||||
fetch('/api/session.php?action=logout', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).finally(function () {
|
||||
window.location.href = '/auth/logout.php';
|
||||
});
|
||||
}
|
||||
|
||||
// Registreer gebruikersactiviteit (throttled)
|
||||
function registerUserActivity() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
|
||||
// Controleer of de sessie al verlopen is (bijv. laptop uit slaapstand)
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
redirectToExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
// Als de waarschuwingsmodal actief is, vereisen we expliciete klik op 'Blijf Ingelogd'
|
||||
if (isWarningShowing) return;
|
||||
|
||||
// Lokale activiteit bijwerken (maximaal eens per 3 seconden wegschrijven)
|
||||
if (currentNow - lastActivity > 3000) {
|
||||
lastActivity = currentNow;
|
||||
try {
|
||||
localStorage.setItem('fociz_session_last_activity', currentNow.toString());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Server keepalive ping (maximaal eens per 60 seconden)
|
||||
var minPingInterval = Math.min(60000, Math.max(15000, (idleTimeout * 1000) / 4));
|
||||
if (currentNow - lastPingTime > minPingInterval) {
|
||||
lastPingTime = currentNow;
|
||||
sendKeepalive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct controleren bij terugkeer naar tabblad of ontwaken uit slaapstand
|
||||
function checkIdleOnWakeup() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
var stored = localStorage.getItem('fociz_session_last_activity');
|
||||
if (stored) {
|
||||
var parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed > 0 && parsed <= currentNow) {
|
||||
lastActivity = Math.max(lastActivity, parsed);
|
||||
}
|
||||
}
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
if (elapsedSec >= idleTimeout) {
|
||||
redirectToExpired();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', checkIdleOnWakeup);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkIdleOnWakeup();
|
||||
}
|
||||
});
|
||||
|
||||
// Event listeners voor menselijke interactie
|
||||
var events = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'];
|
||||
events.forEach(function (evt) {
|
||||
window.addEventListener(evt, registerUserActivity, { passive: true });
|
||||
});
|
||||
|
||||
// Hoofdtimer interval (elke seconde)
|
||||
function tick() {
|
||||
if (hasRedirected) return;
|
||||
var currentNow = Date.now();
|
||||
var elapsedSec = Math.floor((currentNow - lastActivity) / 1000);
|
||||
var remainingSec = idleTimeout - elapsedSec;
|
||||
|
||||
if (remainingSec <= 0) {
|
||||
redirectToExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingSec <= warningSeconds) {
|
||||
// Binnen waarschuwingsvenster
|
||||
showModal(remainingSec);
|
||||
} else if (isWarningShowing) {
|
||||
// Waarschuwing niet meer van toepassing (bijv. verlengd vanuit ander tabblad)
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Starten zodra DOM gereed is
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initDom();
|
||||
setInterval(tick, 1000);
|
||||
});
|
||||
} else {
|
||||
initDom();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -137,7 +137,12 @@ $saveStmt = $db->prepare("
|
||||
settings = VALUES(settings),
|
||||
last_sync_at = NOW()
|
||||
");
|
||||
$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]);
|
||||
$saveStmt->execute([
|
||||
$user['id'],
|
||||
fociz_encrypt($accessToken),
|
||||
fociz_encrypt($refreshToken),
|
||||
json_encode($newSettings)
|
||||
]);
|
||||
|
||||
// 8. Succesvol terugkeren naar de gewenste pagina of het Persoonlijk Profiel
|
||||
$returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php';
|
||||
|
||||
@@ -18,11 +18,24 @@ $error = null;
|
||||
$submitted = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$clientIp = fociz_get_client_ip();
|
||||
$ipRateKey = "forgot_ip_{$clientIp}";
|
||||
$ipLimit = fociz_check_rate_limit($ipRateKey, 5, 900);
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$emailRateKey = "forgot_email_{$email}";
|
||||
$emailLimit = fociz_check_rate_limit($emailRateKey, 3, 900);
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$error = "Sessie verlopen of beveiligingstoken ongeldig. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif (!$ipLimit['allowed'] || !$emailLimit['allowed']) {
|
||||
$retryAfter = max($ipLimit['retry_after'], $emailLimit['retry_after']);
|
||||
$error = "Te veel herstelaanvragen. Probeer het over " . ceil($retryAfter / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = "Vul een geldig e-mailadres in.";
|
||||
} else {
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
fociz_hit_rate_limit($emailRateKey, 900);
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
|
||||
@@ -176,6 +189,7 @@ $pageTitle = "Fociz — Wachtwoord Vergeten";
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="POST" action="/auth/forgot-password.php" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw e-mailadres</label>
|
||||
<div class="relative">
|
||||
|
||||
+33
-7
@@ -39,8 +39,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
if (empty($name) || empty($password) || empty($totpCode)) {
|
||||
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "Wachtwoorden komen niet overeen.";
|
||||
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
|
||||
@@ -75,15 +75,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$db->commit();
|
||||
|
||||
// Start dagsessie (geldig tot 23:59:59)
|
||||
fociz_log_audit($newUserId, 'USER_REGISTERED_VIA_INVITE', 'user', $newUserId, ['email' => $invite['email'], 'role' => $invite['role']], 'SUCCESS', $invite['email']);
|
||||
|
||||
// Start dagsessie met verse sessie ID (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $newUserId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $invite['email'];
|
||||
$_SESSION['user_role'] = $invite['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($newUserId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($newUserId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['invite_totp_secret']);
|
||||
|
||||
header('Location: /planner.php');
|
||||
@@ -172,12 +186,12 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 12 tekens)</label>
|
||||
<input type="password" name="password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<input type="password" name="confirm_password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +205,7 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
|
||||
<div id="qrcode" class="w-40 h-40 mx-auto flex items-center justify-center"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono">
|
||||
@@ -213,8 +227,20 @@ $pageTitle = "Fociz — Uitnodiging Accepteren";
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrcode.min.js"></script>
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer) {
|
||||
new QRCode(qrContainer, {
|
||||
text: <?= json_encode($otpAuthUrl) ?>,
|
||||
width: 160,
|
||||
height: 160,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+164
-11
@@ -12,6 +12,18 @@ if (!has_admin_user()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Als de sessie verlopen, ingetrokken of gewijzigd is: beëindig deze direct en wis cookies
|
||||
if (isset($_GET['expired']) || isset($_GET['revoked']) || isset($_GET['ip_changed'])) {
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
fociz_terminate_session(session_id());
|
||||
}
|
||||
clear_day_session_cookie();
|
||||
$_SESSION = [];
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Als al ingelogd (via actieve sessie of persistente cookie), ga direct naar planner
|
||||
$currentUser = get_logged_in_user();
|
||||
if ($currentUser) {
|
||||
@@ -22,27 +34,76 @@ if ($currentUser) {
|
||||
$error = null;
|
||||
$infoMessage = null;
|
||||
if (isset($_GET['expired'])) {
|
||||
$infoMessage = "Uw dagsessie is om 23:59 beëindigd. Log opnieuw in om een nieuwe werkdag te starten.";
|
||||
$infoMessage = "Uw sessie is beeïndigd";
|
||||
}
|
||||
if (isset($_GET['revoked'])) {
|
||||
$infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan.";
|
||||
}
|
||||
if (isset($_GET['ip_changed'])) {
|
||||
$infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA.";
|
||||
}
|
||||
if (isset($_GET['ip_verified'])) {
|
||||
$infoMessage = "Nieuwe inloglocatie succesvol geverifieerd! U kunt nu veilig inloggen.";
|
||||
}
|
||||
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
|
||||
|
||||
// Terug naar stap 1
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
|
||||
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');
|
||||
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
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$db = get_db_connection();
|
||||
$clientIp = fociz_get_client_ip();
|
||||
$ipRateKey = "login_ip_{$clientIp}";
|
||||
$ipLimit = fociz_check_rate_limit($ipRateKey, 10, 900);
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$error = "Sessie verlopen of beveiligingstoken ongeldig. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif (!$ipLimit['allowed']) {
|
||||
$error = "Te veel inlogpogingen vanaf dit netwerkadres. Probeer het over " . ceil($ipLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif ($step === 1) {
|
||||
// STAP 1: Wachtwoord verificatie
|
||||
if ($step === 1) {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$password = $_POST['password'] ?? '';
|
||||
$emailRateKey = "login_email_{$email}";
|
||||
$emailLimit = fociz_check_rate_limit($emailRateKey, 5, 900);
|
||||
|
||||
if (empty($email) || empty($password)) {
|
||||
if (!$emailLimit['allowed']) {
|
||||
$error = "Dit account is tijdelijk geblokkeerd wegens te veel mislukte pogingen. Probeer het over " . ceil($emailLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($email) || empty($password)) {
|
||||
$error = "Vul e-mailadres en wachtwoord in.";
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT id, name, email, password_hash, two_factor_secret, two_factor_enabled FROM users WHERE email = ?");
|
||||
@@ -50,12 +111,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
|
||||
fociz_clear_rate_limit($emailRateKey);
|
||||
// Controleer of de inloglocatie/IP bekend is
|
||||
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
||||
|
||||
if (!$isIpKnown) {
|
||||
// Stuur verificatie-mail en log audit challenge
|
||||
$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, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
|
||||
|
||||
// Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
|
||||
// Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
|
||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||
$_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;
|
||||
}
|
||||
} else {
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
if (!empty($email)) {
|
||||
fociz_hit_rate_limit($emailRateKey, 900);
|
||||
}
|
||||
fociz_log_audit($user ? (int)$user['id'] : null, 'LOGIN_FAILED', 'user', $user ? (int)$user['id'] : null, ['reason' => 'Invalid credentials', 'ip' => $clientIp], 'FAILURE', $email);
|
||||
$error = "Onjuist e-mailadres of wachtwoord.";
|
||||
}
|
||||
}
|
||||
@@ -70,7 +156,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($totpCode)) {
|
||||
$totpRateKey = "login_totp_{$userId}";
|
||||
$totpLimit = fociz_check_rate_limit($totpRateKey, 5, 900);
|
||||
|
||||
if (!$totpLimit['allowed']) {
|
||||
$error = "Te veel ongeldige verificatiecodes. Probeer het over " . ceil($totpLimit['retry_after'] / 60) . " minuten opnieuw.";
|
||||
} elseif (empty($totpCode)) {
|
||||
$error = "Vul de 6-cijferige 2FA code in uit uw authenticator app.";
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
@@ -104,24 +195,60 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
fociz_clear_rate_limit($totpRateKey);
|
||||
fociz_clear_rate_limit($ipRateKey);
|
||||
|
||||
// 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);
|
||||
|
||||
$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):
|
||||
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
$_SESSION['user_role'] = $user['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
|
||||
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
|
||||
issue_day_session_token((int)$user['id']);
|
||||
|
||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
|
||||
// Registreer actieve sessie in user_sessions (SOC 2 / ISO 27001)
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
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', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
|
||||
|
||||
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');
|
||||
exit;
|
||||
} else {
|
||||
fociz_hit_rate_limit($totpRateKey, 900);
|
||||
fociz_hit_rate_limit($ipRateKey, 900);
|
||||
fociz_log_audit((int)$userId, 'LOGIN_MFA_FAILED', 'user', (int)$userId, ['ip' => $clientIp], 'FAILURE', $_SESSION['pending_2fa_user_email'] ?? null);
|
||||
$error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw.";
|
||||
}
|
||||
}
|
||||
@@ -209,6 +336,7 @@ $pageTitle = "Fociz — Inloggen";
|
||||
<?php if ($step === 1): ?>
|
||||
<!-- STAP 1: E-mail & Wachtwoord -->
|
||||
<form method="POST" action="/auth/login.php" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres</label>
|
||||
<div class="relative">
|
||||
@@ -236,6 +364,30 @@ $pageTitle = "Fociz — Inloggen";
|
||||
|
||||
<!-- STAP 2: 2FA TOTP Code -->
|
||||
<form method="POST" action="/auth/login.php" class="space-y-5">
|
||||
<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="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>
|
||||
@@ -249,10 +401,7 @@ $pageTitle = "Fociz — Inloggen";
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="text" name="totp_code" required autofocus maxlength="9" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
<span class="text-[11px] text-slate-500 dark:text-slate-500 text-center block mt-1">
|
||||
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
|
||||
</span>
|
||||
<input type="text" name="totp_code" required autofocus maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
@@ -274,6 +423,10 @@ $pageTitle = "Fociz — Inloggen";
|
||||
</div>
|
||||
|
||||
<script>
|
||||
try {
|
||||
localStorage.removeItem('fociz_session_last_activity');
|
||||
localStorage.removeItem('fociz_session_logged_out');
|
||||
} catch(e) {}
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+6
-2
@@ -6,13 +6,17 @@
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
// Vernietig persistente login in database
|
||||
// Vernietig persistente login en sessie in database (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
$uid = (int)$_SESSION['user_id'];
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$stmt->execute([$uid]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($uid, 'SESSION_TERMINATED_LOGOUT', 'users', $uid, ['ip' => fociz_get_client_ip()], 'SUCCESS', $_SESSION['user_email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren bij uitloggen
|
||||
}
|
||||
|
||||
+13
-9
@@ -37,8 +37,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) {
|
||||
|
||||
if (empty($password)) {
|
||||
$error = "Vul een nieuw wachtwoord in.";
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "De wachtwoorden komen niet overeen.";
|
||||
} else {
|
||||
@@ -62,6 +62,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) {
|
||||
$db->commit();
|
||||
$success = true;
|
||||
|
||||
fociz_log_audit(null, 'PASSWORD_RESET_COMPLETED', 'user', null, ['email' => $resetRecord['user_email']], 'SUCCESS', $resetRecord['user_email']);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) $db->rollBack();
|
||||
error_log("Fout bij resetten wachtwoord: " . $e->getMessage());
|
||||
@@ -183,16 +185,16 @@ $pageTitle = "Fociz — Wachtwoord Opnieuw Instellen";
|
||||
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Nieuw wachtwoord</label>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Nieuw wachtwoord (min. 12 tekens)</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="pwd"
|
||||
required
|
||||
minlength="8"
|
||||
minlength="12"
|
||||
autofocus
|
||||
placeholder="Minimaal 8 tekens"
|
||||
placeholder="Minimaal 12 tekens"
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||
oninput="checkPasswordStrength(this.value)"
|
||||
>
|
||||
@@ -204,6 +206,7 @@ $pageTitle = "Fociz — Wachtwoord Opnieuw Instellen";
|
||||
<div id="bar3" class="h-1 flex-1 rounded-full bg-slate-200 dark:bg-slate-800 transition-colors"></div>
|
||||
<span id="strengthText" class="text-[10px] text-slate-400 pl-1 font-medium"></span>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-1">Minimaal 12 tekens (incl. hoofdletter, kleine letter, cijfer en speciaal teken).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -213,7 +216,8 @@ $pageTitle = "Fociz — Wachtwoord Opnieuw Instellen";
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
required
|
||||
placeholder="Herhaal wachtwoord"
|
||||
minlength="12"
|
||||
placeholder="Herhaal wachtwoord (min. 12 tekens)"
|
||||
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition"
|
||||
>
|
||||
</div>
|
||||
@@ -255,9 +259,9 @@ $pageTitle = "Fociz — Wachtwoord Opnieuw Instellen";
|
||||
if (!val) return;
|
||||
|
||||
var score = 0;
|
||||
if (val.length >= 8) score++;
|
||||
if (/[A-Z]/.test(val) && /[0-9]/.test(val)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(val) && val.length >= 10) score++;
|
||||
if (val.length >= 12) score++;
|
||||
if (/[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(val) && val.length >= 12) score++;
|
||||
|
||||
if (score === 1) {
|
||||
b1.className = 'h-1 flex-1 rounded-full bg-amber-500';
|
||||
|
||||
+33
-7
@@ -35,8 +35,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht.";
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = "Voer een geldig e-mailadres in.";
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = "Het wachtwoord moet minimaal 8 tekens bevatten.";
|
||||
} elseif (($pwErr = fociz_validate_password($password)) !== null) {
|
||||
$error = $pwErr;
|
||||
} elseif ($password !== $confirmPassword) {
|
||||
$error = "De ingevoerde wachtwoorden komen niet overeen.";
|
||||
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
|
||||
@@ -62,17 +62,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$adminId = (int)$db->lastInsertId();
|
||||
|
||||
// Automatische dagsessie starten (geldig tot 23:59:59)
|
||||
// Automatische dagsessie starten met verse sessie ID (OWASP ASVS v4.0 §3.3.1)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $adminId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $email;
|
||||
$_SESSION['user_role'] = 'ADMIN';
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($adminId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($adminId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['setup_totp_secret']);
|
||||
|
||||
fociz_log_audit($adminId, 'INITIAL_ADMIN_SETUP', 'user', $adminId, ['email' => $email, 'name' => $name], 'SUCCESS', $email);
|
||||
|
||||
$success = true;
|
||||
} catch (Exception $e) {
|
||||
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
|
||||
@@ -193,12 +207,12 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 12 tekens)</label>
|
||||
<input type="password" name="password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
<input type="password" name="confirm_password" required minlength="12" placeholder="••••••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +228,7 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
</p>
|
||||
|
||||
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
|
||||
<div id="qrcode" class="w-44 h-44 mx-auto flex items-center justify-center"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
|
||||
@@ -238,8 +252,20 @@ $pageTitle = "Fociz — Eerste Initialisatie";
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrcode.min.js"></script>
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
const qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer) {
|
||||
new QRCode(qrContainer, {
|
||||
text: <?= json_encode($otpAuthUrl) ?>,
|
||||
width: 176,
|
||||
height: 176,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Nieuwe Inloglocatie / IP Verificatie
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
$token = trim($_GET['token'] ?? '');
|
||||
$db = get_db_connection();
|
||||
|
||||
$error = null;
|
||||
$success = false;
|
||||
$userEmail = '';
|
||||
$ipAddress = '';
|
||||
|
||||
if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
$error = "Ongeldige of ontbrekende verificatielink.";
|
||||
} else {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT kip.*, u.email, u.name
|
||||
FROM user_known_ips kip
|
||||
JOIN users u ON kip.user_id = u.id
|
||||
WHERE kip.verify_token = ?
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
$error = "Deze verificatielink is niet gevonden of al gebruikt.";
|
||||
} elseif (!empty($record['verify_token_expires_at']) && strtotime($record['verify_token_expires_at']) < time()) {
|
||||
$error = "Deze verificatielink is verlopen (geldigheidsduur was 30 minuten). Probeer opnieuw in te loggen om een nieuwe link te ontvangen.";
|
||||
} else {
|
||||
// Verifieer het IP-adres
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE user_known_ips
|
||||
SET is_verified = 1, verify_token = NULL, verify_token_expires_at = NULL, last_seen_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$updateStmt->execute([$record['id']]);
|
||||
|
||||
$success = true;
|
||||
$userEmail = $record['email'];
|
||||
$ipAddress = $record['ip_address'];
|
||||
fociz_log_audit((int)$record['user_id'], 'IP_VERIFIED', 'user_known_ips', (int)$record['id'], ['ip' => $ipAddress], 'SUCCESS', $userEmail);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("verify-ip.php error: " . $e->getMessage());
|
||||
$error = "Er is een databasefout opgetreden bij het verifiëren.";
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = "Fociz — Locatie Verificatie";
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
indigo: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-slate-100 dark:bg-[#0c0f17] text-slate-800 dark:text-slate-100 min-h-screen flex items-center justify-center p-4 transition-colors duration-200">
|
||||
|
||||
<div class="w-full max-w-md bg-white dark:bg-[#121622] rounded-2xl border border-slate-200/80 dark:border-slate-800/80 shadow-2xl p-8 space-y-6">
|
||||
|
||||
<!-- Logo & Header -->
|
||||
<div class="text-center space-y-2">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-500/10 border border-indigo-500/20 text-indigo-500 mb-2">
|
||||
<i data-lucide="shield-check" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-white">Locatie Verificatie</h1>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Beveiligde inlogtoegang voor Fociz</p>
|
||||
</div>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-800 dark:text-emerald-400 text-sm space-y-2 text-center">
|
||||
<div class="flex items-center justify-center space-x-2 font-bold text-emerald-700 dark:text-emerald-300">
|
||||
<i data-lucide="check-circle-2" class="w-5 h-5"></i>
|
||||
<span>Locatie Succesvol Geverifieerd!</span>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
Het IP-adres <strong class="font-mono"><?= htmlspecialchars($ipAddress) ?></strong> is nu geautoriseerd voor uw account (<?= htmlspecialchars($userEmail) ?>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<a href="/auth/login.php?ip_verified=1" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
|
||||
<span>Direct Inloggen</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-800 dark:text-rose-400 text-sm space-y-2 text-center">
|
||||
<div class="flex items-center justify-center space-x-2 font-bold text-rose-700 dark:text-rose-300">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||
<span>Verificatie Mislukt</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed">
|
||||
<?= htmlspecialchars($error ?? 'Onbekende fout.') ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<a href="/auth/login.php" class="w-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold py-2.5 px-4 rounded-xl transition flex items-center justify-center space-x-2 cursor-pointer text-sm">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
<span>Terug naar Inloggen</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,6 +21,63 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
max-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>
|
||||
|
||||
<div id="boardPageContainer" class="space-y-4 w-full">
|
||||
@@ -44,9 +101,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
Beheerder
|
||||
</span>
|
||||
</div>
|
||||
<p id="boardDescriptionText" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Teamtaken, opdrachten & Outlook agendablokkering
|
||||
</p>
|
||||
<p id="boardDescriptionText" class="hidden text-xs text-slate-500 dark:text-slate-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,10 +132,10 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
<!-- Filter: Prioriteit -->
|
||||
<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">
|
||||
<option value="ALL">Alle prioriteiten</option>
|
||||
<option value="P1">P1 — Urgent</option>
|
||||
<option value="P2">P2 — Hoog</option>
|
||||
<option value="P3">P3 — Normaal</option>
|
||||
<option value="ALL">Prioriteiten (Alle)</option>
|
||||
<option value="P1">P1 Urgent</option>
|
||||
<option value="P2">P2 Hoog</option>
|
||||
<option value="P3">P3 Normaal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +214,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
|
||||
<!-- MODAL 1: Taak (Post-it / Opdracht) Toevoegen of Bewerken -->
|
||||
<div id="cardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div id="cardModal" onclick="if(event.target === this) closeCardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 w-full max-w-xl shadow-2xl space-y-5 my-8">
|
||||
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -277,6 +332,25 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</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">
|
||||
<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">
|
||||
@@ -341,7 +415,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
|
||||
<!-- MODAL 2: Bord Instellingen, Kolommen & Leden Autorisatie (Beheerder) -->
|
||||
<div id="manageBoardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div id="manageBoardModal" onclick="if(event.target === this) closeManageBoardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 w-full max-w-xl shadow-2xl space-y-5 my-8">
|
||||
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -403,7 +477,7 @@ $isAdmin = ($userRole === 'ADMIN');
|
||||
</div>
|
||||
|
||||
<!-- MODAL 3: Nieuw Bord Aanmaken (Beheerder) -->
|
||||
<div id="createBoardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||
<div id="createBoardModal" onclick="if(event.target === this) closeCreateBoardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||
<div class="fociz-card p-6 w-full max-w-md shadow-2xl space-y-4">
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<h3 class="text-base font-bold text-slate-900 dark:text-white">Nieuw Bord Toevoegen</h3>
|
||||
@@ -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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<script>
|
||||
@@ -508,10 +614,11 @@ function onBoardSelectChange(bId) {
|
||||
}
|
||||
|
||||
// 2. Data van specifiek bord inladen
|
||||
async function loadBoardData(boardId) {
|
||||
async function loadBoardData(boardId, showLoader = true) {
|
||||
currentBoardId = boardId;
|
||||
document.getElementById('linkToTimeline')?.setAttribute('href', `/timeline.php?board_id=${boardId}`);
|
||||
const container = document.getElementById('boardLanesContainer');
|
||||
if (showLoader) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center w-full py-20 text-slate-400 text-sm">
|
||||
<i data-lucide="loader-2" class="w-5 h-5 animate-spin mr-2"></i>
|
||||
@@ -519,6 +626,7 @@ async function loadBoardData(boardId) {
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/board.php?action=get_board_data&board_id=${boardId}`);
|
||||
@@ -527,7 +635,14 @@ async function loadBoardData(boardId) {
|
||||
currentBoardData = data;
|
||||
|
||||
// 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 roleBadge = document.getElementById('boardRoleBadge');
|
||||
|
||||
@@ -567,9 +682,13 @@ function onCardDragStart(e, cardId) {
|
||||
function onCardDragEnd(e) {
|
||||
if (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;
|
||||
}
|
||||
// Verwijder eventuele achtergebleven opacity op alle kaarten
|
||||
document.querySelectorAll('[id^="card-"].opacity-40').forEach(el => {
|
||||
el.classList.remove('opacity-40', 'pointer-events-none');
|
||||
});
|
||||
setTimeout(() => {
|
||||
isDraggingCard = false;
|
||||
}, 150);
|
||||
@@ -599,6 +718,23 @@ async function onLaneDrop(e, targetLaneId) {
|
||||
|
||||
const rawId = draggedCardId || e.dataTransfer.getData('text/plain');
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -611,7 +747,7 @@ async function onLaneDrop(e, targetLaneId) {
|
||||
target_position: 999
|
||||
})
|
||||
});
|
||||
await loadBoardData(currentBoardId);
|
||||
await loadBoardData(currentBoardId, false);
|
||||
} catch (err) {
|
||||
console.error('Fout bij verplaatsen taak:', err);
|
||||
}
|
||||
@@ -678,7 +814,7 @@ function renderCards() {
|
||||
if (card.assignee_name) {
|
||||
const initial = card.assignee_name.charAt(0).toUpperCase();
|
||||
const avatarMarkup = card.assignee_avatar
|
||||
? `<img src="${escapeHtml(card.assignee_avatar)}" class="w-5 h-5 rounded-full object-cover flex-shrink-0 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-800/60">`
|
||||
? `<img src="${escapeHtml(card.assignee_avatar)}" class="w-5 h-5 rounded-full object-cover flex-shrink-0 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-800/60" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');"><div class="hidden w-5 h-5 rounded-full bg-gradient-to-tr from-indigo-600 to-sky-500 text-white font-bold text-[9px] flex items-center justify-center flex-shrink-0">${initial}</div>`
|
||||
: `<div class="w-5 h-5 rounded-full bg-gradient-to-tr from-indigo-600 to-sky-500 text-white font-bold text-[9px] flex items-center justify-center flex-shrink-0">${initial}</div>`;
|
||||
assigneeHtml = `
|
||||
<div class="flex items-center space-x-1.5" title="Toegewezen aan: ${escapeHtml(card.assignee_name)}">
|
||||
@@ -715,7 +851,8 @@ function renderCards() {
|
||||
ondragstart="onCardDragStart(event, ${card.id})"
|
||||
ondragend="onCardDragEnd(event)"
|
||||
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">
|
||||
<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
|
||||
function openManageBoardModal() {
|
||||
if (!currentBoardData) return;
|
||||
@@ -1275,11 +1537,25 @@ function toggleBoardMaximize() {
|
||||
// Pas bewaarde breedte-status direct toe bij het laden
|
||||
applyMaximizeState(isBoardMaximized);
|
||||
|
||||
// Sneltoets Escape om gemaximaliseerd bord te verkleinen (mits geen modal openstaat)
|
||||
// Sneltoets Escape om geopende modals te sluiten of gemaximaliseerd bord te verkleinen
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && isBoardMaximized) {
|
||||
const anyModalOpen = document.querySelector('#cardModal:not(.hidden), #manageBoardModal:not(.hidden), #createBoardModal:not(.hidden)');
|
||||
if (!anyModalOpen) {
|
||||
if (e.key === 'Escape') {
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
if (cardModal && !cardModal.classList.contains('hidden')) {
|
||||
closeCardModal();
|
||||
return;
|
||||
}
|
||||
const manageModal = document.getElementById('manageBoardModal');
|
||||
if (manageModal && !manageModal.classList.contains('hidden')) {
|
||||
closeManageBoardModal();
|
||||
return;
|
||||
}
|
||||
const createModal = document.getElementById('createBoardModal');
|
||||
if (createModal && !createModal.classList.contains('hidden')) {
|
||||
closeCreateBoardModal();
|
||||
return;
|
||||
}
|
||||
if (isBoardMaximized) {
|
||||
toggleBoardMaximize();
|
||||
}
|
||||
}
|
||||
|
||||
+207
-27
@@ -4,15 +4,22 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/settings.php';
|
||||
$currentUser = require_auth();
|
||||
$db = get_db_connection();
|
||||
$brainstormCategories = get_brainstorm_categories();
|
||||
|
||||
// Verwerk nieuwe sessie aanmaken
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') {
|
||||
$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'] ?? '');
|
||||
$autoAi = !empty($_POST['auto_ai']) ? 1 : 0;
|
||||
|
||||
if (!empty($title)) {
|
||||
$stmt = $db->prepare("
|
||||
@@ -21,7 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
");
|
||||
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
|
||||
$newId = $db->lastInsertId();
|
||||
header("Location: /brainstorm_canvas.php?id={$newId}");
|
||||
$redirectUrl = "/brainstorm_canvas.php?id={$newId}" . ($autoAi ? "&auto_ai=1" : "");
|
||||
header("Location: {$redirectUrl}");
|
||||
exit;
|
||||
} else {
|
||||
$error = "Titel van de brainstormsessie is verplicht.";
|
||||
@@ -88,9 +96,11 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?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">
|
||||
<div>
|
||||
<?php foreach ($sessions as $sess):
|
||||
$canDelete = ($currentUser['role'] === 'ADMIN' || (int)$sess['created_by_id'] === (int)$currentUser['id']);
|
||||
?>
|
||||
<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">
|
||||
<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']) ?>
|
||||
@@ -110,29 +120,46 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?= htmlspecialchars($sess['description']) ?>
|
||||
</p>
|
||||
<?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">
|
||||
<span class="truncate">Door <?= htmlspecialchars($sess['creator_name']) ?></span>
|
||||
<div class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold flex-shrink-0 ml-2">
|
||||
<div class="flex items-center space-x-2 flex-shrink-0 ml-2">
|
||||
<?php if ($canDelete): ?>
|
||||
<button
|
||||
type="button"
|
||||
onclick="promptDeleteSession(event, <?= (int)$sess['id'] ?>, '<?= htmlspecialchars(addslashes($sess['title']), ENT_QUOTES) ?>', <?= (int)$sess['cards_count'] ?>)"
|
||||
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer"
|
||||
title="Sessie verwijderen"
|
||||
>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-bold">
|
||||
<span>Open canvas</span>
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuwe sessie -->
|
||||
<div id="newSessionModal" 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 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-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">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Nieuwe Brainstormsessie</span>
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<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">
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
</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">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
@@ -141,31 +168,78 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<form method="POST" action="/brainstorm.php" class="space-y-4">
|
||||
<input type="hidden" name="action" value="create_session">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Onderwerp / Titel</label>
|
||||
<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">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Onderwerp / Titel <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" required placeholder="Bijv. Modernisering Klantportaal & Self-service" class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs font-semibold">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<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">
|
||||
<?php
|
||||
$hasActiveCategory = false;
|
||||
foreach ($brainstormCategories as $bCat):
|
||||
if (isset($bCat['is_active']) && empty($bCat['is_active'])) continue;
|
||||
$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>
|
||||
</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 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Uitgebreide Briefing, Context & Doelstellingen
|
||||
</label>
|
||||
<span class="text-[10px] text-indigo-600 dark:text-indigo-400 font-medium">Voeding voor AI post-its</span>
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="6"
|
||||
placeholder="Voer hier alle achtergrondinformatie, context en doelen in waar de AI post-its van gaat maken. Bijvoorbeeld: • Achtergrond: Klanten klagen over lange wachttijden bij onboarding. • Doel: Binnen 3 maanden de doorlooptijd met 50% verkorten. • Bekende knelpunten: Handmatige validatie, ontbrekende API-koppeling met CRM. • Gewenste richting: Self-service portaal, automatische validatie, duidelijke statusupdates."
|
||||
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-300 dark:border-slate-800 rounded-xl p-3.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs leading-relaxed resize-y font-normal"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400 mt-1">
|
||||
Tip: Hoe gedetailleerder de informatie, hoe gerichter en waardevoller de ideeën, knelpunten en oplossingen die AI genereert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- AI Direct Genereren Optie -->
|
||||
<div class="p-3.5 rounded-xl bg-violet-50/70 dark:bg-violet-950/25 border border-violet-200/80 dark:border-violet-800/40 flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_ai_checkbox"
|
||||
name="auto_ai"
|
||||
value="1"
|
||||
checked
|
||||
class="w-4 h-4 mt-0.5 rounded text-violet-600 focus:ring-0 border-violet-300 dark:border-violet-700 cursor-pointer"
|
||||
>
|
||||
<label for="auto_ai_checkbox" class="text-xs cursor-pointer select-none">
|
||||
<span class="font-bold text-violet-900 dark:text-violet-200 block flex items-center space-x-1.5">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-violet-600 dark:text-violet-400"></i>
|
||||
<span>Direct AI post-its genereren op basis van deze sessiebeschrijving</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-violet-700/80 dark:text-violet-300/80 block mt-0.5 leading-snug">
|
||||
AI Co-Pilot analyseert de ingevoerde context direct bij het openen en maakt automatisch gerichte post-its aan voor Ideeën, Knelpunten, Oplossingen en Quick Wins.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
Sessie Aanmaken & Canvas Openen
|
||||
<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">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4"></i>
|
||||
<span>Sessie Aanmaken & Canvas Openen</span>
|
||||
</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">
|
||||
Annuleren
|
||||
@@ -175,4 +249,110 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</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>
|
||||
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) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('newSessionModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
closeDeleteSessionModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
+1511
-71
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"ext-mysqli": "*"
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# =========================================================================
|
||||
# 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 Server
|
||||
DB_HOST="192.168.2.33"
|
||||
DB_PORT="3306"
|
||||
|
||||
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||
|
||||
# Microsoft 365 Entra ID Tenant
|
||||
MICROSOFT_TENANT_ID="planningsagenda.nl"
|
||||
|
||||
# SMTP Mail Server
|
||||
SMTP_HOST="mail.fociz.nl"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="info@fociz.nl"
|
||||
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
||||
SMTP_SECURE="tls"
|
||||
SMTP_FROM_EMAIL="info@fociz.nl"
|
||||
SMTP_FROM_NAME="Fociz Dev"
|
||||
SMTP_REPLY_TO="info@fociz.nl"
|
||||
|
||||
# Webhooks (optioneel)
|
||||
JIRA_WEBHOOK_SECRET="fociz_webhook_dev_2026"
|
||||
@@ -1,38 +0,0 @@
|
||||
# =========================================================================
|
||||
# FOCIZ OMGEVINGSCONFIGURATIE VOORBEELD (TEMPLATE)
|
||||
# =========================================================================
|
||||
# Alle actieve database- en applicatie-instellingen staan centraal in:
|
||||
# config/config.php
|
||||
#
|
||||
# Indien gewenst kan een lokaal .env bestand in de hoofdmap worden geplaatst
|
||||
# om specifieke variabelen lokaal te overschrijven.
|
||||
|
||||
DB_HOST="127.0.0.1"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="fociz"
|
||||
DB_USER="fociz_user"
|
||||
DB_PASS=""
|
||||
|
||||
APP_ENV="live"
|
||||
APP_URL="https://www.fociz.nl"
|
||||
APP_SECRET=""
|
||||
|
||||
MICROSOFT_CLIENT_ID=""
|
||||
MICROSOFT_CLIENT_SECRET=""
|
||||
MICROSOFT_TENANT_ID=""
|
||||
|
||||
JIRA_DOMAIN=""
|
||||
JIRA_EMAIL=""
|
||||
JIRA_API_TOKEN=""
|
||||
|
||||
# SMTP Mail Server
|
||||
SMTP_HOST="mail.fociz.nl"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="info@fociz.nl"
|
||||
SMTP_PASS=""
|
||||
SMTP_SECURE="tls"
|
||||
SMTP_FROM_EMAIL="no-reply@fociz.nl"
|
||||
SMTP_FROM_NAME="Fociz"
|
||||
SMTP_REPLY_TO="info@fociz.nl"
|
||||
|
||||
|
||||
+41
-18
@@ -17,6 +17,7 @@ if (!headers_sent()) {
|
||||
header('X-XSS-Protection: 1; mode=block');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'");
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
@@ -24,10 +25,11 @@ if (!headers_sent()) {
|
||||
}
|
||||
@ini_set('expose_php', 'off');
|
||||
|
||||
// 1. Optionele .env inlezing uit de hoofdmap (uitsluitend .env, GEEN .env.example fallback)
|
||||
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
|
||||
$envVars = [];
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
if (file_exists($envFile)) {
|
||||
$envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
|
||||
foreach ($envLocations as $envFile) {
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines !== false) {
|
||||
foreach ($lines as $line) {
|
||||
@@ -46,6 +48,8 @@ if (file_exists($envFile)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Eerste gevonden .env bestand is leidend
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,30 +81,52 @@ if (!function_exists('fociz_env')) {
|
||||
define('APP_NAME', 'Fociz');
|
||||
|
||||
// Automatische omgevingsdetectie:
|
||||
// 1. Expliciete .env variabele 'APP_ENV'
|
||||
// 2. Automatisch 'development' indien hostname 'dev.' of 'localhost' bevat
|
||||
// 3. Standaard 'live'
|
||||
$detectedHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
// 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
|
||||
$detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
|
||||
$isDevHost = (
|
||||
str_contains($detectedHost, 'dev.') ||
|
||||
str_contains($detectedHost, 'localhost') ||
|
||||
str_contains($detectedHost, '127.0.0.1')
|
||||
);
|
||||
$defaultEnv = $isDevHost ? 'development' : 'live';
|
||||
define('APP_ENV', fociz_env('APP_ENV', $defaultEnv));
|
||||
$isLiveHost = (
|
||||
!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'));
|
||||
|
||||
// =========================================================================
|
||||
// 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');
|
||||
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
||||
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'));
|
||||
define('DB_PASS', fociz_env('DB_PASS', $isDevEnv ? 'F0c1z_Dev_Secure_2026!' : 'F0c1z_Live_Secure_2026!'));
|
||||
|
||||
// Veiligheidswaarborg: op het live domein of live omgeving NOOIT per ongeluk de dev database gebruiken
|
||||
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');
|
||||
|
||||
// Bepaal de dynamische of geconfigureerde applicatie URL
|
||||
@@ -156,10 +182,7 @@ if (APP_ENV === 'live') {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
|
||||
error_log("Fociz Fatal Error: [{$error['type']}] {$error['message']} in {$error['file']}:{$error['line']}");
|
||||
$showDiagnostics = (
|
||||
(isset($_SESSION['user_role']) && $_SESSION['user_role'] === 'ADMIN') ||
|
||||
(isset($_GET['debug']) && $_GET['debug'] === 'fociz')
|
||||
);
|
||||
$showDiagnostics = (isset($_SESSION['user_role']) && $_SESSION['user_role'] === 'ADMIN');
|
||||
if ($showDiagnostics) {
|
||||
echo "<div style='font-family: monospace; background: #0c0f17; color: #ef4444; padding: 25px; margin: 20px; border-radius: 12px; border: 1px solid #dc2626;'>
|
||||
<h3>⚠️ Fociz Systeemfout (Diagnostiek)</h3>
|
||||
|
||||
+106
-19
@@ -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 = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
@@ -41,6 +42,9 @@ function get_db_connection(): PDO {
|
||||
if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
|
||||
$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(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
@@ -53,6 +57,8 @@ function get_db_connection(): PDO {
|
||||
// 2. CHECK: Bestaat de database al?
|
||||
try {
|
||||
$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);
|
||||
return $pdo;
|
||||
|
||||
@@ -70,6 +76,7 @@ function get_db_connection(): PDO {
|
||||
));
|
||||
|
||||
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
|
||||
$pdo->exec("SET time_zone = '{$tzOffset}'");
|
||||
ensure_tables_exist($pdo);
|
||||
return $pdo;
|
||||
|
||||
@@ -119,8 +126,14 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
if ($tablesChecked) return;
|
||||
$tablesChecked = true;
|
||||
|
||||
try {
|
||||
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
|
||||
$lockFile = sys_get_temp_dir() . '/fociz_schema_' . md5(__DIR__) . '.lock';
|
||||
|
||||
if (file_exists($lockFile) && file_exists($schemaFile) && filemtime($lockFile) >= filemtime($schemaFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (file_exists($schemaFile)) {
|
||||
$sql = file_get_contents($schemaFile);
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||
@@ -146,6 +159,7 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
"ALTER TABLE users ADD COLUMN notification_settings TEXT NULL AFTER remember_token_expires_at",
|
||||
"ALTER TABLE users ADD COLUMN theme VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER notification_settings",
|
||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
|
||||
"ALTER TABLE users MODIFY COLUMN avatar_url MEDIUMTEXT NULL",
|
||||
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
|
||||
"ALTER TABLE users ADD COLUMN default_jira_filter_code VARCHAR(50) NULL AFTER avatar_url",
|
||||
"ALTER TABLE users ADD COLUMN fociz_widget_layout TEXT NULL AFTER default_jira_filter_code",
|
||||
@@ -154,6 +168,30 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
|
||||
"CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
// 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
|
||||
"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
|
||||
"ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at",
|
||||
"ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip",
|
||||
"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
|
||||
"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) {
|
||||
try {
|
||||
@@ -163,31 +201,62 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Zorg dat de standaard Jira Servicedesk wachtrijfilters (DX en SD) aanwezig zijn
|
||||
// Zorg dat de standaard Jira wachtrijfilters (SD, IN, AAN, GEN, DX) aanwezig zijn
|
||||
try {
|
||||
$filterCount = (int)$pdo->query("SELECT COUNT(*) FROM jira_queue_filters")->fetchColumn();
|
||||
if ($filterCount === 0) {
|
||||
$existingCodes = $pdo->query("SELECT filter_code FROM jira_queue_filters")->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
$defaultFilters = [
|
||||
[
|
||||
'code' => 'SD',
|
||||
'name' => 'SD — Servicedesk',
|
||||
'project' => 'SD',
|
||||
'jql' => 'project = "SD"',
|
||||
'desc' => 'Servicedesk supportvragen en operationele meldingen.',
|
||||
'order' => 1
|
||||
],
|
||||
[
|
||||
'code' => 'IN',
|
||||
'name' => 'IN — Interfaces',
|
||||
'project' => 'IN',
|
||||
'jql' => 'project = "IN"',
|
||||
'desc' => 'Interface-koppelingen, API-endpoints en data-uitwisselingen.',
|
||||
'order' => 2
|
||||
],
|
||||
[
|
||||
'code' => 'AAN',
|
||||
'name' => 'AAN — Aanvragen',
|
||||
'project' => 'AAN',
|
||||
'jql' => 'project = "AAN"',
|
||||
'desc' => 'Klant- en gebruikersaanvragen en wijzigingsverzoeken.',
|
||||
'order' => 3
|
||||
],
|
||||
[
|
||||
'code' => 'GEN',
|
||||
'name' => 'GEN — General',
|
||||
'project' => 'GEN',
|
||||
'jql' => 'project = "GEN"',
|
||||
'desc' => 'Algemene taken, organisatie en platformprojecten.',
|
||||
'order' => 4
|
||||
],
|
||||
[
|
||||
'code' => 'DX',
|
||||
'name' => 'DX — Koppelingen & SSO',
|
||||
'project' => 'SD',
|
||||
'jql' => '"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
|
||||
'desc' => 'Servicedesk tickets specifiek voor koppelingen en migraties.',
|
||||
'order' => 5
|
||||
]
|
||||
];
|
||||
$insertFilterStmt = $pdo->prepare("
|
||||
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([
|
||||
'DX',
|
||||
'DX — Koppelingen, SSO & Migratie',
|
||||
'SD',
|
||||
'"Onderwerp logging[dropdown]" in (Koppelingen, SSO-koppeling, Rest-endpoints, Datadump, UWV-koppeling, Migratie)',
|
||||
'Servicedesk tickets met betrekking tot koppelingen, endpoints en datamigraties.',
|
||||
1
|
||||
]);
|
||||
$insertFilterStmt->execute([
|
||||
'SD',
|
||||
'SD — Regulier Servicedesk',
|
||||
'SD',
|
||||
'("Onderwerp logging[Dropdown]" = EMPTY OR "Onderwerp logging[Dropdown]" != Koppelingen AND "Onderwerp logging[Dropdown]" != Migratie AND "Onderwerp logging[Dropdown]" != SSO-koppeling AND "Onderwerp logging[Dropdown]" != UWV-koppeling AND "Onderwerp logging[Dropdown]" != Rest-endpoints AND "Onderwerp logging[Dropdown]" != Datadump)',
|
||||
'Reguliere servicedesk incidenten, algemene meldingen en gebruikersvragen.',
|
||||
2
|
||||
$df['code'], $df['name'], $df['project'], $df['jql'], $df['desc'], $df['order']
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien tabel nog in opbouw is
|
||||
}
|
||||
@@ -220,8 +289,26 @@ function ensure_tables_exist(PDO $pdo): void {
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
error_log("Fout bij controleren tabellen: " . $e->getMessage());
|
||||
}
|
||||
|
||||
+94
-1
@@ -13,11 +13,13 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`remember_token` VARCHAR(64) NULL,
|
||||
`remember_token_expires_at` DATETIME NULL,
|
||||
`notification_settings` TEXT NULL,
|
||||
`avatar_url` VARCHAR(255) NULL,
|
||||
`avatar_url` MEDIUMTEXT NULL,
|
||||
`default_jira_filter_code` VARCHAR(50) NULL,
|
||||
`fociz_widget_layout` TEXT NULL,
|
||||
`last_mail_check_at` DATETIME NULL,
|
||||
`last_jira_check_at` DATETIME NULL,
|
||||
`last_login_ip` VARCHAR(45) NULL,
|
||||
`last_login_at` DATETIME NULL,
|
||||
`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;
|
||||
@@ -99,12 +101,18 @@ CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
|
||||
`content` TEXT NOT NULL,
|
||||
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
|
||||
`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,
|
||||
`author_name` VARCHAR(120) NULL,
|
||||
`converted_to_roadmap_item_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`session_id`),
|
||||
INDEX (`assignee_id`),
|
||||
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -242,3 +250,88 @@ CREATE TABLE IF NOT EXISTS `app_cache` (
|
||||
`expires_at` INT UNSIGNED NOT NULL,
|
||||
INDEX `idx_app_cache_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz V2.0: Subtaken & Checklists per taak
|
||||
CREATE TABLE IF NOT EXISTS `task_subtasks` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`position` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (`task_id`, `position`),
|
||||
CONSTRAINT `fk_subtasks_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_subtasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz Beveiliging: Bekende IP-adressen en verificatie per gebruiker
|
||||
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`),
|
||||
CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz SOC 2 & ISO 27001: Onwijzigbare Audit Trail (Beveiligings- en beheeracties)
|
||||
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;
|
||||
|
||||
-- Fociz SOC 2 CC6.1 & ISO 27001 A.9.4.2: Actieve Sessietracking & Remote Revocation
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`device_name` VARCHAR(100) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`last_activity_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`session_id_hash`),
|
||||
INDEX (`expires_at`),
|
||||
INDEX (`is_active`),
|
||||
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) 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;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Beveiliging van de interne includes map (Pen Test Beveiliging)
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
+816
-30
@@ -8,27 +8,420 @@ require_once __DIR__ . '/http.php';
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met de geconfigureerde AI provider
|
||||
* STRICT ZERO-DATA LEAKAGE PRIVACY FIREWALL
|
||||
* Garandeert dat NOOIT data uit Jira, Mail (Outlook) of Microsoft Teams
|
||||
* doorgestuurd kan worden naar een externe AI provider (Gemini / OpenAI).
|
||||
*
|
||||
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
||||
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int]
|
||||
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string]
|
||||
* @param array $messages Referentie naar de messages array die verzonden wordt
|
||||
* @return array Aantal en type geactiveerde firewall filters
|
||||
*/
|
||||
function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
$aiSettings = get_ai_settings();
|
||||
if (empty($aiSettings['ai_api_key'])) {
|
||||
function fociz_ai_enforce_privacy_firewall(array &$messages): array {
|
||||
$detections = [
|
||||
'jira_tickets' => 0,
|
||||
'jira_urls' => 0,
|
||||
'teams_links' => 0,
|
||||
'email_addrs' => 0,
|
||||
'mail_headers' => 0,
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
// Jira ticket keys zoals PROJ-1234, ABC-99 (2 tot 10 hoofdletters gevolgd door streepje en cijfers)
|
||||
'jira_tickets' => '/\b[A-Z]{2,10}-\d+\b/',
|
||||
// Jira / Atlassian domeinen en URLs
|
||||
'jira_urls' => '/https?:\/\/[a-zA-Z0-9-]+\.atlassian\.net[^\s]*/i',
|
||||
// Teams URLs en links
|
||||
'teams_links' => '/https?:\/\/teams\.microsoft\.com[^\s]*/i',
|
||||
// E-mailadressen
|
||||
'email_addrs' => '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
|
||||
// E-mailheaders en Microsoft Outlook / Teams traces
|
||||
'mail_headers' => '/\b(Subject:|Onderwerp:|From:|Van:|To:|Aan:|CC:|BCC:|FW:|Fwd:|RE:)\s*[^\r\n]+/i',
|
||||
];
|
||||
|
||||
$replacements = [
|
||||
'jira_tickets' => '[TICKET_AFGESCHERMD]',
|
||||
'jira_urls' => '[JIRA_URL_AFGESCHERMD]',
|
||||
'teams_links' => '[TEAMS_LINK_AFGESCHERMD]',
|
||||
'email_addrs' => '[EMAIL_AFGESCHERMD]',
|
||||
'mail_headers' => '[MAIL_HEADER_AFGESCHERMD]',
|
||||
];
|
||||
|
||||
foreach ($messages as $idx => $msg) {
|
||||
if (!isset($msg['content']) || !is_string($msg['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = $msg['content'];
|
||||
foreach ($patterns as $key => $pattern) {
|
||||
$count = 0;
|
||||
$text = preg_replace($pattern, $replacements[$key], $text, -1, $count);
|
||||
if ($count > 0) {
|
||||
$detections[$key] += $count;
|
||||
}
|
||||
}
|
||||
$messages[$idx]['content'] = $text;
|
||||
}
|
||||
|
||||
$totalBlocked = array_sum($detections);
|
||||
if ($totalBlocked > 0) {
|
||||
error_log(sprintf(
|
||||
"[FOCIZ AI FIREWALL] Zero-Data Leakage actief: %d potentiële datalekken geblokkeerd en geanonimiseerd (%s)",
|
||||
$totalBlocked,
|
||||
json_encode($detections)
|
||||
));
|
||||
}
|
||||
|
||||
return $detections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met Google Gemini Pro
|
||||
*
|
||||
* @param array $messages
|
||||
* @param array $options
|
||||
* @param array $aiSettings
|
||||
* @return array
|
||||
*/
|
||||
function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings): array {
|
||||
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
||||
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gemini-2.0-flash';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = max(512, (int)($options['max_tokens'] ?? 2048));
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
|
||||
'error' => 'Geen Google Gemini API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = $aiSettings['ai_api_key'];
|
||||
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
|
||||
// Google Generative Language API structuur opbouwen
|
||||
$systemInstructions = [];
|
||||
$contents = [];
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$role = $msg['role'] ?? 'user';
|
||||
$text = (string)($msg['content'] ?? '');
|
||||
|
||||
if ($role === 'system') {
|
||||
$systemInstructions[] = ['text' => $text];
|
||||
} else {
|
||||
$contents[] = [
|
||||
'role' => ($role === 'assistant') ? 'model' : 'user',
|
||||
'parts' => [['text' => $text]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($contents)) {
|
||||
$contents[] = [
|
||||
'role' => 'user',
|
||||
'parts' => [['text' => 'Hallo']],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
'temperature' => (float)$temperature,
|
||||
'maxOutputTokens' => (int)$maxTokens,
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($systemInstructions)) {
|
||||
$payload['system_instruction'] = [
|
||||
'parts' => $systemInstructions,
|
||||
];
|
||||
}
|
||||
|
||||
if ($jsonMode) {
|
||||
$payload['generationConfig']['responseMimeType'] = 'application/json';
|
||||
}
|
||||
|
||||
$cleanModel = preg_replace('#^models/#', '', $model);
|
||||
|
||||
// Helper om tekst uit Gemini candidate te extraheren
|
||||
$extractCandidateText = function(?array $candidate): string {
|
||||
if (!$candidate || empty($candidate['content']['parts'])) {
|
||||
return '';
|
||||
}
|
||||
$txt = '';
|
||||
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);
|
||||
};
|
||||
|
||||
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
||||
$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}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = $finalText;
|
||||
|
||||
// Indien Gemini het als markdown code block wrapped heeft (bv. ```json ... ```), schoon dit op
|
||||
if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $rawContent, $m)) {
|
||||
$cleanContent = trim($m[1]);
|
||||
} else {
|
||||
$cleanContent = $rawContent;
|
||||
}
|
||||
|
||||
$parsedJson = null;
|
||||
if ($jsonMode) {
|
||||
$parsedJson = json_decode($cleanContent, true);
|
||||
if ($parsedJson === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
// Poging 2: json decode met raw
|
||||
$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 [
|
||||
'success' => true,
|
||||
'content' => $cleanContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $successfulModel,
|
||||
'provider' => 'gemini',
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => ($promptTokens + $completionTokens),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met OpenAI
|
||||
*
|
||||
* @param array $messages
|
||||
* @param array $options
|
||||
* @param array $aiSettings
|
||||
* @return array
|
||||
*/
|
||||
function fociz_ai_chat_openai(array $messages, array $options, array $aiSettings): array {
|
||||
$apiKey = trim((string)($aiSettings['ai_api_key'] ?? ''));
|
||||
$model = !empty($aiSettings['ai_model']) ? trim((string)$aiSettings['ai_model']) : 'gpt-4o-mini';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = $options['max_tokens'] ?? 1500;
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen OpenAI API-sleutel ingevuld of geconfigureerd.',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
@@ -47,12 +440,21 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 35,
|
||||
'timeout' => 20,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
|
||||
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})");
|
||||
error_log("fociz_ai_chat fout: {$errMsg}");
|
||||
$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}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
@@ -66,11 +468,230 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
$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 [
|
||||
'success' => true,
|
||||
'content' => $rawContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $response['json']['model'] ?? $model,
|
||||
'provider' => 'openai',
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => ($promptTokens + $completionTokens),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoofdfunctie: Voert een gestructureerde chat completion uit
|
||||
* Routeert automatisch naar Google Gemini Pro of OpenAI en dwingt ALTIJD
|
||||
* de Zero-Data Leakage privacy firewall af.
|
||||
*
|
||||
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
||||
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int, 'user_id' => ?int]
|
||||
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string, 'provider' => ?string]
|
||||
*/
|
||||
function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
// 1. ABSOLUTE ZERO-DATA LEAKAGE PRIVACY CONTROLE:
|
||||
// Filter ALLE Jira ticket keys, Atlassian URLs, Teams links en Mail adressen/headers
|
||||
fociz_ai_enforce_privacy_firewall($messages);
|
||||
|
||||
$userId = $options['user_id'] ?? null;
|
||||
$aiSettings = get_ai_settings($userId);
|
||||
|
||||
if (empty($aiSettings['ai_api_key'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen AI API-sleutel geconfigureerd. Stel Google Gemini Pro of OpenAI in via Systeembeheer of je profiel.',
|
||||
];
|
||||
}
|
||||
|
||||
$provider = strtolower($aiSettings['ai_provider'] ?? 'gemini');
|
||||
$model = strtolower($aiSettings['ai_model'] ?? '');
|
||||
|
||||
// Routering: als provider gemini is of model gemini bevat, stuur naar Google Gemini
|
||||
if ($provider === 'gemini' || strpos($model, 'gemini') !== false) {
|
||||
$res = fociz_ai_chat_gemini($messages, $options, $aiSettings);
|
||||
} else {
|
||||
$res = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkt een brainstorm idee uit tot 3-5 concrete, behapbare items
|
||||
* voor een Kanban Bord, Sprint/Tijdlijn of Dagplanner.
|
||||
*
|
||||
* @param string $ideaContent
|
||||
* @param string $sessionTitle
|
||||
* @param string $theme
|
||||
* @param int|null $userId
|
||||
* @return array ['success' => bool, 'items' => array, 'error' => ?string]
|
||||
*/
|
||||
function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '', string $theme = '', ?int $userId = null): array {
|
||||
$systemPrompt = "Je bent een senior Agile/Scrum product specialist en technisch ontwerper.\n"
|
||||
. "Je taak is om een brainstorm idee of concept om te zetten in 3 tot 5 concrete, behapbare en direct uitvoerbare taken voor een agile team.\n"
|
||||
. "Elk item moet een heldere taak zijn die binnen één dag of sprintblok voltooid kan worden.\n\n"
|
||||
. "Formateer de uitvoer als een JSON-object met exact deze structuur:\n"
|
||||
. "{\n"
|
||||
. " \"summary\": \"Korte samenvatting van de aanpak (1 zin)\",\n"
|
||||
. " \"items\": [\n"
|
||||
. " {\n"
|
||||
. " \"title\": \"Actiegerichte titel (bijv: API endpoint ontwerpen voor authenticatie)\",\n"
|
||||
. " \"description\": \"Korte toelichting en 2 concrete acceptatiecriteria\",\n"
|
||||
. " \"estimated_minutes\": 45,\n"
|
||||
. " \"priority\": \"P1\",\n"
|
||||
. " \"color\": \"indigo\",\n"
|
||||
. " \"category\": \"Backend\"\n"
|
||||
. " }\n"
|
||||
. " ]\n"
|
||||
. "}\n\n"
|
||||
. "Regels:\n"
|
||||
. "- Prioriteit moet 'P1' (hoog/focus), 'P2' (medium) of 'P3' (laag) zijn.\n"
|
||||
. "- Kleur moet gekozen worden uit: 'indigo', 'blue', 'emerald', 'amber', 'rose', 'purple'.\n"
|
||||
. "- Duur in minuten: 15, 30, 45, 60, 90 of 120.\n"
|
||||
. "- Categorie: 'Frontend', 'Backend', 'Design', 'Onderzoek', of 'Testing'.\n"
|
||||
. "- Retourneer UITSCHLUITEND geldige JSON.";
|
||||
|
||||
$userPrompt = "Brainstorm Sessie: " . ($sessionTitle ?: 'Algemeen') . "\n"
|
||||
. "Thema: " . ($theme ?: 'Productontwikkeling') . "\n\n"
|
||||
. "Uit te werken idee:\n\"" . $ideaContent . "\"\n\n"
|
||||
. "Splits dit idee op in 3 tot 5 concrete items.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], [
|
||||
'json_mode' => true,
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 2000,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
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 [
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
$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 [
|
||||
'success' => true,
|
||||
'summary' => $summary ?: 'Dit idee is opgesplitst in behapbare deeltaken.',
|
||||
'items' => $items,
|
||||
'model' => $aiRes['model'] ?? 'Gemini Pro',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,7 +702,7 @@ function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
* - 'Oplossingen'
|
||||
* - 'Quick Wins'
|
||||
*/
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array {
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards, ?int $userId = null): array {
|
||||
if (empty($cards)) {
|
||||
return ['success' => true, 'categorized' => []];
|
||||
}
|
||||
@@ -109,7 +730,7 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
|
||||
@@ -121,28 +742,89 @@ function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $c
|
||||
/**
|
||||
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
||||
*/
|
||||
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): 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"
|
||||
. "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.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
|
||||
], ['json_mode' => true]);
|
||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën op basis van de sessie en context."],
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
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']];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = ''): array {
|
||||
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '', ?int $userId = null): array {
|
||||
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
|
||||
@@ -154,39 +836,72 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
|
||||
], ['json_mode' => true]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
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']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berekent een slimme dagindeling voor open taken rondom bestaande afspraken
|
||||
* Berekent een slimme dagindeling voor open taken rondom geanonimiseerde bezette tijdblokken.
|
||||
* ZERO-DATA LEAKAGE: Bevat NOOIT meetingtitels, inhoud of e-mailadressen!
|
||||
*/
|
||||
function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySettings): array {
|
||||
function fociz_ai_smart_schedule(array $tasks, array $busyBlocks, array $workdaySettings, ?int $userId = null): array {
|
||||
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
|
||||
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:30')[0];
|
||||
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:00')[0];
|
||||
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
|
||||
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
|
||||
|
||||
// Anonimiseer taken: stuur alleen ID, titel (gefilterd door firewall), prioriteit en duur
|
||||
$sanitizedTasks = array_map(fn($t) => [
|
||||
'id' => (int)$t['id'],
|
||||
'title' => (string)$t['title'],
|
||||
'priority' => $t['priority'] ?? 'P2',
|
||||
'duration_minutes' => (int)($t['duration_minutes'] ?? 30),
|
||||
], $tasks);
|
||||
|
||||
// Anonimiseer bezette blokken: GEEN titels of deelnemers, enkel tijdstippen!
|
||||
$sanitizedBusy = array_map(fn($b) => [
|
||||
'start' => $b['start'] ?? '09:00',
|
||||
'end' => $b['end'] ?? '10:00',
|
||||
], $busyBlocks);
|
||||
|
||||
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
|
||||
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
|
||||
. "Regels:\n"
|
||||
. "1. Plan GEEN taken tijdens bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
|
||||
. "1. Plan GEEN taken tijdens bezette blokken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
|
||||
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
|
||||
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
|
||||
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
|
||||
|
||||
$userPrompt = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n"
|
||||
. "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE);
|
||||
$userPrompt = "Bezette tijdsintervallen (agenda):\n" . json_encode($sanitizedBusy, JSON_UNESCAPED_UNICODE) . "\n\n"
|
||||
. "In te plannen taken:\n" . json_encode($sanitizedTasks, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true]);
|
||||
], ['json_mode' => true, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
|
||||
@@ -195,3 +910,74 @@ function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySett
|
||||
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een persoonlijke AI dagbriefing op basis van interne Fociz taken.
|
||||
* ZERO-DATA LEAKAGE: Absoluut GEEN Jira, Mail (Outlook) of Teams data!
|
||||
*
|
||||
* @param string $userName Naam van de gebruiker
|
||||
* @param string $date Datum (Y-m-d)
|
||||
* @param array $tasks Openstaande interne taken van vandaag
|
||||
* @param array $busyBlocks Geanonimiseerde bezette tijdsblokken (start/end)
|
||||
* @param array $unfinishedYest Gisteren niet voltooide taken
|
||||
* @param int|null $userId
|
||||
* @return array ['success' => bool, 'greeting' => string, 'focus_task' => string, 'tip' => string, 'warnings' => array]
|
||||
*/
|
||||
function fociz_ai_daily_briefing(
|
||||
string $userName,
|
||||
string $date,
|
||||
array $tasks,
|
||||
array $busyBlocks = [],
|
||||
array $unfinishedYest = [],
|
||||
?int $userId = null
|
||||
): array {
|
||||
$dutchDays = ['', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
||||
$dayName = $dutchDays[(int)date('N', strtotime($date))] ?? 'vandaag';
|
||||
$dateLabel = $dayName . ' ' . date('j', strtotime($date)) . ' ' . date('F', strtotime($date));
|
||||
|
||||
// Alleen pure Fociz taken samenvatten
|
||||
$taskSummary = array_map(fn($t) => [
|
||||
'title' => $t['title'],
|
||||
'priority' => $t['priority'] ?? 'P2',
|
||||
'time' => $t['time_block_start'] ?? null,
|
||||
'duration' => (int)($t['duration_minutes'] ?? 30),
|
||||
], array_slice($tasks, 0, 10));
|
||||
|
||||
$yesterdaySummary = array_map(fn($t) => $t['title'], array_slice($unfinishedYest, 0, 5));
|
||||
|
||||
$systemPrompt = "Je bent de persoonlijke AI Co-Pilot van {$userName} in Fociz. "
|
||||
. "Schrijf een korte, energieke en persoonlijke dagbriefing in het Nederlands voor {$dateLabel}. "
|
||||
. "Wees direct, concreet en vriendelijk. Gebruik geen markdown headers, geen bullet symbolen zoals *, -, •. "
|
||||
. "Geef 1-2 zinnen dagopening + 1 zin over de grootste prioriteit + 1 praktische focustip. "
|
||||
. "Maximaal 60 woorden totaal. "
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'greeting': openingszin (max 20 woorden)\n"
|
||||
. "- 'focus_task': de meest urgente taak vandaag (max 15 woorden)\n"
|
||||
. "- 'tip': één concrete focustip voor vandaag (max 20 woorden)\n"
|
||||
. "- 'warnings': array van strings, max 2 items (bijv. gisteren niet afgerond of overvolle dag)";
|
||||
|
||||
$userPrompt = json_encode([
|
||||
'date' => $dateLabel,
|
||||
'tasks_today' => $taskSummary,
|
||||
'busy_blocks_count' => count($busyBlocks),
|
||||
'unfinished_yesterday' => $yesterdaySummary,
|
||||
'total_tasks' => count($tasks),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true, 'temperature' => 0.6, 'max_tokens' => 300, 'user_id' => $userId]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen dagbriefing genereren'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'greeting' => $aiRes['json']['greeting'] ?? "Goedemorgen {$userName}, een nieuwe dag!",
|
||||
'focus_task' => $aiRes['json']['focus_task'] ?? '',
|
||||
'tip' => $aiRes['json']['tip'] ?? '',
|
||||
'warnings' => $aiRes['json']['warnings'] ?? [],
|
||||
'model' => $aiRes['model'] ?? 'AI',
|
||||
];
|
||||
}
|
||||
|
||||
+910
-15
File diff suppressed because it is too large
Load Diff
@@ -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!'
|
||||
];
|
||||
}
|
||||
|
||||
@@ -137,3 +137,24 @@ function fociz_cache_delete_pattern(string $prefix): bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert alle verlopen items actief uit de cache tabel (AVG/GDPR opschoning)
|
||||
*/
|
||||
function fociz_cache_cleanup_expired(?PDO $db = null): int {
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
ensure_app_cache_table($pdo);
|
||||
$stmt = $pdo->prepare("DELETE FROM app_cache WHERE expires_at < ?");
|
||||
$stmt->execute([time()]);
|
||||
return $stmt->rowCount();
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_cache_cleanup_expired error: " . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Probabilistische automatische GC (5% kans bij elke script execution)
|
||||
if (mt_rand(1, 20) === 1) {
|
||||
fociz_cache_cleanup_expired();
|
||||
}
|
||||
|
||||
@@ -14,13 +14,9 @@
|
||||
|
||||
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';
|
||||
|
||||
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
|
||||
$environment = fociz_env('APP_ENV');
|
||||
}
|
||||
|
||||
$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>
|
||||
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,49 @@
|
||||
<!-- Fociz Agenda Notificaties Engine -->
|
||||
<script src="/assets/js/notifications.js"></script>
|
||||
|
||||
<?php
|
||||
$sessionIdleTimeout = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
?>
|
||||
<!-- Fociz Inactiviteitswaarschuwing Modal (ISO 27001 / NIST SP 800-63B) -->
|
||||
<div id="focizSessionModal" class="fixed inset-0 z-[9999] bg-slate-950/80 backdrop-blur-md hidden items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-[#111622] border border-amber-500/30 dark:border-amber-500/30 rounded-3xl w-full max-w-md shadow-2xl p-6 sm:p-7 text-center relative overflow-hidden">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 dark:bg-amber-500/20 border border-amber-500/30 text-amber-600 dark:text-amber-400 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-7 h-7 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight mb-2">
|
||||
Sessie verloopt bijna
|
||||
</h3>
|
||||
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-5 leading-relaxed">
|
||||
Wegens inactiviteit wordt u binnenkort automatisch afgemeld conform het beveiligingsbeleid. Klik op <strong class="text-slate-800 dark:text-slate-200">Blijf Ingelogd</strong> om uw sessie te verlengen.
|
||||
</p>
|
||||
|
||||
<div class="inline-flex items-center space-x-2 px-4 py-2 rounded-2xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-700 dark:text-amber-300 font-mono text-xs font-bold mb-6">
|
||||
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke-width="2"></circle>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
<span>Verloopt over:</span>
|
||||
<span id="focizSessionCountdown" class="text-amber-600 dark:text-amber-400 font-black tracking-wider">02:00</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button type="button" id="focizSessionLogoutBtn" class="w-full py-2.5 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold transition cursor-pointer">
|
||||
Nu Uitloggen
|
||||
</button>
|
||||
<button type="button" id="focizSessionKeepaliveBtn" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
|
||||
Blijf Ingelogd
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fociz Session Guard Script (NIST SP 800-63B / ISO 27001) -->
|
||||
<script src="/assets/js/session-guard.js" data-timeout="<?= $sessionIdleTimeout ?>"></script>
|
||||
|
||||
<script>
|
||||
// Lucide Icons initialiseren
|
||||
if (typeof lucide !== 'undefined') {
|
||||
|
||||
+82
-7
@@ -7,6 +7,11 @@ require_once __DIR__ . '/auth.php';
|
||||
$currentUser = $currentUser ?? require_auth();
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
$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>
|
||||
<html lang="nl" class="dark">
|
||||
@@ -64,9 +69,12 @@ $currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUse
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
let hasRedirected = false;
|
||||
const sessionExpiresAt = <?= strtotime('today 23:59:59') ?> * 1000;
|
||||
const checkExpiry = function() {
|
||||
if (hasRedirected) return;
|
||||
if (Date.now() > sessionExpiresAt + 1000) {
|
||||
hasRedirected = true;
|
||||
window.location.href = '/auth/login.php?expired=1';
|
||||
}
|
||||
};
|
||||
@@ -146,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">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Brand Logo (met linker padding zodat het dev/live badge vrij staat) -->
|
||||
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
|
||||
<a href="/planner.php" class="flex items-center space-x-2.5 group">
|
||||
<!-- Brand Logo (uitgelijnd met de widgets / content container) -->
|
||||
<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 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">
|
||||
F
|
||||
</div>
|
||||
@@ -247,13 +255,80 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</nav>
|
||||
</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 -->
|
||||
<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 -->
|
||||
<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">
|
||||
<?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">
|
||||
<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');">
|
||||
<div class="hidden w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
@@ -272,7 +347,7 @@ require_once __DIR__ . '/env_badge.php';
|
||||
</a>
|
||||
|
||||
<!-- 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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -70,7 +70,8 @@ if (!function_exists('fociz_http_request')) {
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
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_HTTPHEADER, $headerLines);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
|
||||
|
||||
+658
-283
File diff suppressed because it is too large
Load Diff
@@ -218,23 +218,25 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = fociz_decrypt($integration['access_token']);
|
||||
$refreshToken = fociz_decrypt($integration['refresh_token'] ?? '');
|
||||
|
||||
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
||||
$expiresAt = (int)($settings['expires_at'] ?? 0);
|
||||
|
||||
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
|
||||
if (!$forceRefresh && $expiresAt > (time() + 240)) {
|
||||
return $integration['access_token'];
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
|
||||
$refreshToken = $integration['refresh_token'] ?? '';
|
||||
$appConfig = microsoft_get_app_config();
|
||||
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
|
||||
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
|
||||
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
|
||||
|
||||
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
|
||||
return !$forceRefresh ? ($integration['access_token'] ?? null) : null;
|
||||
return !$forceRefresh ? $accessToken : null;
|
||||
}
|
||||
|
||||
$cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl';
|
||||
@@ -264,15 +266,20 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st
|
||||
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]);
|
||||
$updateStmt->execute([
|
||||
fociz_encrypt($newAccessToken),
|
||||
fociz_encrypt($newRefreshToken),
|
||||
json_encode($settings),
|
||||
$integration['id']
|
||||
]);
|
||||
|
||||
return $newAccessToken;
|
||||
}
|
||||
|
||||
error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json'])));
|
||||
// Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken
|
||||
if (!empty($integration['access_token'])) {
|
||||
return $integration['access_token'];
|
||||
if (!empty($accessToken)) {
|
||||
return $accessToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -323,7 +330,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||
],
|
||||
'timeout' => 12,
|
||||
'timeout' => 6,
|
||||
]);
|
||||
|
||||
// Retry bij 401 Unauthorized
|
||||
@@ -338,7 +345,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||
],
|
||||
'timeout' => 12,
|
||||
'timeout' => 6,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -409,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;
|
||||
}
|
||||
|
||||
@@ -478,8 +485,24 @@ function microsoft_create_event(int $userId, array $eventData): array {
|
||||
}
|
||||
}
|
||||
|
||||
$isAllDay = !empty($eventData['is_all_day']);
|
||||
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 = [
|
||||
'subject' => $subject,
|
||||
@@ -495,8 +518,9 @@ function microsoft_create_event(int $userId, array $eventData): array {
|
||||
'dateTime' => $endDateTimeStr,
|
||||
'timeZone' => 'Europe/Amsterdam',
|
||||
],
|
||||
'showAs' => 'busy',
|
||||
'isReminderOn' => true,
|
||||
'isAllDay' => $isAllDay,
|
||||
'showAs' => $showAs,
|
||||
'isReminderOn' => $isReminderOn,
|
||||
'reminderMinutesBeforeStart' => 15,
|
||||
];
|
||||
|
||||
@@ -594,6 +618,12 @@ function microsoft_update_event(int $userId, string $eventId, array $eventData):
|
||||
if (!empty($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');
|
||||
$startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00';
|
||||
@@ -910,19 +940,164 @@ function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate,
|
||||
/**
|
||||
* 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();
|
||||
$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 [
|
||||
[
|
||||
'id' => 'demo-mail-1',
|
||||
'folder_id' => 'inbox',
|
||||
'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_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_preview' => 'Beste collega, we hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de UWV REST-endpoints en de validatie van werknemersdossiers...',
|
||||
'body_html' => '<p>Beste collega,</p><p>We hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de <strong>UWV REST-endpoints</strong> en de validatie van werknemersdossiers.</p><p>Zou je kunnen verifiëren of onze SSO-koppeling en de datadump export hier al op aansluiten? Het zou fijn zijn als we dit vanmiddag kort kunnen afstemmen in de planning.</p><p>Met vriendelijke groet,<br><strong>Directie UWV Koppelingen & Integraties</strong></p>',
|
||||
'from_name' => 'Directie UWV Integraties',
|
||||
'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' => [],
|
||||
'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',
|
||||
'is_read' => false,
|
||||
'has_attachments' => true,
|
||||
@@ -932,14 +1107,15 @@ function microsoft_get_mail_demo_data(): array {
|
||||
],
|
||||
[
|
||||
'id' => 'demo-mail-2',
|
||||
'folder_id' => 'inbox',
|
||||
'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_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_email' => 'facturatie@fociz.nl',
|
||||
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
|
||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||
'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',
|
||||
'is_read' => false,
|
||||
'has_attachments' => false,
|
||||
@@ -949,14 +1125,15 @@ function microsoft_get_mail_demo_data(): array {
|
||||
],
|
||||
[
|
||||
'id' => 'demo-mail-3',
|
||||
'folder_id' => 'inbox',
|
||||
'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_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_email' => 'infra@fociz.nl',
|
||||
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
|
||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||
'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',
|
||||
'is_read' => true,
|
||||
'has_attachments' => true,
|
||||
@@ -966,6 +1143,7 @@ function microsoft_get_mail_demo_data(): array {
|
||||
],
|
||||
[
|
||||
'id' => 'demo-mail-4',
|
||||
'folder_id' => 'inbox',
|
||||
'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_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>',
|
||||
@@ -973,7 +1151,7 @@ function microsoft_get_mail_demo_data(): array {
|
||||
'from_email' => 'product@fociz.nl',
|
||||
'to_recipients' => [['name' => 'Alle Medewerkers', 'address' => 'team@fociz.nl']],
|
||||
'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',
|
||||
'is_read' => true,
|
||||
'has_attachments' => false,
|
||||
@@ -983,14 +1161,15 @@ function microsoft_get_mail_demo_data(): array {
|
||||
],
|
||||
[
|
||||
'id' => 'demo-mail-5',
|
||||
'folder_id' => 'inbox',
|
||||
'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_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_email' => 'no-reply@fociz.nl',
|
||||
'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
|
||||
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
|
||||
'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',
|
||||
'is_read' => true,
|
||||
'has_attachments' => false,
|
||||
@@ -1002,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 {
|
||||
$cacheKey = "ms_inbox_{$userId}_{$limit}_" . ($unreadOnly ? '1' : '0');
|
||||
function microsoft_get_disabled_folders(int $userId): array {
|
||||
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) {
|
||||
$cached = fociz_cache_get($cacheKey);
|
||||
if ($cached !== null && is_array($cached)) {
|
||||
@@ -1020,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']);
|
||||
if (!$isLive) {
|
||||
$demo = microsoft_get_mail_demo_data();
|
||||
$demo = microsoft_get_mail_demo_data($cleanFolderId);
|
||||
if ($unreadOnly) {
|
||||
$demo = array_values(array_filter($demo, function($m) { return empty($m['is_read']); }));
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'is_demo' => true,
|
||||
'folder_id' => $cleanFolderId,
|
||||
'messages' => array_slice($demo, 0, $limit),
|
||||
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
|
||||
'total_count' => count($demo),
|
||||
@@ -1038,6 +1542,7 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
|
||||
'folder_id' => $cleanFolderId,
|
||||
'messages' => [],
|
||||
'unread_count' => 0,
|
||||
];
|
||||
@@ -1053,7 +1558,8 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
||||
$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, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
@@ -1061,20 +1567,21 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.body-content-type="text"',
|
||||
],
|
||||
'timeout' => 15,
|
||||
'timeout' => 6,
|
||||
]);
|
||||
|
||||
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
|
||||
$graphError = $res['json']['error']['message'] ?? ($res['error'] ?? 'HTTP ' . $res['status']);
|
||||
$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);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'is_demo' => true,
|
||||
'folder_id' => $cleanFolderId,
|
||||
'messages' => array_slice($demo, 0, $limit),
|
||||
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
|
||||
'total_count' => count($demo),
|
||||
@@ -1123,6 +1630,7 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
||||
|
||||
$messages[] = [
|
||||
'id' => $msg['id'] ?? '',
|
||||
'folder_id' => $cleanFolderId,
|
||||
'subject' => $msg['subject'] ?: '(Geen onderwerp)',
|
||||
'body_preview' => $msg['bodyPreview'] ?? '',
|
||||
'from_name' => $fromName,
|
||||
@@ -1148,6 +1656,7 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
||||
$result = [
|
||||
'success' => true,
|
||||
'is_demo' => false,
|
||||
'folder_id' => $cleanFolderId,
|
||||
'messages' => $messages,
|
||||
'unread_count' => $unreadCount,
|
||||
'total_count' => count($messages),
|
||||
@@ -1156,6 +1665,13 @@ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unre
|
||||
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
|
||||
*/
|
||||
|
||||
+65
-14
@@ -35,15 +35,26 @@ class FocizMailer {
|
||||
$logs[] = date('H:i:s') . ' ' . $msg;
|
||||
};
|
||||
|
||||
// Saniteer e-mailadressen en headers tegen CRLF en SMTP command injection
|
||||
$toEmail = str_replace(["\r", "\n", "\0"], '', trim($toEmail));
|
||||
$toName = str_replace(["\r", "\n", "\0"], '', trim($toName));
|
||||
$subject = str_replace(["\r", "\n", "\0"], '', trim($subject));
|
||||
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$err = "Ongeldig e-mailadres voor verzending: {$toEmail}";
|
||||
$log("FOUT: {$err}");
|
||||
return ['success' => false, 'error' => $err, 'logs' => $logs];
|
||||
}
|
||||
|
||||
// 1. Configuratieparameters ophalen
|
||||
$host = $options['host'] ?? (defined('SMTP_HOST') ? SMTP_HOST : 'mail.fociz.nl');
|
||||
$port = (int)($options['port'] ?? (defined('SMTP_PORT') ? SMTP_PORT : 587));
|
||||
$username = $options['username'] ?? (defined('SMTP_USER') ? SMTP_USER : 'info@fociz.nl');
|
||||
$password = $options['password'] ?? (defined('SMTP_PASS') ? SMTP_PASS : '');
|
||||
$secure = strtolower($options['secure'] ?? (defined('SMTP_SECURE') ? SMTP_SECURE : 'tls'));
|
||||
$fromEmail = $options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl');
|
||||
$fromName = $options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz');
|
||||
$replyTo = $options['reply_to'] ?? (defined('SMTP_REPLY_TO') ? SMTP_REPLY_TO : 'info@fociz.nl');
|
||||
$fromEmail = str_replace(["\r", "\n", "\0"], '', trim($options['from_email'] ?? (defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : 'no-reply@fociz.nl')));
|
||||
$fromName = str_replace(["\r", "\n", "\0"], '', trim($options['from_name'] ?? (defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'Fociz')));
|
||||
$replyTo = str_replace(["\r", "\n", "\0"], '', trim($options['reply_to'] ?? (defined('SMTP_REPLY_TO') ? SMTP_REPLY_TO : 'info@fociz.nl')));
|
||||
$timeout = (int)($options['timeout'] ?? 15);
|
||||
|
||||
// Technische afzender voor de SMTP envelope (moet doorgaans overeenkomen met het geauthenticeerde account)
|
||||
@@ -120,15 +131,58 @@ class FocizMailer {
|
||||
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
||||
}
|
||||
|
||||
// 4. EHLO Handshake
|
||||
$clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl';
|
||||
$resp = $sendCommand("EHLO {$clientHost}");
|
||||
if ($resp['code'] !== 250) {
|
||||
$resp = $sendCommand("HELO {$clientHost}");
|
||||
if ($resp['code'] !== 250) {
|
||||
throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}");
|
||||
// 4. EHLO Handshake conform RFC 5321 §4.1.4
|
||||
// Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
|
||||
// Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
|
||||
$resolveHeloHost = function() use ($host): string {
|
||||
// 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
|
||||
if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
|
||||
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')
|
||||
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
||||
@@ -154,10 +208,7 @@ class FocizMailer {
|
||||
$log("TLS encryptie succesvol geactiveerd.");
|
||||
|
||||
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
||||
$resp = $sendCommand("EHLO {$clientHost}");
|
||||
if ($resp['code'] !== 250) {
|
||||
throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}");
|
||||
}
|
||||
$clientHost = $performHeloHandshake($clientHost);
|
||||
}
|
||||
|
||||
// 6. SMTP Authenticatie
|
||||
|
||||
+224
-11
@@ -45,8 +45,17 @@ function get_all_system_settings(bool $forceRefresh = false): array {
|
||||
$stmt = $db->query("SELECT setting_key, setting_value FROM system_settings");
|
||||
while ($row = $stmt->fetch()) {
|
||||
$val = $row['setting_value'];
|
||||
if ($val === null) {
|
||||
$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) {
|
||||
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) {
|
||||
$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,32 +105,233 @@ function set_system_setting(string $key, $value): bool {
|
||||
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
|
||||
*/
|
||||
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 [
|
||||
'workday_start' => get_system_setting('workday_start', '08:30'),
|
||||
'workday_end' => get_system_setting('workday_end', '17:30'),
|
||||
'workday_end' => get_system_setting('workday_end', '17:00'),
|
||||
'lunch_start' => get_system_setting('lunch_start', '12:30'),
|
||||
'lunch_end' => get_system_setting('lunch_end', '13:00'),
|
||||
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
|
||||
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
|
||||
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
|
||||
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
|
||||
'workdays' => $workdays,
|
||||
'workdays_str' => implode(',', $workdays),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt AI configuratie-instellingen op (Release v1.5)
|
||||
* Haalt AI configuratie-instellingen op (Release v2.0 - Google Gemini Pro & OpenAI)
|
||||
* Ondersteunt organisatiebrede en optioneel persoonlijke sleutels.
|
||||
*/
|
||||
function get_ai_settings(): array {
|
||||
$envKey = fociz_env('OPENAI_API_KEY', '');
|
||||
$savedKey = get_system_setting('ai_api_key', '');
|
||||
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
|
||||
function get_ai_settings(?int $userId = null): array {
|
||||
if (!function_exists('fociz_decrypt')) {
|
||||
require_once __DIR__ . '/auth.php';
|
||||
}
|
||||
|
||||
$apiKey = '';
|
||||
$isPersonal = false;
|
||||
$personalProvider = null;
|
||||
$personalModel = null;
|
||||
|
||||
// 1. Controleer persoonlijke sleutel van de ingelogde gebruiker
|
||||
if ($userId !== null && $userId > 0) {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("SELECT access_token, settings, is_enabled FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') AND is_enabled = 1 ORDER BY id DESC LIMIT 1");
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
// Sleutel kan in access_token staan of in settings['api_key']
|
||||
if (!empty($row['access_token'])) {
|
||||
$apiKey = fociz_decrypt((string)$row['access_token']);
|
||||
$isPersonal = true;
|
||||
} elseif (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['api_key'])) {
|
||||
$apiKey = fociz_decrypt((string)$userSettings['api_key']);
|
||||
$isPersonal = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($row['settings'])) {
|
||||
$userSettings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($userSettings['provider'])) {
|
||||
$personalProvider = (string)$userSettings['provider'];
|
||||
}
|
||||
if (!empty($userSettings['model'])) {
|
||||
$personalModel = (string)$userSettings['model'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("Fout bij ophalen persoonlijke AI sleutel: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback naar organisatiebrede instellingen
|
||||
if (empty($apiKey)) {
|
||||
$savedKeyRaw = get_system_setting('ai_api_key', '');
|
||||
if (!empty($savedKeyRaw)) {
|
||||
$apiKey = fociz_decrypt((string)$savedKeyRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback naar .env variabelen (GEMINI_API_KEY of OPENAI_API_KEY)
|
||||
if (empty($apiKey)) {
|
||||
$apiKey = (string)(fociz_env('GEMINI_API_KEY', '') ?: fociz_env('OPENAI_API_KEY', ''));
|
||||
}
|
||||
|
||||
$provider = !empty($personalProvider) ? $personalProvider : get_system_setting('ai_provider', 'gemini');
|
||||
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 [
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)),
|
||||
'ai_provider' => get_system_setting('ai_provider', 'openai'),
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', $hasKey),
|
||||
'ai_provider' => (string)$provider,
|
||||
'ai_api_key' => $apiKey,
|
||||
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
|
||||
'has_key' => !empty($apiKey),
|
||||
'ai_model' => (string)$model,
|
||||
'has_key' => $hasKey,
|
||||
'is_personal_key'=> $isPersonal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt geconfigureerde sessie idle timeout in seconden op conform NIST SP 800-63B / ISO 27001
|
||||
* Toegestane waarden: 900 (15m), 1800 (30m - default), 3600 (60m), 7200 (120m)
|
||||
*/
|
||||
function get_session_idle_timeout(): int {
|
||||
$timeout = (int)get_system_setting('session_idle_timeout', 1800);
|
||||
$validTimeouts = [900, 1800, 3600, 7200];
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+4
-6
@@ -107,14 +107,12 @@ class TOTP {
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
|
||||
* Genereert een veilige client-side QR code (geen externe API calls meer ter bescherming van TOTP secrets)
|
||||
*/
|
||||
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
|
||||
return sprintf(
|
||||
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
|
||||
urlencode($otpAuthUrl),
|
||||
$size
|
||||
);
|
||||
// Externe API (quickchart.io) verwijderd om te voorkomen dat 2FA secrets uitlekken naar derden.
|
||||
// QR rendering gebeurt nu 100% lokaal via /assets/js/qrcode.min.js in de browser.
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5187
-308
File diff suppressed because it is too large
Load Diff
+444
-93
@@ -25,6 +25,10 @@ if (!empty($_GET['success'])) {
|
||||
|
||||
// POST Verwerking VÓÓR headers zodat redirects en mutaties soepel verlopen
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$message = "Beveiligingsfout: CSRF-token ongeldig of verlopen. Ververs de pagina en probeer opnieuw.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// 1. Outlook koppelen via OAuth
|
||||
@@ -79,7 +83,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
if (($jiraToken === '' || $jiraToken === '••••••••••••••••') && !empty($existingSettings['jira_api_token']) && $existingSettings['jira_api_token'] !== '••••••••••••••••') {
|
||||
$jiraToken = $existingSettings['jira_api_token'];
|
||||
$jiraToken = fociz_decrypt($existingSettings['jira_api_token']);
|
||||
}
|
||||
|
||||
$accountName = $existingSettings['account_name'] ?? '';
|
||||
@@ -104,7 +108,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$newSettings = [
|
||||
'jira_domain' => $jiraDomain,
|
||||
'jira_email' => $jiraEmail,
|
||||
'jira_api_token' => $jiraToken,
|
||||
'jira_api_token' => fociz_encrypt($jiraToken),
|
||||
'account_name' => $accountName,
|
||||
'account_id' => $accountId,
|
||||
];
|
||||
@@ -128,6 +132,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4b. Persoonlijke Google Gemini / AI instellingen opslaan
|
||||
elseif ($action === 'save_personal_ai') {
|
||||
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
||||
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
|
||||
$aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
|
||||
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
|
||||
|
||||
// Haal eventueel bestaande integratie op
|
||||
$stmtEx = $db->prepare("SELECT id, access_token, settings FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') LIMIT 1");
|
||||
$stmtEx->execute([$userId]);
|
||||
$existing = $stmtEx->fetch();
|
||||
|
||||
$tokenToSave = $existing['access_token'] ?? null;
|
||||
if ($apiKeyIn !== '' && $apiKeyIn !== '••••••••••••••••') {
|
||||
$tokenToSave = fociz_encrypt($apiKeyIn);
|
||||
}
|
||||
|
||||
$settingsJson = json_encode([
|
||||
'provider' => $aiProvider,
|
||||
'model' => $aiModel,
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$stmtUp = $db->prepare("UPDATE integrations SET provider = 'GEMINI', is_enabled = ?, access_token = ?, settings = ?, last_sync_at = NOW() WHERE id = ?");
|
||||
$stmtUp->execute([$aiEnabled, $tokenToSave, $settingsJson, $existing['id']]);
|
||||
} else {
|
||||
$stmtIn = $db->prepare("INSERT INTO integrations (user_id, provider, is_enabled, access_token, settings, last_sync_at) VALUES (?, 'GEMINI', ?, ?, ?, NOW())");
|
||||
$stmtIn->execute([$userId, $aiEnabled, $tokenToSave, $settingsJson]);
|
||||
}
|
||||
|
||||
$message = "Uw persoonlijke AI instellingen zijn succesvol opgeslagen.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 4c. Persoonlijke AI sleutel ontkoppelen / verwijderen
|
||||
elseif ($action === 'disconnect_personal_ai') {
|
||||
$stmtDel = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI')");
|
||||
$stmtDel->execute([$userId]);
|
||||
$message = "Uw persoonlijke AI sleutel is ontkoppeld. Fociz gebruikt nu de centrale instellingen van de organisatie.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 5. Notificatievoorkeuren opslaan
|
||||
elseif ($action === 'save_notifications') {
|
||||
$settings = [
|
||||
@@ -171,21 +217,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!password_verify($currentPassword, $storedHash)) {
|
||||
$message = "Uw huidige wachtwoord is niet correct ingevoerd.";
|
||||
$messageType = 'error';
|
||||
} elseif (strlen($newPassword) < 8) {
|
||||
$message = "Het nieuwe wachtwoord moet minimaal 8 tekens lang zijn.";
|
||||
} elseif (($pwErr = fociz_validate_password($newPassword)) !== null) {
|
||||
$message = $pwErr;
|
||||
$messageType = 'error';
|
||||
} elseif ($newPassword !== $confirmPassword) {
|
||||
$message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
|
||||
$stmtUp->execute([$newHash, $userId]);
|
||||
$message = "Uw wachtwoord is succesvol gewijzigd.";
|
||||
|
||||
// SOC 2 CC6.1 / ISO 27001 A.9.4.2: Trek overige sessies in bij wachtwoordwijziging
|
||||
fociz_revoke_other_sessions($userId, session_id());
|
||||
|
||||
fociz_log_audit($userId, 'PASSWORD_CHANGED', 'user', $userId, ['method' => 'profile'], 'SUCCESS', $user['email']);
|
||||
$message = "Uw wachtwoord is succesvol gewijzigd. Alle overige sessies op andere apparaten zijn uit veiligheidsoverwegingen beëindigd.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
// 7b. Alle overige sessies op afstand beëindigen (SOC 2 / ISO 27001)
|
||||
elseif ($action === 'revoke_other_sessions') {
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
$message = "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 8. Standaard avatar kiezen uit bibliotheek
|
||||
elseif ($action === 'select_preset_avatar') {
|
||||
$preset = trim($_POST['avatar_preset'] ?? '');
|
||||
@@ -214,43 +271,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$message = "De afbeelding is te groot (maximaal 4MB toegestaan).";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mime, $allowedTypes)) {
|
||||
$message = "Alleen JPG, PNG, WebP of GIF afbeeldingen zijn toegestaan.";
|
||||
$processed = fociz_process_avatar_upload($file, $userId);
|
||||
if (!$processed['success']) {
|
||||
$message = $processed['error'] ?? "Fout bij verwerken van de afbeelding.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$ext = match($mime) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
default => 'jpg'
|
||||
};
|
||||
|
||||
$uploadDir = __DIR__ . '/assets/uploads/avatars';
|
||||
if (!is_dir($uploadDir)) {
|
||||
@mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$filename = "avatar_{$userId}_" . time() . "_" . bin2hex(random_bytes(4)) . ".{$ext}";
|
||||
$targetPath = $uploadDir . '/' . $filename;
|
||||
|
||||
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
$webPath = "/assets/uploads/avatars/{$filename}";
|
||||
$avatarUri = $processed['data_uri'];
|
||||
$stmtUp = $db->prepare("UPDATE users SET avatar_url = ? WHERE id = ?");
|
||||
$stmtUp->execute([$webPath, $userId]);
|
||||
$_SESSION['user_avatar_url'] = $webPath;
|
||||
$user['avatar_url'] = $webPath;
|
||||
$message = "Uw profielfoto is succesvol geüpload en ingesteld!";
|
||||
$stmtUp->execute([$avatarUri, $userId]);
|
||||
$_SESSION['user_avatar_url'] = $avatarUri;
|
||||
$user['avatar_url'] = $avatarUri;
|
||||
$message = "Uw profielfoto is succesvol geüpload en permanent bewaard!";
|
||||
$messageType = 'success';
|
||||
} else {
|
||||
$message = "Er is een fout opgetreden bij het verplaatsen van het geüploade bestand.";
|
||||
$messageType = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,12 +297,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$message = "Uw avatar is teruggezet naar de standaard weergave met uw initialen.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actuele gegevens ophalen
|
||||
$stmtUser = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
$stmtUser->execute([$userId]);
|
||||
$userProfile = $stmtUser->fetch();
|
||||
if ($userProfile) {
|
||||
$userProfile['avatar_url'] = fociz_get_valid_avatar_url($userProfile['avatar_url'] ?? null, $userId);
|
||||
}
|
||||
|
||||
// Outlook status ophalen
|
||||
$stmtOutlook = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
@@ -294,9 +330,20 @@ $appConfig = microsoft_get_app_config();
|
||||
// Notificatie-instellingen
|
||||
$notifSettings = get_user_notification_settings($userId);
|
||||
|
||||
// AI Co-Pilot & Google Gemini instellingen voor gebruiker
|
||||
$userAiSettings = get_ai_settings($userId);
|
||||
$stmtAiInt = $db->prepare("SELECT id, access_token, is_enabled, settings FROM integrations WHERE user_id = ? AND provider IN ('GEMINI', 'AI') LIMIT 1");
|
||||
$stmtAiInt->execute([$userId]);
|
||||
$userAiIntRow = $stmtAiInt->fetch();
|
||||
$hasPersonalAiKey = !empty($userAiIntRow['access_token']);
|
||||
$userAiRowSettings = json_decode($userAiIntRow['settings'] ?? '{}', true) ?: [];
|
||||
|
||||
// Thema ophalen
|
||||
$userTheme = get_user_theme($userId);
|
||||
|
||||
// Actieve apparaten & sessies ophalen (SOC 2 / ISO 27001)
|
||||
$activeSessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
|
||||
$pageTitle = 'Mijn Profiel & Koppelingen — Fociz';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
@@ -310,7 +357,10 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<div class="flex items-center space-x-4 sm:space-x-5">
|
||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 shadow-sm flex-shrink-0 overflow-hidden">
|
||||
<?php if (!empty($userProfile['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($userProfile['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-2xl object-cover bg-white dark:bg-[#0c0f17]">
|
||||
<img src="<?= htmlspecialchars($userProfile['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-2xl object-cover bg-white dark:bg-[#0c0f17]" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
|
||||
@@ -524,6 +574,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php if (!empty($userProfile['avatar_url'])): ?>
|
||||
<form method="POST" action="/profile.php" class="self-start sm:self-auto">
|
||||
<input type="hidden" name="action" value="remove_avatar">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" onclick="return confirm('Avatar herstellen naar standaard initiaal-badge?')" class="inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800/50 hover:bg-rose-100 dark:hover:bg-rose-900/50 transition cursor-pointer">
|
||||
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
|
||||
<span>Standaard Herstellen</span>
|
||||
@@ -532,61 +583,51 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<!-- Linker kolom: 24 Standaard Preset Avatars (8 cols) -->
|
||||
<div class="lg:col-span-8 space-y-3">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<!-- Avatar Selector Grid & Upload -->
|
||||
<div class="mt-6 grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
<!-- Linker kolom: 24 mini karakters in grid (8 cols) -->
|
||||
<div class="lg:col-span-8 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||
<span>Kies een Karakter (24 Flitsende Avatars)</span>
|
||||
<span>Kies een Karakter (24 Stijlen)</span>
|
||||
</h3>
|
||||
<span class="text-[11px] text-slate-400 dark:text-slate-500">Klik om direct toe te passen</span>
|
||||
|
||||
<!-- Categorie Filters -->
|
||||
<div class="flex items-center space-x-1 bg-slate-100 dark:bg-[#0c0f17] p-1 rounded-xl border border-slate-200 dark:border-slate-800 text-[11px]" id="avatarFilterBar">
|
||||
<button type="button" onclick="filterAvatars('all')" class="avatar-filter-btn active px-2.5 py-1 rounded-lg font-medium transition cursor-pointer" data-cat="all">Alles</button>
|
||||
<button type="button" onclick="filterAvatars('pro')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="pro">Pro</button>
|
||||
<button type="button" onclick="filterAvatars('creative')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="creative">Creatief</button>
|
||||
<button type="button" onclick="filterAvatars('tech')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="tech">Tech</button>
|
||||
<button type="button" onclick="filterAvatars('heroes')" class="avatar-filter-btn px-2.5 py-1 rounded-lg font-medium transition text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer" data-cat="heroes">Heroes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categorie Filter Pills -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<button type="button" onclick="filterAvatarCategory('all')" id="avCatBtn-all" class="av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-bold transition cursor-pointer bg-indigo-600 text-white shadow-xs">
|
||||
Alle (24)
|
||||
</button>
|
||||
<button type="button" onclick="filterAvatarCategory('emoji')" id="avCatBtn-emoji" class="av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-semibold transition cursor-pointer bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300">
|
||||
⚡ Fun & Emojis (6)
|
||||
</button>
|
||||
<button type="button" onclick="filterAvatarCategory('bots')" id="avCatBtn-bots" class="av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-semibold transition cursor-pointer bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300">
|
||||
🤖 Cyber & Bots (6)
|
||||
</button>
|
||||
<button type="button" onclick="filterAvatarCategory('personas')" id="avCatBtn-personas" class="av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-semibold transition cursor-pointer bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300">
|
||||
✨ Moderne Personas (6)
|
||||
</button>
|
||||
<button type="button" onclick="filterAvatarCategory('heroes')" id="avCatBtn-heroes" class="av-cat-btn px-2.5 py-1 rounded-lg text-[11px] font-semibold transition cursor-pointer bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300">
|
||||
🎯 Adventurer & Heroes (6)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-3 p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2.5 p-3 rounded-2xl bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800" id="avatarGrid">
|
||||
<?php
|
||||
$avatarPresets = [
|
||||
['file' => 'avatar-01.svg', 'cat' => 'emoji', 'name' => 'Cool Star (Zonnebril)'],
|
||||
['file' => 'avatar-02.svg', 'cat' => 'emoji', 'name' => 'Party Champion'],
|
||||
['file' => 'avatar-03.svg', 'cat' => 'emoji', 'name' => 'Celebration Vibe'],
|
||||
['file' => 'avatar-04.svg', 'cat' => 'emoji', 'name' => 'Laser Eyes Energy'],
|
||||
['file' => 'avatar-05.svg', 'cat' => 'emoji', 'name' => 'Sunny Smiles'],
|
||||
['file' => 'avatar-06.svg', 'cat' => 'emoji', 'name' => 'Fire Heart'],
|
||||
['file' => 'avatar-07.svg', 'cat' => 'bots', 'name' => 'Cyber Tron'],
|
||||
['file' => 'avatar-08.svg', 'cat' => 'bots', 'name' => 'Aero Bot'],
|
||||
['file' => 'avatar-09.svg', 'cat' => 'bots', 'name' => 'Neon Volt'],
|
||||
['file' => 'avatar-10.svg', 'cat' => 'bots', 'name' => 'Pixel Pulse'],
|
||||
['file' => 'avatar-11.svg', 'cat' => 'bots', 'name' => 'Quantum AI'],
|
||||
['file' => 'avatar-12.svg', 'cat' => 'bots', 'name' => 'Robo Racer'],
|
||||
['file' => 'avatar-13.svg', 'cat' => 'personas', 'name' => 'Alex (Hipster)'],
|
||||
['file' => 'avatar-14.svg', 'cat' => 'personas', 'name' => 'Maya (Tech Lead)'],
|
||||
['file' => 'avatar-15.svg', 'cat' => 'personas', 'name' => 'Lucas (Designer)'],
|
||||
['file' => 'avatar-16.svg', 'cat' => 'personas', 'name' => 'Elena (Developer)'],
|
||||
['file' => 'avatar-17.svg', 'cat' => 'personas', 'name' => 'Sam (Creative)'],
|
||||
['file' => 'avatar-18.svg', 'cat' => 'personas', 'name' => 'Zoe (Founder)'],
|
||||
['file' => 'avatar-19.svg', 'cat' => 'heroes', 'name' => 'Jack (Adventurer)'],
|
||||
['file' => 'avatar-20.svg', 'cat' => 'heroes', 'name' => 'Luna (Heroine)'],
|
||||
['file' => 'avatar-21.svg', 'cat' => 'heroes', 'name' => 'Oliver (Explorer)'],
|
||||
['file' => 'avatar-22.svg', 'cat' => 'heroes', 'name' => 'Mia (Rebel)'],
|
||||
['file' => 'avatar-01.svg', 'cat' => 'pro', 'name' => 'Alex (Business)'],
|
||||
['file' => 'avatar-02.svg', 'cat' => 'pro', 'name' => 'Sophie (Corporate)'],
|
||||
['file' => 'avatar-03.svg', 'cat' => 'pro', 'name' => 'Liam (Consultant)'],
|
||||
['file' => 'avatar-04.svg', 'cat' => 'pro', 'name' => 'Emma (Manager)'],
|
||||
['file' => 'avatar-05.svg', 'cat' => 'pro', 'name' => 'Daan (Director)'],
|
||||
['file' => 'avatar-06.svg', 'cat' => 'pro', 'name' => 'Noa (Executive)'],
|
||||
['file' => 'avatar-07.svg', 'cat' => 'creative', 'name' => 'Mila (Designer)'],
|
||||
['file' => 'avatar-08.svg', 'cat' => 'creative', 'name' => 'Lucas (Creator)'],
|
||||
['file' => 'avatar-09.svg', 'cat' => 'creative', 'name' => 'Zara (Artist)'],
|
||||
['file' => 'avatar-10.svg', 'cat' => 'creative', 'name' => 'Finn (Illustrator)'],
|
||||
['file' => 'avatar-11.svg', 'cat' => 'creative', 'name' => 'Lotte (Writer)'],
|
||||
['file' => 'avatar-12.svg', 'cat' => 'creative', 'name' => 'Sem (Architect)'],
|
||||
['file' => 'avatar-13.svg', 'cat' => 'tech', 'name' => 'Bram (Developer)'],
|
||||
['file' => 'avatar-14.svg', 'cat' => 'tech', 'name' => 'Julia (Engineer)'],
|
||||
['file' => 'avatar-15.svg', 'cat' => 'tech', 'name' => 'Jesse (DevOps)'],
|
||||
['file' => 'avatar-16.svg', 'cat' => 'tech', 'name' => 'Tess (Data Scientist)'],
|
||||
['file' => 'avatar-17.svg', 'cat' => 'tech', 'name' => 'Milan (Cyber Sec)'],
|
||||
['file' => 'avatar-18.svg', 'cat' => 'tech', 'name' => 'Liv (AI Engineer)'],
|
||||
['file' => 'avatar-19.svg', 'cat' => 'heroes', 'name' => 'Max (Ninja)'],
|
||||
['file' => 'avatar-20.svg', 'cat' => 'heroes', 'name' => 'Sara (Astronaut)'],
|
||||
['file' => 'avatar-21.svg', 'cat' => 'heroes', 'name' => 'Robin (Pilot)'],
|
||||
['file' => 'avatar-22.svg', 'cat' => 'heroes', 'name' => 'Eva (Explorer)'],
|
||||
['file' => 'avatar-23.svg', 'cat' => 'heroes', 'name' => 'Leo (Cosmic)'],
|
||||
['file' => 'avatar-24.svg', 'cat' => 'heroes', 'name' => 'Nova (Cyberpunk)'],
|
||||
];
|
||||
@@ -597,6 +638,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<form method="POST" action="/profile.php" class="relative group avatar-item" data-category="<?= $av['cat'] ?>">
|
||||
<input type="hidden" name="action" value="select_preset_avatar">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<input type="hidden" name="avatar_preset" value="<?= $avatarPath ?>">
|
||||
<button type="submit" class="w-full aspect-square rounded-2xl p-1 transition-all duration-200 transform hover:scale-110 active:scale-95 hover:shadow-lg hover:shadow-indigo-500/25 focus:outline-none cursor-pointer <?= $isSelected ? 'ring-2 ring-indigo-600 dark:ring-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 shadow-sm' : 'hover:bg-white dark:hover:bg-slate-800 border border-transparent' ?>" title="<?= htmlspecialchars($av['name']) ?>">
|
||||
<img src="<?= $avatarPath ?>" alt="<?= htmlspecialchars($av['name']) ?>" class="w-full h-full rounded-xl object-contain pointer-events-none drop-shadow-xs">
|
||||
@@ -620,6 +662,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" action="/profile.php" enctype="multipart/form-data" class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-4">
|
||||
<input type="hidden" name="action" value="upload_custom_avatar">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="text-center p-5 border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-xl hover:border-indigo-500 dark:hover:border-indigo-400 transition bg-white dark:bg-[#111622]">
|
||||
<div class="w-10 h-10 mx-auto mb-2 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 flex items-center justify-center">
|
||||
@@ -674,6 +717,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</a>
|
||||
<form method="POST" onsubmit="return confirm('Weet u zeker dat u uw Microsoft Outlook agenda wilt ontkoppelen?');">
|
||||
<input type="hidden" name="action" value="disconnect_outlook">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
|
||||
Ontkoppelen
|
||||
</button>
|
||||
@@ -703,6 +747,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="mt-4 p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<input type="hidden" name="action" value="save_outlook_settings">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<div class="space-y-1">
|
||||
<label for="sync_interval_seconds" class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
|
||||
<i data-lucide="refresh-cw" class="w-3.5 h-3.5 text-sky-500"></i>
|
||||
@@ -752,6 +797,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<div>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="connect_outlook">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="inline-flex items-center space-x-2 px-6 py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition shadow-xs cursor-pointer">
|
||||
<i data-lucide="log-in" class="w-4 h-4"></i>
|
||||
<span>Koppel Mijn Outlook Agenda</span>
|
||||
@@ -802,6 +848,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<?php if ($isJiraConnected): ?>
|
||||
<form method="POST" onsubmit="return confirm('Weet u zeker dat u Jira wilt ontkoppelen?');">
|
||||
<input type="hidden" name="action" value="disconnect_jira">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
|
||||
Jira Ontkoppelen
|
||||
</button>
|
||||
@@ -811,6 +858,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="action" value="save_jira">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -840,7 +888,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</a>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="password" id="jiraApiTokenInput" name="jira_api_token" value="<?= !empty($jiraSettings['jira_api_token']) ? htmlspecialchars($jiraSettings['jira_api_token']) : '' ?>" placeholder="Plak hier uw Atlassian API token..." class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-10 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="password" id="jiraApiTokenInput" name="jira_api_token" value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : '' ?>" placeholder="Plak hier uw Atlassian API token..." class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-10 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<button type="button" onclick="toggleJiraToken()" id="toggleJiraTokenBtn" title="Toon / verberg token" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
|
||||
<i data-lucide="eye" class="w-4 h-4" id="eyeJiraIcon"></i>
|
||||
</button>
|
||||
@@ -864,6 +912,138 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 2b: Mijn AI Co-Pilot & Google Gemini Pro -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
|
||||
<i data-lucide="sparkles" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">AI Co-Pilot & Google Gemini Pro</h2>
|
||||
<?php if ($hasPersonalAiKey): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-500/15 border border-indigo-200 dark:border-indigo-500/30 text-indigo-700 dark:text-indigo-400 text-[10px] font-semibold">
|
||||
Eigen Sleutel
|
||||
</span>
|
||||
<?php elseif (!empty($userAiSettings['has_key'])): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold">
|
||||
Organisatiesleutel Actief
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-amber-700 dark:text-amber-400 text-[10px] font-semibold">
|
||||
Geen Sleutel
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Koppel uw eigen Google Gemini Pro account of gebruik de centrale organisatieconfiguratie</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/60 text-xs font-bold flex items-center space-x-1">
|
||||
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
|
||||
<span>Zero-Data Leakage Actief</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Shield Banner -->
|
||||
<div class="mt-6 p-3.5 rounded-2xl bg-emerald-50/70 dark:bg-emerald-950/20 border border-emerald-200/70 dark:border-emerald-800/40 flex items-start space-x-3">
|
||||
<div class="w-7 h-7 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="shield-check" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<strong class="text-emerald-900 dark:text-emerald-200 font-bold block">100% Zero-Data Leakage Garantie</strong>
|
||||
<p class="text-emerald-800/80 dark:text-emerald-300/80 text-[11px] mt-0.5">
|
||||
Uw gevoelige gegevens uit <strong>Jira tickets</strong>, <strong>Outlook e-mails</strong> en <strong>Microsoft Teams</strong> worden strikt afgeschermd en <strong>NOOIT</strong> naar Google Gemini of OpenAI verstuurd.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profile.php" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="action" value="save_personal_ai">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="p-3.5 rounded-2xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
|
||||
<label class="flex items-center space-x-2.5 text-xs text-slate-900 dark:text-white cursor-pointer select-none">
|
||||
<input type="checkbox" name="ai_enabled" value="1" <?= (!isset($userAiIntRow['is_enabled']) || !empty($userAiIntRow['is_enabled'])) ? 'checked' : '' ?> class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
|
||||
<span class="font-semibold">AI Co-Pilot assistentie inschakelen voor mijn account</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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">
|
||||
<optgroup label="Google Gemini (Aanbevolen)">
|
||||
<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-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 label="OpenAI">
|
||||
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
||||
<option value="gpt-4o" <?= $userAiSettings['ai_model'] === 'gpt-4o' ? 'selected' : '' ?>>OpenAI GPT-4o</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<input type="hidden" name="ai_provider" id="profile_ai_provider" value="<?= strpos($userAiSettings['ai_model'], 'gpt') !== false ? 'openai' : 'gemini' ?>">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Persoonlijke Google Gemini API Sleutel
|
||||
<span class="text-[11px] font-normal text-slate-500 dark:text-slate-400">(Optioneel)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="ai_api_key"
|
||||
id="profile_ai_api_key"
|
||||
value="<?= $hasPersonalAiKey ? '••••••••••••••••' : '' ?>"
|
||||
placeholder="<?= $userAiSettings['has_key'] ? 'Gebruikt organisatie-sleutel (leeg laten)' : 'AIzaSy... (Google AI Studio)' ?>"
|
||||
class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-9 py-2.5 text-xs text-slate-900 dark:text-white font-mono placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<button type="button" onclick="togglePasswordVisibility('profile_ai_api_key', this)" class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-1">
|
||||
Gratis sleutel aanmaken? <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">Google AI Studio →</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Test Resultaat Alert in Profiel -->
|
||||
<div id="profileAiTestResult" class="hidden p-3 rounded-xl text-xs"></div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button type="button" onclick="testPersonalAiConnection()" id="profileAiTestBtn" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-xs font-semibold transition cursor-pointer flex items-center space-x-2">
|
||||
<i data-lucide="activity" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||
<span id="profileAiTestBtnText">Test Verbinding</span>
|
||||
</button>
|
||||
|
||||
<?php if ($hasPersonalAiKey): ?>
|
||||
<button type="submit" form="disconnectPersonalAiForm" class="px-3 py-2 rounded-xl text-xs text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition cursor-pointer font-medium">
|
||||
Persoonlijke Sleutel Verwijderen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs cursor-pointer">
|
||||
AI Instellingen Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($hasPersonalAiKey): ?>
|
||||
<form method="POST" id="disconnectPersonalAiForm" class="hidden">
|
||||
<input type="hidden" name="action" value="disconnect_personal_ai">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 3: Mijn Meldingen & Agenda Push Notificaties -->
|
||||
<div class="fociz-card p-6 sm:p-7">
|
||||
<div class="flex items-center space-x-3.5 pb-6 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -912,6 +1092,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<input type="hidden" name="action" value="save_notifications">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="flex items-center justify-between p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
|
||||
<div>
|
||||
@@ -1021,6 +1202,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<input type="hidden" name="action" value="update_profile">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
@@ -1054,6 +1236,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" class="space-y-3">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
@@ -1064,16 +1247,17 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Nieuw Wachtwoord (min. 8 tekens)
|
||||
Nieuw Wachtwoord (min. 12 tekens)
|
||||
</label>
|
||||
<input type="password" name="new_password" required minlength="8" 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 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="password" name="new_password" required minlength="12" placeholder="Minimaal 12 tekens" 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 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-1">Minimaal 12 tekens (incl. hoofdletter, kleine letter, cijfer en speciaal teken).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Bevestig Nieuw Wachtwoord
|
||||
</label>
|
||||
<input type="password" name="confirm_password" required minlength="8" 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 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="password" name="confirm_password" required minlength="12" placeholder="Herhaal nieuw wachtwoord" 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 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
@@ -1084,6 +1268,72 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actieve Apparaten & Sessies Card (SOC 2 CC6.1 / ISO 27001 A.9.4.2) -->
|
||||
<div class="fociz-card p-6 sm:p-7 space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs flex-shrink-0">
|
||||
<i data-lucide="laptop" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Actieve Apparaten & Sessies</h3>
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/60 text-emerald-700 dark:text-emerald-300 font-semibold text-[10px]">
|
||||
<?= count($activeSessions) ?> <?= count($activeSessions) === 1 ? 'actieve sessie' : 'actieve sessies' ?>
|
||||
</span>
|
||||
</div>
|
||||
<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 (maximaal 20 actieve sessies).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($activeSessions) > 1): ?>
|
||||
<form method="POST" action="/profile.php" onsubmit="return confirm('Weet u zeker dat u alle andere actieve sessies op afstand wilt beëindigen? U blijft op dit apparaat ingelogd.')">
|
||||
<input type="hidden" name="action" value="revoke_other_sessions">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="inline-flex items-center space-x-2 px-3.5 py-2 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/30 dark:hover:bg-rose-900/40 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/60 text-xs font-semibold transition cursor-pointer">
|
||||
<i data-lucide="log-out" class="w-3.5 h-3.5"></i>
|
||||
<span>Overal Uitloggen (Overige Sessies)</span>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<?php foreach ($activeSessions as $s): ?>
|
||||
<div class="p-3.5 rounded-2xl <?= $s['is_current'] ? 'bg-indigo-50/40 dark:bg-indigo-950/20 border-indigo-200/80 dark:border-indigo-800/50' : 'bg-slate-50/70 dark:bg-[#0c0f17]/60 border-slate-200 dark:border-slate-800' ?> border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-9 h-9 rounded-xl <?= $s['is_current'] ? 'bg-indigo-600 text-white shadow-xs' : 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400' ?> flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="<?= stripos($s['device_name'] ?? '', 'Phone') !== false || stripos($s['device_name'] ?? '', 'iPhone') !== false || stripos($s['device_name'] ?? '', 'Android') !== false ? 'smartphone' : 'monitor' ?>" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($s['device_name'] ?: 'Onbekend apparaat') ?></span>
|
||||
<?php if ($s['is_current']): ?>
|
||||
<span class="px-2 py-0.5 rounded-md bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800/60 text-[10px] font-bold">
|
||||
Dit apparaat (Huidig)
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 flex items-center space-x-2 mt-0.5">
|
||||
<span class="font-mono"><?= htmlspecialchars($s['ip_address']) ?></span>
|
||||
<span>•</span>
|
||||
<span>Laatst actief: <?= date('d-m-Y H:i', strtotime($s['last_activity_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!$s['is_current']): ?>
|
||||
<button type="button" onclick="revokeSessionById(<?= (int)$s['id'] ?>)" class="self-end sm:self-center px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-rose-50 dark:bg-slate-800 dark:hover:bg-rose-950/30 text-slate-600 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400 text-xs font-semibold transition border border-slate-200 dark:border-slate-700 hover:border-rose-200 dark:hover:border-rose-800 cursor-pointer">
|
||||
Sessie Beëindigen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -1144,6 +1394,22 @@ function previewUploadAvatar(input) {
|
||||
nameEl.textContent = '📄 ' + file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||
nameEl.classList.remove('hidden');
|
||||
}
|
||||
// Live thumbnail weergave
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
let prevImg = document.getElementById('uploadAvatarLivePreview');
|
||||
if (!prevImg && nameEl && nameEl.parentNode) {
|
||||
prevImg = document.createElement('img');
|
||||
prevImg.id = 'uploadAvatarLivePreview';
|
||||
prevImg.className = 'w-16 h-16 rounded-full mx-auto my-2 object-cover border-2 border-indigo-500 shadow-sm';
|
||||
nameEl.parentNode.insertBefore(prevImg, nameEl);
|
||||
}
|
||||
if (prevImg) {
|
||||
prevImg.src = e.target.result;
|
||||
prevImg.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,8 +1434,20 @@ function filterAvatarCategory(category) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync banner status met browser permissie & activeer actuele thema kaart
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Model en provider sync
|
||||
const profileModelSelect = document.getElementById('profile_ai_model');
|
||||
const profileProviderInput = document.getElementById('profile_ai_provider');
|
||||
if (profileModelSelect && profileProviderInput) {
|
||||
profileModelSelect.addEventListener('change', function() {
|
||||
if (this.value.indexOf('gemini') !== -1) {
|
||||
profileProviderInput.value = 'gemini';
|
||||
} else {
|
||||
profileProviderInput.value = 'openai';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiseer geselecteerde thema-kaart
|
||||
const activeTheme = localStorage.getItem('fociz_theme') || '<?= $userTheme ?>' || 'dark';
|
||||
updateProfileThemeUI(activeTheme);
|
||||
@@ -1194,6 +1472,79 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (deniedEl) deniedEl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function testPersonalAiConnection() {
|
||||
const btn = document.getElementById('profileAiTestBtn');
|
||||
const btnText = document.getElementById('profileAiTestBtnText');
|
||||
const resultDiv = document.getElementById('profileAiTestResult');
|
||||
const apiKey = document.getElementById('profile_ai_api_key').value.trim();
|
||||
const model = document.getElementById('profile_ai_model').value;
|
||||
const provider = document.getElementById('profile_ai_provider').value;
|
||||
|
||||
resultDiv.classList.add('hidden');
|
||||
resultDiv.className = 'hidden p-3 rounded-xl text-xs';
|
||||
btn.disabled = true;
|
||||
btnText.innerText = 'Testen...';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
provider: provider,
|
||||
model: model,
|
||||
};
|
||||
// Stuur alleen de ingevulde sleutel mee als het geen placeholder bolletjes zijn
|
||||
if (apiKey && apiKey !== '••••••••••••••••') {
|
||||
payload.api_key = apiKey;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/ai.php?action=test_connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
if (data.success) {
|
||||
resultDiv.className = 'p-3 rounded-xl text-xs bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200 flex items-center space-x-2';
|
||||
resultDiv.innerHTML = `<i data-lucide="check-circle" class="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0"></i><span><strong>Verbinding Geslaagd!</strong> ${data.message}</span>`;
|
||||
} else {
|
||||
resultDiv.className = 'p-3 rounded-xl text-xs bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 flex items-center space-x-2';
|
||||
resultDiv.innerHTML = `<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400 flex-shrink-0"></i><span><strong>Verbinding Mislukt:</strong> ${data.error || 'Onbekende fout'}</span>`;
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (err) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'p-3 rounded-xl text-xs bg-rose-50 dark:bg-rose-950/30 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-200 flex items-center space-x-2';
|
||||
resultDiv.innerHTML = `<i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 dark:text-rose-400 flex-shrink-0"></i><span><strong>Netwerkfout:</strong> ${err.message}</span>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.innerText = 'Test Verbinding';
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSessionById(sessionId) {
|
||||
if (!confirm('Weet u zeker dat u deze sessie op afstand wilt beëindigen?')) return;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'revoke_session');
|
||||
formData.append('session_id', sessionId);
|
||||
formData.append('csrf_token', '<?= htmlspecialchars(get_csrf_token()) ?>');
|
||||
|
||||
const res = await fetch('/api/session.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Fout bij beëindigen van sessie.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Netwerkfout bij beëindigen van sessie: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
+638
-4
@@ -38,6 +38,21 @@ try {
|
||||
}
|
||||
|
||||
$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">
|
||||
@@ -64,13 +79,21 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
|
||||
<!-- Filters: Quarters & Status -->
|
||||
<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">
|
||||
<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
|
||||
</a>
|
||||
<?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 ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
@@ -108,7 +131,23 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
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 -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
@@ -206,7 +245,7 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
|
||||
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div id="newItemModal" 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 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">
|
||||
@@ -280,6 +319,237 @@ $quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
</button>
|
||||
</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>
|
||||
@@ -334,6 +604,370 @@ async function submitManualItem() {
|
||||
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) => {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('newItemModal');
|
||||
if (modal && !modal.classList.contains('hidden')) modal.classList.add('hidden');
|
||||
closeEditRoadmapModal();
|
||||
closeRoadmapScheduleModal();
|
||||
closeRoadmapContextMenu();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
+291
-11
@@ -71,9 +71,7 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
|
||||
Beheerder
|
||||
</span>
|
||||
</div>
|
||||
<p id="boardDescriptionText" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Gantt-planning, mijlpalen & datumbereiken
|
||||
</p>
|
||||
<p id="boardDescriptionText" class="hidden text-xs text-slate-500 dark:text-slate-400 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,13 +162,18 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
|
||||
<!-- 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">
|
||||
|
||||
<!-- 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="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 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 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>
|
||||
|
||||
<!-- Inline Snelle Taakinvoer: ⚡ Wat moet er gedaan worden? (Exact zoals Jira) -->
|
||||
@@ -224,7 +227,7 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
|
||||
</div>
|
||||
|
||||
<!-- Gedeelde MODAL 1: Taak Toevoegen of Bewerken (100% gesynchroniseerd met Kanban bord) -->
|
||||
<div id="cardModal" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div id="cardModal" onclick="if(event.target === this) closeCardModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="fociz-card p-6 w-full max-w-xl shadow-2xl space-y-5 my-8">
|
||||
|
||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800/80">
|
||||
@@ -348,6 +351,25 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -388,6 +410,26 @@ html.dark #timelineGridWrapper::-webkit-scrollbar-thumb {
|
||||
</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>
|
||||
// --- Globale Status voor Tijdlijn ---
|
||||
let currentBoardId = null;
|
||||
@@ -490,7 +532,14 @@ async function loadBoardData(boardId) {
|
||||
if (data.success && data.has_board) {
|
||||
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
|
||||
const badge = document.getElementById('boardRoleBadge');
|
||||
@@ -528,6 +577,60 @@ function populateLaneFilter() {
|
||||
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() {
|
||||
@@ -668,10 +771,31 @@ function renderTimeline() {
|
||||
const laneDotColor = LANE_COLORS[card.lane_color] || 'bg-slate-500';
|
||||
const hasSchedule = (card.start_date && card.end_date);
|
||||
|
||||
// 1. Linkerpaneel Item
|
||||
// 1. Linkerpaneel Item (draggable naar lane drop zones)
|
||||
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.onclick = () => openCardModal(card.id);
|
||||
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.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
|
||||
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.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
|
||||
rightRow.onclick = (e) => {
|
||||
if (e.target.closest('.timeline-bar')) return;
|
||||
@@ -728,6 +857,10 @@ function renderTimeline() {
|
||||
barEl.style.left = `${leftPx}px`;
|
||||
barEl.style.width = `${widthPx}px`;
|
||||
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) => {
|
||||
ev.stopPropagation();
|
||||
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
|
||||
function enableBarDragging(barEl, cardId, originalStart, originalEnd) {
|
||||
let isDragging = false;
|
||||
@@ -944,13 +1100,14 @@ function openCardModal(cardId = null) {
|
||||
if (hasSched) {
|
||||
document.getElementById('cardStartDate').value = foundCard.start_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 {
|
||||
titleEl.innerText = 'Nieuwe Taak Toevoegen';
|
||||
deleteBtn.classList.add('hidden');
|
||||
document.getElementById('cardScheduleToggle').checked = false;
|
||||
document.getElementById('cardSyncOutlook').checked = true;
|
||||
toggleScheduleFields(false);
|
||||
}
|
||||
|
||||
@@ -1073,6 +1230,129 @@ function escapeHtml(str) {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// --- 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) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeCardModal();
|
||||
closeTimelineContextMenu();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
@@ -13,8 +13,14 @@ $message = null;
|
||||
$generatedLink = null;
|
||||
$error = null;
|
||||
|
||||
// Uitnodiging aanmaken
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
|
||||
// POST acties afhandelen met CSRF-bescherming
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$csrfToken = $_POST['csrf_token'] ?? null;
|
||||
|
||||
if (!verify_csrf_token($csrfToken)) {
|
||||
$error = "Beveiligingsfout: CSRF-token ongeldig of verlopen. Ververs de pagina en probeer opnieuw.";
|
||||
} elseif ($action === 'create_invite') {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
|
||||
|
||||
@@ -33,6 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
|
||||
// Verstuur de gestylede uitnodigingsmail direct via SMTP
|
||||
$mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink);
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_INVITED', 'invite', null, ['email' => $email, 'role' => $role], 'SUCCESS', $adminUser['email']);
|
||||
if ($mailResult['success']) {
|
||||
$message = "Uitnodigingslink succesvol aangemaakt én per e-mail verzonden naar {$email}!";
|
||||
} else {
|
||||
@@ -40,11 +47,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uitnodiging opnieuw verzenden per e-mail
|
||||
if (isset($_GET['resend_invite'])) {
|
||||
$inviteId = (int)$_GET['resend_invite'];
|
||||
} elseif ($action === 'resend_invite') {
|
||||
$inviteId = (int)($_POST['invite_id'] ?? 0);
|
||||
$stmtInv = $db->prepare("SELECT * FROM invites WHERE id = ? AND used_at IS NULL AND expires_at > NOW()");
|
||||
$stmtInv->execute([$inviteId]);
|
||||
$inv = $stmtInv->fetch();
|
||||
@@ -52,6 +56,7 @@ if (isset($_GET['resend_invite'])) {
|
||||
$baseUrl = rtrim(APP_URL, '/');
|
||||
$link = "{$baseUrl}/auth/invite.php?token={$inv['token']}";
|
||||
$mailResult = FocizMailer::sendInvitationEmail($inv['email'], $adminUser['name'], $inv['role'], $link);
|
||||
fociz_log_audit((int)$adminUser['id'], 'INVITE_RESENT', 'invite', $inviteId, ['email' => $inv['email'], 'role' => $inv['role']], 'SUCCESS', $adminUser['email']);
|
||||
if ($mailResult['success']) {
|
||||
$message = "Uitnodiging succesvol opnieuw per e-mail verzonden naar {$inv['email']}!";
|
||||
} else {
|
||||
@@ -60,26 +65,31 @@ if (isset($_GET['resend_invite'])) {
|
||||
} else {
|
||||
$error = "Uitnodiging niet gevonden of reeds verlopen/geactiveerd.";
|
||||
}
|
||||
}
|
||||
|
||||
// Uitnodiging intrekken
|
||||
if (isset($_GET['delete_invite'])) {
|
||||
$inviteId = (int)$_GET['delete_invite'];
|
||||
} elseif ($action === 'delete_invite') {
|
||||
$inviteId = (int)($_POST['invite_id'] ?? 0);
|
||||
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
|
||||
$stmtDel->execute([$inviteId]);
|
||||
fociz_log_audit((int)$adminUser['id'], 'INVITE_REVOKED', 'invite', $inviteId, [], 'SUCCESS', $adminUser['email']);
|
||||
header('Location: /users.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Gebruiker verwijderen
|
||||
if (isset($_GET['delete_user'])) {
|
||||
$delUserId = (int)$_GET['delete_user'];
|
||||
} elseif ($action === 'delete_user') {
|
||||
$delUserId = (int)($_POST['user_id'] ?? 0);
|
||||
if ($delUserId !== (int)$adminUser['id']) {
|
||||
$stmtUserToDel = $db->prepare("SELECT email, name FROM users WHERE id = ?");
|
||||
$stmtUserToDel->execute([$delUserId]);
|
||||
$delUser = $stmtUserToDel->fetch();
|
||||
|
||||
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
|
||||
$stmtDelUser->execute([$delUserId]);
|
||||
try {
|
||||
$db->prepare("DELETE FROM user_sessions WHERE user_id = ?")->execute([$delUserId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_DELETED', 'user', $delUserId, ['deleted_email' => $delUser['email'] ?? null, 'deleted_name' => $delUser['name'] ?? null], 'SUCCESS', $adminUser['email']);
|
||||
}
|
||||
header('Location: /users.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Haal alle gebruikers op
|
||||
@@ -119,7 +129,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
Team & Uitnodigingen
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -158,6 +168,7 @@ require_once __DIR__ . '/includes/header.php';
|
||||
|
||||
<form method="POST" action="/users.php" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
|
||||
<input type="hidden" name="action" value="create_invite">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
|
||||
<div class="md:col-span-6">
|
||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres van collega</label>
|
||||
@@ -208,7 +219,10 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 overflow-hidden shadow-2xs">
|
||||
<?php if (!empty($u['avatar_url'])): ?>
|
||||
<img src="<?= htmlspecialchars($u['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[10px] object-cover bg-white dark:bg-slate-900">
|
||||
<img src="<?= htmlspecialchars($u['avatar_url']) ?>" alt="Avatar" class="w-full h-full rounded-[10px] object-cover bg-white dark:bg-slate-900" onerror="this.classList.add('hidden'); if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');">
|
||||
<div class="hidden w-full h-full rounded-[10px] bg-white dark:bg-slate-900 flex items-center justify-center font-bold text-xs text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($u['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="w-full h-full rounded-[10px] bg-white dark:bg-slate-900 flex items-center justify-center font-bold text-xs text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($u['name'], 0, 1)) ?>
|
||||
@@ -237,9 +251,14 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</td>
|
||||
<td class="p-3 text-right">
|
||||
<?php if ($u['id'] !== $adminUser['id']): ?>
|
||||
<a href="/users.php?delete_user=<?= $u['id'] ?>" onclick="return confirm('Gebruiker verwijderen?')" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
|
||||
<form method="POST" action="/users.php" class="inline" onsubmit="return confirm('Weet u zeker dat u deze gebruiker wilt verwijderen?')">
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Gebruiker verwijderen" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer bg-transparent border-0">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="text-[10px] text-slate-400 dark:text-slate-600 font-medium">Uzelf</span>
|
||||
<?php endif; ?>
|
||||
@@ -281,7 +300,11 @@ require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
|
||||
<td class="p-3 font-mono text-slate-900 dark:text-white"><?= htmlspecialchars($inv['email']) ?></td>
|
||||
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $inv['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
|
||||
<?= $inv['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<?php if ($isUsed): ?>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60">Geaccepteerd</span>
|
||||
@@ -295,14 +318,24 @@ require_once __DIR__ . '/includes/header.php';
|
||||
<td class="p-3 text-right">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<?php if (!$isUsed && !$isExpired): ?>
|
||||
<a href="/users.php?resend_invite=<?= $inv['id'] ?>" title="Uitnodigingsmail opnieuw verzenden" class="inline-flex items-center space-x-1 px-2 py-1 bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/40 dark:hover:bg-indigo-900/60 text-indigo-600 dark:text-indigo-400 rounded-lg text-[11px] font-medium transition cursor-pointer">
|
||||
<form method="POST" action="/users.php" class="inline">
|
||||
<input type="hidden" name="action" value="resend_invite">
|
||||
<input type="hidden" name="invite_id" value="<?= (int)$inv['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Uitnodigingsmail opnieuw verzenden" class="inline-flex items-center space-x-1 px-2 py-1 bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/40 dark:hover:bg-indigo-900/60 text-indigo-600 dark:text-indigo-400 rounded-lg text-[11px] font-medium transition cursor-pointer border-0">
|
||||
<i data-lucide="mail" class="w-3.5 h-3.5"></i>
|
||||
<span>Opnieuw mailen</span>
|
||||
</a>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" title="Uitnodiging intrekken" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
|
||||
<form method="POST" action="/users.php" class="inline" onsubmit="return confirm('Weet u zeker dat u deze uitnodiging wilt intrekken?')">
|
||||
<input type="hidden" name="action" value="delete_invite">
|
||||
<input type="hidden" name="invite_id" value="<?= (int)$inv['id'] ?>">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" title="Uitnodiging intrekken" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer bg-transparent border-0">
|
||||
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
|
||||
</a>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user