feat(outlook): fix calendar sync format and enable bidirectional task-to-outlook scheduling

This commit is contained in:
2026-09-09 12:10:54 +02:00
parent 60f458b28a
commit 247fb4cd26
5 changed files with 473 additions and 55 deletions
+145 -10
View File
@@ -4,6 +4,7 @@
*/ */
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth(); $user = require_auth();
$db = get_db_connection(); $db = get_db_connection();
@@ -36,8 +37,11 @@ try {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$title = trim($data['title'] ?? ''); $title = trim($data['title'] ?? '');
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2'; $priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
$timeBlockStart = $data['time_block_start'] ?? null; $timeBlockStart = !empty($data['time_block_start']) ? trim($data['time_block_start']) : null;
$date = $data['date'] ?? $today; $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)) { if (empty($title)) {
echo json_encode(['error' => 'Titel is verplicht']); echo json_encode(['error' => 'Titel is verplicht']);
@@ -54,14 +58,44 @@ try {
$planId = $db->lastInsertId(); $planId = $db->lastInsertId();
$source = !empty($data['source']) ? substr(trim($data['source']), 0, 50) : 'FOCIZ'; $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; $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(" $stmt = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, status, source, external_id, external_url) INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
VALUES (?, ?, ?, ?, ?, 'TODO', ?, ?, ?) 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(); $taskId = (int)$db->lastInsertId();
echo json_encode([ echo json_encode([
@@ -71,14 +105,102 @@ try {
'title' => $title, 'title' => $title,
'priority' => $priority, 'priority' => $priority,
'time_block_start' => $timeBlockStart, 'time_block_start' => $timeBlockStart,
'time_block_end' => $timeBlockEnd,
'duration_minutes' => $durationMinutes,
'is_completed' => 0, 'is_completed' => 0,
'status' => 'TODO' 'status' => 'TODO',
] 'source' => $source,
'external_id' => $externalId,
'external_url' => $externalUrl,
],
'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)),
]); ]);
exit; 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') { if ($action === 'toggle_task') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0); $taskId = (int)($data['task_id'] ?? 0);
@@ -95,11 +217,24 @@ try {
exit; exit;
} }
// 4. Taak verwijderen // 5. Taak verwijderen (verwijder ook eventueel gekoppelde Outlook afspraak)
if ($action === 'delete_task') { if ($action === 'delete_task') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0); $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 = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$stmt->execute([$taskId, $user['id']]); $stmt->execute([$taskId, $user['id']]);
+6 -1
View File
@@ -118,7 +118,12 @@ function ensure_tables_exist(PDO $pdo): void {
// Controleer of de hoofdtabel 'users' al bestaat // Controleer of de hoofdtabel 'users' al bestaat
$result = $pdo->query("SHOW TABLES LIKE 'users'"); $result = $pdo->query("SHOW TABLES LIKE 'users'");
if ($result && $result->rowCount() > 0) { 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; return;
} }
+1 -1
View File
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS `tasks` (
`duration_minutes` INT NULL DEFAULT 30, `duration_minutes` INT NULL DEFAULT 30,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0, `is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ', `source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
`external_id` VARCHAR(100) NULL, `external_id` VARCHAR(255) NULL,
`external_url` TEXT NULL, `external_url` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+157 -5
View File
@@ -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']))); 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; return null;
} }
@@ -283,9 +287,21 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
return []; return [];
} }
// Venster van 00:00:00 tot 23:59:59 lokale tijd // Bereken het dagvenster in Europe/Amsterdam en converteer naar UTC ISO 8601 (eindigend op 'Z')
$startIso = "{$targetDate}T00:00:00"; // Microsoft Graph vereist strikt een ISO 8601 formaat met tijdzone of 'Z' (bijv. 2026-09-09T00:00:00Z)
$endIso = "{$targetDate}T23:59:59"; 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([ $url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([
'startDateTime' => $startIso, 'startDateTime' => $startIso,
@@ -305,7 +321,12 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
'timeout' => 12, '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 []; return [];
} }
@@ -321,15 +342,24 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
$rawStart = $e['start']['dateTime'] ?? ''; $rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['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 = ''; $startTime = '';
$endTime = ''; $endTime = '';
if (!empty($rawStart)) { if (!empty($rawStart)) {
if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
$startTime = $m[1];
} else {
$startTime = date('H:i', strtotime($rawStart)); $startTime = date('H:i', strtotime($rawStart));
} }
}
if (!empty($rawEnd)) { if (!empty($rawEnd)) {
if (preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
$endTime = $m[1];
} else {
$endTime = date('H:i', strtotime($rawEnd)); $endTime = date('H:i', strtotime($rawEnd));
} }
}
// Teams of meeting URL detecteren // Teams of meeting URL detecteren
$meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com'); $meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com');
@@ -353,6 +383,7 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
'end_raw' => $rawEnd, 'end_raw' => $rawEnd,
'location' => $locationName ?: 'Geen locatie', 'location' => $locationName ?: 'Geen locatie',
'meeting_url' => $meetingUrl, 'meeting_url' => $meetingUrl,
'web_link' => $e['webLink'] ?? '',
'is_all_day' => !empty($e['isAllDay']), 'is_all_day' => !empty($e['isAllDay']),
'source' => 'OUTLOOK_LIVE', 'source' => 'OUTLOOK_LIVE',
]; ];
@@ -361,6 +392,127 @@ function microsoft_fetch_today_events(int $userId, ?string $date = null): array
return $events; 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)) . '<br><br><small style="color: #64748b;">Aangemaakt via <a href="https://dev.fociz.nl/planner.php">Fociz Dagplanning</a></small>',
],
'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 * Ontkoppelt Microsoft integratie voor een gebruiker
* Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig. * Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig.
+146 -20
View File
@@ -50,15 +50,13 @@ $isLiveOutlook = false;
if ($isOutlookConnected) { if ($isOutlookConnected) {
$outlookEvents = microsoft_fetch_today_events($userId, $today); $outlookEvents = microsoft_fetch_today_events($userId, $today);
$isLiveOutlook = !empty($outlookEvents) || true; // Live koppeling is actief $isLiveOutlook = true;
} } else {
// Alleen tonen indien de gebruiker Microsoft Outlook nog niet gekoppeld heeft
// Fallback naar sandbox data indien Outlook niet live gekoppeld is
if (!$isOutlookConnected || empty($outlookEvents)) {
$outlookEvents = [ $outlookEvents = [
[ [
'id' => 'evt-1', 'id' => 'evt-1',
'subject' => 'Daily Team Standup & Focus Sync', 'subject' => 'Voorbeeld: Team Standup & Sync',
'start' => '09:00', 'start' => '09:00',
'end' => '09:30', 'end' => '09:30',
'location' => 'Microsoft Teams', 'location' => 'Microsoft Teams',
@@ -67,7 +65,7 @@ if (!$isOutlookConnected || empty($outlookEvents)) {
], ],
[ [
'id' => 'evt-2', 'id' => 'evt-2',
'subject' => 'Sprint & Roadmap Review met Stakeholders', 'subject' => 'Voorbeeld: Roadmap Review met Stakeholders',
'start' => '11:00', 'start' => '11:00',
'end' => '12:00', 'end' => '12:00',
'location' => 'Vergaderruimte A & Teams', 'location' => 'Vergaderruimte A & Teams',
@@ -76,7 +74,7 @@ if (!$isOutlookConnected || empty($outlookEvents)) {
], ],
[ [
'id' => 'evt-3', 'id' => 'evt-3',
'subject' => 'Architectuur & MariaDB Database Evaluatie', 'subject' => 'Voorbeeld: Architectuur & DB Evaluatie',
'start' => '14:30', 'start' => '14:30',
'end' => '15:30', 'end' => '15:30',
'location' => 'Online Teams Call', 'location' => 'Online Teams Call',
@@ -91,16 +89,38 @@ $jiraData = jira_get_user_issues($userId);
$jiraIssues = $jiraData['issues'] ?? []; $jiraIssues = $jiraData['issues'] ?? [];
$isJiraLive = !empty($jiraData['success']) && empty($jiraData['is_demo']); $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 // 5. Tijdlijn uren bepalen
$startHour = 8; $startHour = 8;
$endHour = 18; $endHour = 18;
foreach ($outlookEvents as $e) { foreach ($displayedOutlookEvents as $e) {
if (!empty($e['start'])) { if (!empty($e['start'])) {
$h = (int)explode(':', $e['start'])[0]; $h = (int)explode(':', $e['start'])[0];
if ($h < $startHour && $h >= 6) $startHour = $h; if ($h < $startHour && $h >= 6) $startHour = $h;
if ($h > $endHour && $h <= 22) $endHour = $h + 1; 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 = []; $hours = [];
for ($h = $startHour; $h <= $endHour; $h++) { for ($h = $startHour; $h <= $endHour; $h++) {
$hours[] = sprintf('%02d:00', $h); $hours[] = sprintf('%02d:00', $h);
@@ -254,11 +274,11 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Taaktitel</label> <label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Taaktitel</label>
<input type="text" id="newTaskTitle" placeholder="Wat moet er vandaag gedaan worden?" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-3 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-emerald-500"> <input type="text" id="newTaskTitle" placeholder="Wat moet er vandaag gedaan worden?" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-3 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-emerald-500">
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div> <div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Prioriteit</label> <label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Prioriteit</label>
<select id="newTaskPriority" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none"> <select id="newTaskPriority" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none">
<option value="P1">P1 - Brandend / Hoogste Focus</option> <option value="P1">P1 - Brandend / Focus</option>
<option value="P2" selected>P2 - Belangrijk (Standaard)</option> <option value="P2" selected>P2 - Belangrijk (Standaard)</option>
<option value="P3">P3 - Regulier / Lage druk</option> <option value="P3">P3 - Regulier / Lage druk</option>
</select> </select>
@@ -267,7 +287,31 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Tijdblok (Starttijd)</label> <label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Tijdblok (Starttijd)</label>
<input type="text" id="newTaskTime" placeholder="Bijv. 09:30" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none"> <input type="text" id="newTaskTime" placeholder="Bijv. 09:30" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none">
</div> </div>
<div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Tijdsduur</label>
<select id="newTaskDuration" class="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-2.5 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none">
<option value="15">15 minuten</option>
<option value="30" selected>30 minuten</option>
<option value="45">45 minuten</option>
<option value="60">60 minuten (1 uur)</option>
<option value="90">90 minuten (1,5 uur)</option>
<option value="120">120 minuten (2 uur)</option>
</select>
</div> </div>
</div>
<?php if ($isOutlookConnected): ?>
<div class="p-2.5 rounded-xl bg-blue-50/70 dark:bg-blue-950/40 border border-blue-200/70 dark:border-blue-800/40">
<label class="flex items-center space-x-2 text-xs text-blue-900 dark:text-blue-200 cursor-pointer select-none">
<input type="checkbox" id="newTaskSyncOutlook" checked class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 border-slate-300 dark:border-slate-700">
<span class="flex items-center space-x-1.5 font-medium">
<i data-lucide="calendar" class="w-3.5 h-3.5 text-blue-500"></i>
<span>Direct inplannen in Microsoft Outlook agenda</span>
</span>
</label>
</div>
<?php endif; ?>
<div class="flex space-x-2 pt-1"> <div class="flex space-x-2 pt-1">
<button onclick="submitNewTask()" class="px-4 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold rounded-lg text-xs transition cursor-pointer">Opslaan</button> <button onclick="submitNewTask()" class="px-4 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold rounded-lg text-xs transition cursor-pointer">Opslaan</button>
<button onclick="toggleAddTaskForm()" class="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-400 rounded-lg text-xs transition cursor-pointer">Annuleren</button> <button onclick="toggleAddTaskForm()" class="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-400 rounded-lg text-xs transition cursor-pointer">Annuleren</button>
@@ -296,6 +340,11 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<span class="px-1.5 py-0.2 rounded bg-sky-500/10 border border-sky-500/20 text-sky-600 dark:text-sky-400 font-mono text-[9px] font-bold"> <span class="px-1.5 py-0.2 rounded bg-sky-500/10 border border-sky-500/20 text-sky-600 dark:text-sky-400 font-mono text-[9px] font-bold">
JIRA JIRA
</span> </span>
<?php elseif ($task['source'] === 'OUTLOOK' || !empty($task['external_id'])): ?>
<span class="px-1.5 py-0.2 rounded bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400 font-mono text-[9px] font-bold flex items-center space-x-0.5">
<i data-lucide="calendar" class="w-2.5 h-2.5"></i>
<span>OUTLOOK</span>
</span>
<?php endif; ?> <?php endif; ?>
<span class="text-xs font-medium truncate <?= $task['is_completed'] ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-800 dark:text-slate-200' ?>"> <span class="text-xs font-medium truncate <?= $task['is_completed'] ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-800 dark:text-slate-200' ?>">
<?= htmlspecialchars($task['title']) ?> <?= htmlspecialchars($task['title']) ?>
@@ -306,12 +355,12 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<?php if (!empty($task['time_block_start'])): ?> <?php if (!empty($task['time_block_start'])): ?>
<span class="text-[10px] text-slate-500 dark:text-slate-400 font-mono flex items-center space-x-1"> <span class="text-[10px] text-slate-500 dark:text-slate-400 font-mono flex items-center space-x-1">
<i data-lucide="clock" class="w-2.5 h-2.5"></i> <i data-lucide="clock" class="w-2.5 h-2.5"></i>
<span><?= htmlspecialchars($task['time_block_start']) ?></span> <span><?= htmlspecialchars($task['time_block_start']) ?><?= !empty($task['time_block_end']) ? ' - ' . htmlspecialchars($task['time_block_end']) : '' ?></span>
</span> </span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($task['external_url'])): ?> <?php if (!empty($task['external_url'])): ?>
<a href="<?= htmlspecialchars($task['external_url']) ?>" target="_blank" class="text-[10px] text-sky-600 dark:text-sky-400 hover:underline flex items-center space-x-0.5"> <a href="<?= htmlspecialchars($task['external_url']) ?>" target="_blank" class="text-[10px] <?= ($task['source'] === 'OUTLOOK' || !empty($task['external_id'])) ? 'text-blue-600 dark:text-blue-400' : 'text-sky-600 dark:text-sky-400' ?> hover:underline flex items-center space-x-0.5">
<span>Open in Jira</span> <span><?= ($task['source'] === 'OUTLOOK' || !empty($task['external_id'])) ? 'Open in Outlook' : 'Open in Jira' ?></span>
<i data-lucide="external-link" class="w-2.5 h-2.5"></i> <i data-lucide="external-link" class="w-2.5 h-2.5"></i>
</a> </a>
<?php endif; ?> <?php endif; ?>
@@ -324,6 +373,12 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<?= htmlspecialchars($task['priority']) ?> <?= htmlspecialchars($task['priority']) ?>
</span> </span>
<?php if ($isOutlookConnected && empty($task['external_id']) && !$task['is_completed']): ?>
<button onclick="syncTaskToOutlook(<?= $task['id'] ?>, this)" title="Direct synchroniseren naar Outlook Agenda" class="p-1.5 rounded-lg text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-500/10 transition">
<i data-lucide="calendar-plus" class="w-3.5 h-3.5"></i>
</button>
<?php endif; ?>
<?php if (!$task['is_completed']): ?> <?php if (!$task['is_completed']): ?>
<button onclick="selectFocusTask('<?= htmlspecialchars(addslashes($task['title'])) ?>')" title="Koppel aan Focus Timer" class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition"> <button onclick="selectFocusTask('<?= htmlspecialchars(addslashes($task['title'])) ?>')" title="Koppel aan Focus Timer" class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition">
<i data-lucide="flame" class="w-3.5 h-3.5"></i> <i data-lucide="flame" class="w-3.5 h-3.5"></i>
@@ -431,8 +486,8 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<?php foreach ($hours as $hour): <?php foreach ($hours as $hour):
$hourInt = (int)explode(':', $hour)[0]; $hourInt = (int)explode(':', $hour)[0];
// Zoek Outlook meetings die binnen dit uur vallen // Zoek Outlook meetings die binnen dit uur vallen (gefilterd op Fociz duplicaten)
$slotEvents = array_filter($outlookEvents, function($e) use ($hourInt) { $slotEvents = array_filter($displayedOutlookEvents, function($e) use ($hourInt) {
if (empty($e['start'])) return false; if (empty($e['start'])) return false;
return (int)explode(':', $e['start'])[0] === $hourInt; return (int)explode(':', $e['start'])[0] === $hourInt;
}); });
@@ -459,13 +514,20 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<?= htmlspecialchars($evt['subject']) ?> <?= htmlspecialchars($evt['subject']) ?>
</span> </span>
</div> </div>
<div class="flex items-center space-x-1.5 flex-shrink-0">
<?php if (!empty($evt['meeting_url'])): ?> <?php if (!empty($evt['meeting_url'])): ?>
<a href="<?= htmlspecialchars($evt['meeting_url']) ?>" target="_blank" class="flex items-center space-x-1 text-[10px] text-white bg-blue-600 hover:bg-blue-700 px-2 py-0.5 rounded-lg flex-shrink-0 transition"> <a href="<?= htmlspecialchars($evt['meeting_url']) ?>" target="_blank" class="flex items-center space-x-1 text-[10px] text-white bg-blue-600 hover:bg-blue-700 px-2 py-0.5 rounded-lg flex-shrink-0 transition">
<i data-lucide="video" class="w-3 h-3"></i> <i data-lucide="video" class="w-3 h-3"></i>
<span>Deelnemen</span> <span>Deelnemen</span>
</a> </a>
<?php elseif (!empty($evt['web_link'])): ?>
<a href="<?= htmlspecialchars($evt['web_link']) ?>" target="_blank" class="flex items-center space-x-1 text-[10px] text-blue-600 dark:text-blue-300 hover:underline px-1.5 py-0.5 transition">
<i data-lucide="external-link" class="w-3 h-3"></i>
<span>Outlook</span>
</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
<!-- Geplande taken --> <!-- Geplande taken -->
@@ -473,14 +535,27 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<div class="flex items-center justify-between p-2 rounded-xl border text-xs <?= $t['is_completed'] ? 'bg-slate-100 dark:bg-slate-900/40 border-slate-200 dark:border-slate-800 text-slate-400 dark:text-slate-500 line-through' : 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-800/40 text-emerald-900 dark:text-emerald-100' ?>"> <div class="flex items-center justify-between p-2 rounded-xl border text-xs <?= $t['is_completed'] ? 'bg-slate-100 dark:bg-slate-900/40 border-slate-200 dark:border-slate-800 text-slate-400 dark:text-slate-500 line-through' : 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-800/40 text-emerald-900 dark:text-emerald-100' ?>">
<div class="flex items-center space-x-2 truncate mr-2"> <div class="flex items-center space-x-2 truncate mr-2">
<span class="px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-mono text-[10px] font-bold"> <span class="px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-mono text-[10px] font-bold">
<?= htmlspecialchars($t['time_block_start']) ?> <?= htmlspecialchars($t['time_block_start']) ?><?= !empty($t['time_block_end']) ? ' - ' . htmlspecialchars($t['time_block_end']) : '' ?>
</span> </span>
<span class="font-semibold truncate"><?= htmlspecialchars($t['title']) ?></span> <span class="font-semibold truncate"><?= htmlspecialchars($t['title']) ?></span>
</div> </div>
<div class="flex items-center space-x-1.5 flex-shrink-0">
<?php if (!empty($t['external_id']) && $t['source'] === 'OUTLOOK'): ?>
<span class="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-700 dark:text-blue-400 border border-blue-500/20 flex items-center space-x-1" title="In Outlook agenda">
<i data-lucide="calendar" class="w-2.5 h-2.5"></i>
<span class="hidden sm:inline">In Outlook</span>
</span>
<?php elseif ($isOutlookConnected && !$t['is_completed']): ?>
<button onclick="syncTaskToOutlook(<?= $t['id'] ?>, this)" title="Synchroniseer naar Outlook Agenda" class="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-500/10 hover:bg-blue-500/20 text-blue-700 dark:text-blue-300 border border-blue-500/20 flex items-center space-x-1 transition cursor-pointer">
<i data-lucide="calendar-plus" class="w-2.5 h-2.5"></i>
<span class="hidden sm:inline">Naar Outlook</span>
</button>
<?php endif; ?>
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400"> <span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400">
<?= $t['priority'] ?> <?= $t['priority'] ?>
</span> </span>
</div> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($slotEvents) && empty($slotTasks)): ?> <?php if (empty($slotEvents) && empty($slotTasks)): ?>
@@ -597,24 +672,75 @@ function toggleAddTaskForm() {
function quickSchedule(hour) { function quickSchedule(hour) {
toggleAddTaskForm(); toggleAddTaskForm();
document.getElementById('newTaskTime').value = hour; const timeInput = document.getElementById('newTaskTime');
if (timeInput) timeInput.value = hour;
const outlookCheckbox = document.getElementById('newTaskSyncOutlook');
if (outlookCheckbox) outlookCheckbox.checked = true;
} }
async function submitNewTask() { async function submitNewTask() {
const title = document.getElementById('newTaskTitle').value.trim(); const title = document.getElementById('newTaskTitle').value.trim();
const priority = document.getElementById('newTaskPriority').value; const priority = document.getElementById('newTaskPriority').value;
const time = document.getElementById('newTaskTime').value.trim(); const time = document.getElementById('newTaskTime').value.trim();
const duration = document.getElementById('newTaskDuration') ? parseInt(document.getElementById('newTaskDuration').value) : 30;
const syncOutlook = document.getElementById('newTaskSyncOutlook') ? document.getElementById('newTaskSyncOutlook').checked : false;
if (!title) return; if (!title) return;
try {
const res = await fetch('/api/planner.php?action=add_task', { const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ title, priority, time_block_start: time, date: '<?= $today ?>' }) body: JSON.stringify({
title,
priority,
time_block_start: time,
duration_minutes: duration,
sync_to_outlook: syncOutlook,
date: '<?= $today ?>'
})
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else {
alert(data.error || 'Fout bij opslaan van de taak');
}
} catch(err) {
alert('Netwerkfout bij opslaan van de taak');
}
}
async function syncTaskToOutlook(taskId, btn) {
if (!btn) return;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="text-[9px] animate-pulse font-medium">Syncen...</span>';
btn.disabled = true;
try {
const res = await fetch('/api/planner.php?action=sync_task_to_outlook', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId })
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-500"></i>';
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
location.reload();
}, 600);
} else {
alert(data.error || 'Kon taak niet synchroniseren naar Outlook');
btn.innerHTML = originalHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
} catch(err) {
alert('Netwerkfout bij synchroniseren naar Outlook');
btn.innerHTML = originalHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
} }
} }
@@ -662,7 +788,7 @@ async function toggleTask(taskId, isCompleted) {
} }
async function deleteTask(taskId) { async function deleteTask(taskId) {
if (!confirm('Taak verwijderen?')) return; if (!confirm('Taak verwijderen? (Indien gekoppeld aan Outlook wordt ook de agenda-afspraak verwijderd)')) return;
await fetch('/api/planner.php?action=delete_task', { await fetch('/api/planner.php?action=delete_task', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},