Merge pull request 'Dev' (#16) from dev into main

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2026-09-09 10:35:07 +00:00
7 changed files with 548 additions and 113 deletions
+145 -10
View File
@@ -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']]);
+6 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+16 -32
View File
@@ -132,39 +132,28 @@ require_once __DIR__ . '/env_badge.php';
</nav>
</div>
<!-- Right side: Theme Toggle, Notifications, 2FA Status & User -->
<div class="flex items-center space-x-2 sm:space-x-2.5">
<!-- 1-Click Snelle Thema Wisselaar (Zon / Maan) -->
<button type="button" onclick="focizQuickToggleTheme()" class="p-2 rounded-xl text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 transition" title="Thema wisselen (Donker / Licht)">
<i data-lucide="sun" class="w-4 h-4 hidden dark:block text-amber-400"></i>
<i data-lucide="moon" class="w-4 h-4 block dark:hidden text-slate-700"></i>
</button>
<!-- Notificatiebel -->
<button type="button" onclick="openNotificationModal()" class="relative p-2 rounded-xl text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 transition" title="Meldingen & Agenda Herinneringen">
<i data-lucide="bell" class="w-4 h-4"></i>
<span id="focizNotificationDot" class="hidden absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
</button>
<!-- 2FA Badge -->
<div class="hidden sm:flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/25 text-emerald-700 dark:text-emerald-400 text-xs font-semibold">
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
<span>2FA Actief</span>
</div>
<!-- Right side: Clean User Profile & Logout -->
<div class="flex items-center space-x-2 sm:space-x-3">
<!-- Profiel link rechtsboven -->
<a href="/profile.php" class="text-right hidden sm:block group hover:opacity-90 transition pl-1" title="Mijn Profiel & Instellingen">
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition flex items-center justify-end space-x-1">
<span><?= htmlspecialchars($currentUser['name']) ?></span>
<i data-lucide="chevron-right" class="w-3 h-3 text-slate-400 dark:text-slate-500 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition"></i>
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2.5 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60" title="Mijn Profiel & Instellingen">
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 p-0.5 flex-shrink-0 shadow-sm">
<div class="w-full h-full rounded-[10px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-xs text-emerald-600 dark:text-emerald-400 uppercase">
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
</div>
<div class="text-[10px] text-slate-500 dark:text-slate-400">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?> • <?= htmlspecialchars($currentUser['email']) ?>
</div>
<div class="text-left hidden sm:block">
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition leading-tight">
<?= htmlspecialchars($currentUser['name']) ?>
</div>
<div class="text-[10px] text-slate-500 dark:text-slate-400 leading-tight">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</div>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition hidden sm:block"></i>
</a>
<!-- Uitloggen -->
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-500 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">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
@@ -188,11 +177,6 @@ require_once __DIR__ . '/env_badge.php';
<i data-lucide="user" class="w-4 h-4 mb-0.5"></i>
<span>Profiel</span>
</a>
<button type="button" onclick="focizQuickToggleTheme()" class="flex flex-col items-center py-1 text-slate-500 dark:text-slate-400">
<i data-lucide="sun" class="w-4 h-4 mb-0.5 hidden dark:block text-amber-400"></i>
<i data-lucide="moon" class="w-4 h-4 mb-0.5 block dark:hidden"></i>
<span>Thema</span>
</button>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-emerald-600 dark:text-emerald-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
+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'])));
// 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,15 +342,24 @@ 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)) {
if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
$startTime = $m[1];
} else {
$startTime = date('H:i', strtotime($rawStart));
}
}
if (!empty($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
$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,
'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)) . '<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
* Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig.
+174 -38
View File
@@ -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);
@@ -111,6 +131,10 @@ $dutchDays = ['Zondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrijdag'
$dutchMonths = ['', 'januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'];
$formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(int)date('n')] . ' ' . date('Y');
?>
<style>
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>
<div class="space-y-6 max-w-7xl mx-auto">
<!-- Top Banner: Datum, Hoofddoel & Integraties Status -->
@@ -187,7 +211,7 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Linker kolom: Focus Timer & Taken -->
<div class="lg:col-span-7 space-y-6">
<div class="lg:col-span-7 space-y-6 min-w-0">
<!-- Focus Modus Timer -->
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 shadow-sm dark:shadow-xl relative overflow-hidden backdrop-blur-sm transition">
@@ -254,11 +278,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>
<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 class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<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">
<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="P3">P3 - Regulier / Lage druk</option>
</select>
@@ -267,7 +291,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>
<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>
<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>
<?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">
<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>
@@ -296,6 +344,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">
JIRA
</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; ?>
<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']) ?>
@@ -306,12 +359,12 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<?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">
<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>
<?php endif; ?>
<?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">
<span>Open in Jira</span>
<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><?= ($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>
</a>
<?php endif; ?>
@@ -324,6 +377,12 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<?= htmlspecialchars($task['priority']) ?>
</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']): ?>
<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>
@@ -412,8 +471,8 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
</div>
<!-- Rechter kolom: Timeblocking & Outlook Tijdlijn -->
<div class="lg:col-span-5 space-y-6">
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 shadow-sm dark:shadow-xl backdrop-blur-sm transition">
<div class="lg:col-span-5 space-y-6 min-w-0">
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-5 sm:p-6 shadow-sm dark:shadow-xl backdrop-blur-sm transition overflow-hidden">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="clock" class="w-5 h-5 text-emerald-600 dark:text-emerald-400"></i>
@@ -427,12 +486,12 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
</div>
</div>
<div class="space-y-2">
<div class="space-y-2.5">
<?php foreach ($hours as $hour):
$hourInt = (int)explode(':', $hour)[0];
// Zoek Outlook meetings die binnen dit uur vallen
$slotEvents = array_filter($outlookEvents, function($e) use ($hourInt) {
// Zoek Outlook meetings die binnen dit uur vallen (gefilterd op Fociz duplicaten)
$slotEvents = array_filter($displayedOutlookEvents, function($e) use ($hourInt) {
if (empty($e['start'])) return false;
return (int)explode(':', $e['start'])[0] === $hourInt;
});
@@ -442,45 +501,71 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
return !empty($t['time_block_start']) && (int)explode(':', $t['time_block_start'])[0] === $hourInt;
});
?>
<div class="flex items-start group min-h-[50px] border-b border-slate-100 dark:border-slate-800/40 pb-2 last:border-0">
<div class="w-14 text-xs font-mono font-medium text-slate-500 dark:text-slate-400 pt-1 flex-shrink-0">
<div class="flex items-start group min-h-[50px] border-b border-slate-100 dark:border-slate-800/40 pb-2.5 last:border-0 min-w-0">
<div class="w-12 sm:w-14 text-xs font-mono font-medium text-slate-500 dark:text-slate-400 pt-1 flex-shrink-0">
<?= $hour ?>
</div>
<div class="flex-1 space-y-1.5">
<div class="flex-1 space-y-1.5 min-w-0">
<!-- Outlook meetings -->
<?php foreach ($slotEvents as $evt): ?>
<div class="flex items-center justify-between p-2 rounded-xl bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800/60 text-blue-900 dark:text-blue-100 text-xs shadow-sm">
<div class="flex items-center space-x-2 truncate mr-2">
<span class="px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-700 dark:text-blue-400 font-mono text-[10px] font-bold">
<div class="p-2.5 rounded-2xl bg-blue-50/80 dark:bg-blue-950/50 border border-blue-200/80 dark:border-blue-800/60 text-blue-900 dark:text-blue-100 text-xs shadow-sm min-w-0 overflow-hidden">
<div class="flex items-start justify-between gap-2 min-w-0">
<div class="flex items-start space-x-2 min-w-0 flex-1">
<span class="px-1.5 py-0.5 rounded-lg bg-blue-500/15 text-blue-700 dark:text-blue-300 font-mono text-[10px] font-bold flex-shrink-0 whitespace-nowrap mt-0.5">
<?= htmlspecialchars($evt['start']) ?> - <?= htmlspecialchars($evt['end']) ?>
</span>
<span class="font-semibold truncate" title="<?= htmlspecialchars($evt['subject']) ?>">
<span class="font-semibold text-xs leading-snug break-words line-clamp-2 min-w-0 flex-1" title="<?= htmlspecialchars($evt['subject']) ?>">
<?= htmlspecialchars($evt['subject']) ?>
</span>
</div>
<div class="flex items-center space-x-1 flex-shrink-0">
<?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 font-medium">
<i data-lucide="video" class="w-3 h-3"></i>
<span>Deelnemen</span>
<span class="hidden sm:inline">Deelnemen</span>
</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 py-0.5 transition font-medium">
<i data-lucide="external-link" class="w-3 h-3"></i>
<span class="hidden sm:inline">Outlook</span>
</a>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
<!-- Geplande taken -->
<?php foreach ($slotTasks as $t): ?>
<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">
<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']) ?>
<div class="p-2.5 rounded-2xl border text-xs min-w-0 overflow-hidden <?= $t['is_completed'] ? 'bg-slate-100/70 dark:bg-slate-900/40 border-slate-200 dark:border-slate-800 text-slate-400 dark:text-slate-500 line-through' : 'bg-emerald-50/80 dark:bg-emerald-950/30 border-emerald-200/80 dark:border-emerald-800/40 text-emerald-900 dark:text-emerald-100 shadow-sm' ?>">
<div class="flex items-start justify-between gap-2 min-w-0">
<div class="flex items-start space-x-2 min-w-0 flex-1">
<span class="px-1.5 py-0.5 rounded-lg bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 font-mono text-[10px] font-bold flex-shrink-0 whitespace-nowrap mt-0.5">
<?= htmlspecialchars($t['time_block_start']) ?><?= !empty($t['time_block_end']) ? ' - ' . htmlspecialchars($t['time_block_end']) : '' ?>
</span>
<span class="font-semibold text-xs leading-snug break-words line-clamp-2 min-w-0 flex-1" title="<?= htmlspecialchars($t['title']) ?>">
<?= htmlspecialchars($t['title']) ?>
</span>
<span class="font-semibold truncate"><?= htmlspecialchars($t['title']) ?></span>
</div>
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400">
<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-300 border border-blue-500/25 flex items-center space-x-1 whitespace-nowrap" 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 whitespace-nowrap">
<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 flex-shrink-0">
<?= $t['priority'] ?>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($slotEvents) && empty($slotTasks)): ?>
@@ -597,24 +682,75 @@ function toggleAddTaskForm() {
function quickSchedule(hour) {
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() {
const title = document.getElementById('newTaskTitle').value.trim();
const priority = document.getElementById('newTaskPriority').value;
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;
try {
const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST',
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();
if (data.success) {
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 +798,7 @@ async function toggleTask(taskId, isCompleted) {
}
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', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
+24 -1
View File
@@ -724,7 +724,30 @@ require_once __DIR__ . '/includes/header.php';
</div>
<!-- Sectie 4: Account & Beveiliging (Wachtwoord & 2FA) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-6">
<!-- 2FA Beveiliging Card -->
<div class="p-5 rounded-3xl bg-emerald-50/80 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/25 flex flex-col sm:flex-row sm:items-center justify-between gap-4 shadow-sm">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-600 dark:text-emerald-400 font-bold shadow-sm flex-shrink-0">
<i data-lucide="shield-check" 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">Tweestapsverificatie (2FA) is Actief</h3>
<span class="px-2 py-0.5 rounded-full bg-emerald-500 text-slate-950 font-bold text-[10px]">Beveiligd</span>
</div>
<p class="text-xs text-slate-600 dark:text-slate-400 mt-0.5">
Uw Fociz account is optimaal beveiligd met een Authenticator app (TOTP) voor maximale privacy- en dataveiligheid.
</p>
</div>
</div>
<div class="text-xs font-mono font-medium text-emerald-700 dark:text-emerald-400 flex items-center space-x-1.5 flex-shrink-0 self-end sm:self-center">
<i data-lucide="lock" class="w-4 h-4"></i>
<span>Verplicht & Actief</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Profielnaam formulier -->
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-4 transition">
<div class="flex items-center space-x-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">