diff --git a/api/planner.php b/api/planner.php
index 24a2108..74489c3 100644
--- a/api/planner.php
+++ b/api/planner.php
@@ -4,6 +4,7 @@
*/
require_once __DIR__ . '/../includes/auth.php';
+require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
$db = get_db_connection();
@@ -36,8 +37,11 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
$title = trim($data['title'] ?? '');
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
- $timeBlockStart = $data['time_block_start'] ?? null;
- $date = $data['date'] ?? $today;
+ $timeBlockStart = !empty($data['time_block_start']) ? trim($data['time_block_start']) : null;
+ $durationMinutes = (int)($data['duration_minutes'] ?? 30);
+ if ($durationMinutes <= 0) $durationMinutes = 30;
+ $date = !empty($data['date']) ? trim($data['date']) : $today;
+ $syncOutlook = !empty($data['sync_to_outlook']);
if (empty($title)) {
echo json_encode(['error' => 'Titel is verplicht']);
@@ -54,14 +58,44 @@ try {
$planId = $db->lastInsertId();
$source = !empty($data['source']) ? substr(trim($data['source']), 0, 50) : 'FOCIZ';
- $externalId = !empty($data['external_id']) ? substr(trim($data['external_id']), 0, 100) : null;
+ $externalId = !empty($data['external_id']) ? substr(trim($data['external_id']), 0, 255) : null;
$externalUrl = !empty($data['external_url']) ? trim($data['external_url']) : null;
+ $timeBlockEnd = null;
+
+ // Als er een tijdblok is en de gebruiker heeft synchronisatie naar Outlook gekozen (of source is OUTLOOK)
+ if (($syncOutlook || $source === 'OUTLOOK') && !empty($timeBlockStart) && empty($externalId)) {
+ $msResult = microsoft_create_event($user['id'], [
+ 'subject' => $title,
+ 'description' => "Gepland via Fociz Dagplanning ({$priority})",
+ 'date' => $date,
+ 'start_time' => $timeBlockStart,
+ 'duration_minutes' => $durationMinutes,
+ ]);
+
+ if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
+ $source = 'OUTLOOK';
+ $externalId = $msResult['event_id'];
+ $externalUrl = $msResult['web_link'] ?? 'https://outlook.office.com';
+ $timeBlockEnd = $msResult['end_time'] ?? null;
+ }
+ }
+
+ // Bereken timeBlockEnd indien nog niet gezet maar wel starttijd bekend
+ if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
+ try {
+ $dtEnd = new DateTime("{$date} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
+ $dtEnd->modify("+{$durationMinutes} minutes");
+ $timeBlockEnd = $dtEnd->format('H:i');
+ } catch (Throwable $e) {
+ // negeer
+ }
+ }
$stmt = $db->prepare("
- INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, status, source, external_id, external_url)
- VALUES (?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
+ 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', ?, ?, ?)
");
- $stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $source, $externalId, $externalUrl]);
+ $stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl]);
$taskId = (int)$db->lastInsertId();
echo json_encode([
@@ -71,14 +105,102 @@ try {
'title' => $title,
'priority' => $priority,
'time_block_start' => $timeBlockStart,
+ 'time_block_end' => $timeBlockEnd,
+ 'duration_minutes' => $durationMinutes,
'is_completed' => 0,
- 'status' => 'TODO'
- ]
+ 'status' => 'TODO',
+ 'source' => $source,
+ 'external_id' => $externalId,
+ 'external_url' => $externalUrl,
+ ],
+ 'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)),
]);
exit;
}
- // 3. Taak status toggelen (voltooid / open)
+ // 3. Bestaande taak handmatig naar Microsoft Outlook synchroniseren
+ if ($action === 'sync_task_to_outlook') {
+ $data = json_decode(file_get_contents('php://input'), true);
+ $taskId = (int)($data['task_id'] ?? 0);
+
+ if (!$taskId) {
+ echo json_encode(['error' => 'Geen taak-ID opgegeven']);
+ exit;
+ }
+
+ // Haal taak inclusief plan datum op
+ $stmtTask = $db->prepare("
+ SELECT t.*, dp.plan_date
+ FROM tasks t
+ LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id
+ WHERE t.id = ? AND t.user_id = ?
+ ");
+ $stmtTask->execute([$taskId, $user['id']]);
+ $task = $stmtTask->fetch();
+
+ if (!$task) {
+ echo json_encode(['error' => 'Taak niet gevonden']);
+ exit;
+ }
+
+ // Als al gesynchroniseerd
+ if (!empty($task['external_id']) && $task['source'] === 'OUTLOOK') {
+ echo json_encode([
+ 'success' => true,
+ 'already_synced' => true,
+ 'external_id' => $task['external_id'],
+ 'external_url' => $task['external_url']
+ ]);
+ exit;
+ }
+
+ $planDate = $task['plan_date'] ?: $today;
+ $startTime = !empty($task['time_block_start']) ? $task['time_block_start'] : ($data['time_block_start'] ?? '09:00');
+ $duration = (int)($task['duration_minutes'] ?: 30);
+
+ $msResult = microsoft_create_event($user['id'], [
+ 'subject' => $task['title'],
+ 'description' => "Gepland via Fociz Dagplanning (" . $task['priority'] . ")",
+ 'date' => $planDate,
+ 'start_time' => $startTime,
+ 'duration_minutes' => $duration,
+ ]);
+
+ if (empty($msResult['success']) || empty($msResult['event_id'])) {
+ echo json_encode(['error' => $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook']);
+ exit;
+ }
+
+ // Sla Outlook ID en bron op in de taak
+ $stmtUp = $db->prepare("
+ UPDATE tasks
+ SET source = 'OUTLOOK',
+ external_id = ?,
+ external_url = ?,
+ time_block_start = COALESCE(time_block_start, ?),
+ time_block_end = ?
+ WHERE id = ? AND user_id = ?
+ ");
+ $stmtUp->execute([
+ $msResult['event_id'],
+ $msResult['web_link'] ?? 'https://outlook.office.com',
+ $msResult['start_time'],
+ $msResult['end_time'],
+ $taskId,
+ $user['id']
+ ]);
+
+ echo json_encode([
+ 'success' => true,
+ 'external_id' => $msResult['event_id'],
+ 'external_url' => $msResult['web_link'] ?? 'https://outlook.office.com',
+ 'start_time' => $msResult['start_time'],
+ 'end_time' => $msResult['end_time'],
+ ]);
+ exit;
+ }
+
+ // 4. Taak status toggelen (voltooid / open)
if ($action === 'toggle_task') {
$data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0);
@@ -95,11 +217,24 @@ try {
exit;
}
- // 4. Taak verwijderen
+ // 5. Taak verwijderen (verwijder ook eventueel gekoppelde Outlook afspraak)
if ($action === 'delete_task') {
$data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0);
+ // Haal eerst op of deze taak aan Outlook was gekoppeld
+ $stmtCheck = $db->prepare("SELECT external_id, source FROM tasks WHERE id = ? AND user_id = ?");
+ $stmtCheck->execute([$taskId, $user['id']]);
+ $taskToDelete = $stmtCheck->fetch();
+
+ if ($taskToDelete && !empty($taskToDelete['external_id']) && $taskToDelete['source'] === 'OUTLOOK') {
+ try {
+ microsoft_delete_event($user['id'], $taskToDelete['external_id']);
+ } catch (Throwable $t) {
+ error_log("Fout bij verwijderen Outlook afspraak voor taak {$taskId}: " . $t->getMessage());
+ }
+ }
+
$stmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$stmt->execute([$taskId, $user['id']]);
diff --git a/config/database.php b/config/database.php
index e4caf0a..4469844 100644
--- a/config/database.php
+++ b/config/database.php
@@ -118,7 +118,12 @@ function ensure_tables_exist(PDO $pdo): void {
// Controleer of de hoofdtabel 'users' al bestaat
$result = $pdo->query("SHOW TABLES LIKE 'users'");
if ($result && $result->rowCount() > 0) {
- // Tabellen bestaan al! Geen actie nodig.
+ // Tabellen bestaan al. Voer eventuele niet-destructieve kolom-upgrades uit indien nodig
+ try {
+ $pdo->exec("ALTER TABLE tasks MODIFY external_id VARCHAR(255) NULL");
+ } catch (Throwable $t) {
+ // Kolom bestaat wellicht al of heeft al de juiste lengte
+ }
return;
}
diff --git a/config/schema.sql b/config/schema.sql
index 6bbb337..eb6bf11 100644
--- a/config/schema.sql
+++ b/config/schema.sql
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS `tasks` (
`duration_minutes` INT NULL DEFAULT 30,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
- `external_id` VARCHAR(100) NULL,
+ `external_id` VARCHAR(255) NULL,
`external_url` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
diff --git a/includes/integrations/microsoft.php b/includes/integrations/microsoft.php
index 72d7be8..6f6326b 100644
--- a/includes/integrations/microsoft.php
+++ b/includes/integrations/microsoft.php
@@ -269,6 +269,10 @@ function microsoft_get_valid_token(int $userId): ?string {
}
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'];
+ }
return null;
}
@@ -283,9 +287,21 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
return [];
}
- // Venster van 00:00:00 tot 23:59:59 lokale tijd
- $startIso = "{$targetDate}T00:00:00";
- $endIso = "{$targetDate}T23:59:59";
+ // Bereken het dagvenster in Europe/Amsterdam en converteer naar UTC ISO 8601 (eindigend op 'Z')
+ // Microsoft Graph vereist strikt een ISO 8601 formaat met tijdzone of 'Z' (bijv. 2026-09-09T00:00:00Z)
+ try {
+ $tzLocal = new DateTimeZone('Europe/Amsterdam');
+ $tzUtc = new DateTimeZone('UTC');
+
+ $dtStart = new DateTime("{$targetDate} 00:00:00", $tzLocal);
+ $dtEnd = new DateTime("{$targetDate} 23:59:59", $tzLocal);
+
+ $startIso = $dtStart->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
+ $endIso = $dtEnd->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
+ } catch (Throwable $e) {
+ $startIso = "{$targetDate}T00:00:00Z";
+ $endIso = "{$targetDate}T23:59:59Z";
+ }
$url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([
'startDateTime' => $startIso,
@@ -305,7 +321,12 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
'timeout' => 12,
]);
- if ($response['status'] !== 200 || empty($response['json']['value'])) {
+ if ($response['status'] !== 200) {
+ error_log("microsoft_fetch_today_events error (HTTP {$response['status']}): " . ($response['error'] ?? json_encode($response['json'])));
+ return [];
+ }
+
+ if (empty($response['json']['value'])) {
return [];
}
@@ -321,14 +342,23 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
- // Formatteer naar H:i (bijv. 09:30)
+ // Formatteer naar H:i (bijv. 09:30). Omdat we 'Prefer: outlook.timezone="Europe/Amsterdam"'
+ // meesturen, levert Graph de tijden al aan in de Nederlandse tijdzone.
$startTime = '';
$endTime = '';
if (!empty($rawStart)) {
- $startTime = date('H:i', strtotime($rawStart));
+ if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
+ $startTime = $m[1];
+ } else {
+ $startTime = date('H:i', strtotime($rawStart));
+ }
}
if (!empty($rawEnd)) {
- $endTime = date('H:i', strtotime($rawEnd));
+ if (preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
+ $endTime = $m[1];
+ } else {
+ $endTime = date('H:i', strtotime($rawEnd));
+ }
}
// Teams of meeting URL detecteren
@@ -353,6 +383,7 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
'end_raw' => $rawEnd,
'location' => $locationName ?: 'Geen locatie',
'meeting_url' => $meetingUrl,
+ 'web_link' => $e['webLink'] ?? '',
'is_all_day' => !empty($e['isAllDay']),
'source' => 'OUTLOOK_LIVE',
];
@@ -361,6 +392,127 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
return $events;
}
+/**
+ * Maakt een nieuwe afspraak aan in de Microsoft Outlook agenda via Graph API
+ *
+ * @param int $userId
+ * @param array $eventData [
+ * 'subject' => string,
+ * 'description' => ?string,
+ * 'date' => ?string (Y-m-d),
+ * 'start_time' => ?string (H:i),
+ * 'end_time' => ?string (H:i),
+ * 'duration_minutes' => ?int,
+ * ]
+ * @return array ['success' => bool, 'event_id' => ?string, 'web_link' => ?string, 'error' => ?string]
+ */
+function microsoft_create_event(int $userId, array $eventData): array {
+ $token = microsoft_get_valid_token($userId);
+ if (!$token) {
+ return ['success' => false, 'error' => 'Geen geldige Outlook 365 verbinding gevonden'];
+ }
+
+ $subject = trim($eventData['subject'] ?? 'Fociz Dagplanning Taak');
+ $description = trim($eventData['description'] ?? 'Gepland via Fociz Dagplanning & Structuur');
+ $date = !empty($eventData['date']) ? trim($eventData['date']) : date('Y-m-d');
+ $startTime = !empty($eventData['start_time']) ? trim($eventData['start_time']) : '09:00';
+
+ // Als tijd alleen een uur is (bijv. "9" of "09"), converteer naar "09:00"
+ if (preg_match('/^(\d{1,2})$/', $startTime, $m)) {
+ $startTime = sprintf('%02d:00', (int)$m[1]);
+ }
+
+ // Bepaal de eindtijd
+ $endTime = !empty($eventData['end_time']) ? trim($eventData['end_time']) : '';
+ $duration = (int)($eventData['duration_minutes'] ?? 30);
+ if ($duration <= 0) $duration = 30;
+
+ if (empty($endTime)) {
+ try {
+ $dt = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
+ $dt->modify("+{$duration} minutes");
+ $endTime = $dt->format('H:i');
+ } catch (Throwable $e) {
+ $endTime = '10:00';
+ }
+ }
+
+ $startDateTimeStr = "{$date}T{$startTime}:00";
+ $endDateTimeStr = "{$date}T{$endTime}:00";
+
+ $payload = [
+ 'subject' => $subject,
+ 'body' => [
+ 'contentType' => 'HTML',
+ 'content' => nl2br(htmlspecialchars($description)) . '
Aangemaakt via Fociz Dagplanning',
+ ],
+ 'start' => [
+ 'dateTime' => $startDateTimeStr,
+ 'timeZone' => 'Europe/Amsterdam',
+ ],
+ 'end' => [
+ 'dateTime' => $endDateTimeStr,
+ 'timeZone' => 'Europe/Amsterdam',
+ ],
+ 'showAs' => 'busy',
+ 'isReminderOn' => true,
+ 'reminderMinutesBeforeStart' => 15,
+ ];
+
+ $response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
+ 'method' => 'POST',
+ 'headers' => [
+ 'Authorization' => "Bearer {$token}",
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+ ],
+ 'body' => $payload,
+ 'timeout' => 15,
+ ]);
+
+ if ($response['status'] === 201 && is_array($response['json'])) {
+ return [
+ 'success' => true,
+ 'event_id' => $response['json']['id'] ?? null,
+ 'web_link' => $response['json']['webLink'] ?? null,
+ 'start_time'=> $startTime,
+ 'end_time' => $endTime,
+ ];
+ }
+
+ $errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Fout bij aanmaken Outlook afspraak (HTTP {$response['status']})");
+ error_log("microsoft_create_event fout: {$errMsg}");
+ return [
+ 'success' => false,
+ 'error' => $errMsg,
+ ];
+}
+
+/**
+ * Verwijdert een afspraak uit Microsoft Outlook via Graph API
+ */
+function microsoft_delete_event(int $userId, string $eventId): bool {
+ if (empty($eventId)) {
+ return false;
+ }
+
+ $token = microsoft_get_valid_token($userId);
+ if (!$token) {
+ return false;
+ }
+
+ $url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
+ $response = fociz_http_request($url, [
+ 'method' => 'DELETE',
+ 'headers' => [
+ 'Authorization' => "Bearer {$token}",
+ ],
+ 'timeout' => 12,
+ ]);
+
+ return in_array($response['status'], [200, 204, 404]);
+}
+
/**
* Ontkoppelt Microsoft integratie voor een gebruiker
* Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig.
diff --git a/planner.php b/planner.php
index c3294b7..f6dd593 100644
--- a/planner.php
+++ b/planner.php
@@ -50,15 +50,13 @@ $isLiveOutlook = false;
if ($isOutlookConnected) {
$outlookEvents = microsoft_fetch_today_events($userId, $today);
- $isLiveOutlook = !empty($outlookEvents) || true; // Live koppeling is actief
-}
-
-// Fallback naar sandbox data indien Outlook niet live gekoppeld is
-if (!$isOutlookConnected || empty($outlookEvents)) {
+ $isLiveOutlook = true;
+} else {
+ // Alleen tonen indien de gebruiker Microsoft Outlook nog niet gekoppeld heeft
$outlookEvents = [
[
'id' => 'evt-1',
- 'subject' => 'Daily Team Standup & Focus Sync',
+ 'subject' => 'Voorbeeld: Team Standup & Sync',
'start' => '09:00',
'end' => '09:30',
'location' => 'Microsoft Teams',
@@ -67,7 +65,7 @@ if (!$isOutlookConnected || empty($outlookEvents)) {
],
[
'id' => 'evt-2',
- 'subject' => 'Sprint & Roadmap Review met Stakeholders',
+ 'subject' => 'Voorbeeld: Roadmap Review met Stakeholders',
'start' => '11:00',
'end' => '12:00',
'location' => 'Vergaderruimte A & Teams',
@@ -76,7 +74,7 @@ if (!$isOutlookConnected || empty($outlookEvents)) {
],
[
'id' => 'evt-3',
- 'subject' => 'Architectuur & MariaDB Database Evaluatie',
+ 'subject' => 'Voorbeeld: Architectuur & DB Evaluatie',
'start' => '14:30',
'end' => '15:30',
'location' => 'Online Teams Call',
@@ -91,16 +89,38 @@ $jiraData = jira_get_user_issues($userId);
$jiraIssues = $jiraData['issues'] ?? [];
$isJiraLive = !empty($jiraData['success']) && empty($jiraData['is_demo']);
+// Filter Outlook events die afkomstig zijn van Fociz taken om duplicaten op de tijdlijn te voorkomen
+$syncedTaskExternalIds = [];
+foreach ($tasks as $t) {
+ if (!empty($t['external_id'])) {
+ $syncedTaskExternalIds[$t['external_id']] = true;
+ }
+}
+$displayedOutlookEvents = [];
+foreach ($outlookEvents as $evt) {
+ if (!empty($evt['id']) && isset($syncedTaskExternalIds[$evt['id']])) {
+ continue;
+ }
+ $displayedOutlookEvents[] = $evt;
+}
+
// 5. Tijdlijn uren bepalen
$startHour = 8;
$endHour = 18;
-foreach ($outlookEvents as $e) {
+foreach ($displayedOutlookEvents as $e) {
if (!empty($e['start'])) {
$h = (int)explode(':', $e['start'])[0];
if ($h < $startHour && $h >= 6) $startHour = $h;
if ($h > $endHour && $h <= 22) $endHour = $h + 1;
}
}
+foreach ($tasks as $t) {
+ if (!empty($t['time_block_start'])) {
+ $h = (int)explode(':', $t['time_block_start'])[0];
+ if ($h < $startHour && $h >= 6) $startHour = $h;
+ if ($h > $endHour && $h <= 22) $endHour = $h + 1;
+ }
+}
$hours = [];
for ($h = $startHour; $h <= $endHour; $h++) {
$hours[] = sprintf('%02d:00', $h);
@@ -254,11 +274,11 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
-