Add roadmap management, service worker, and user invitation features

- Implemented roadmap.php for managing roadmap items with filtering options by quarter and status.
- Added a service worker (sw.js) to handle push notifications and click events for calendar appointments.
- Created users.php for admin to manage team members and invitations, including user creation and deletion functionalities.
This commit is contained in:
2026-09-09 15:54:24 +02:00
commit 9262b09c4d
41 changed files with 10579 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main
config/config.php merge=ours
includes/env_badge.php merge=ours
config/.env.example merge=ours
+12
View File
@@ -0,0 +1,12 @@
# Environment
.env
.env*.local
# Systeem & Editor
.DS_Store
Thumbs.db
*.log
.vscode/
.idea/
config/.env.example
config/config.php
+10
View File
@@ -0,0 +1,10 @@
FROM php:8.2-apache
# Installeer MariaDB/MySQL drivers
RUN docker-php-ext-install pdo pdo_mysql mysqli
# Activeer Apache mod_rewrite voor schone URL's
RUN a2enmod rewrite
# Zorg dat Apache op poort 80 luistert
EXPOSE 80
+105
View File
@@ -0,0 +1,105 @@
# Fociz — Focus, Dagplanning, Brainstorm & Roadmap Systeem
Fociz is een compleet, modern webplatform gebouwd in **PHP 8 & MariaDB** voor dagplanning, werkstructurering, interactieve brainstormsessies die met één klik omgezet kunnen worden in een roadmap, en koppelingen met Outlook, Teams en Jira.
---
## 🔒 Beveiliging & Toegangsbeheer
- **Strikt afgesloten**: Geen openbare registratie. Enkel toegankelijk na inloggen met tweetrapsauthenticatie (2FA).
- **First-run Admin Setup (`/auth/setup.php`)**:
- Bij het allereerste gebruik detecteert Fociz automatisch dat er nog geen beheerder bestaat en start de setup wizard.
- De beheerder voert naam, e-mail en wachtwoord in en scant de 2FA QR-code met bijv. **Google Authenticator**, **Microsoft Authenticator** of **1Password**.
- Na verificatie van de 6-cijferige code wordt de beheerder opgeslagen en wordt `/auth/setup.php` definitief vergrendeld.
- **Invite-only Registratie (`/auth/invite.php?token=...`)**:
- Nieuwe collega's kunnen enkel toetreden met een unieke uitnodigingslink aangemaakt door de beheerder (`/users.php`).
- Dwingt direct het instellen van een wachtwoord en 2FA-registratie af.
- **Inloggen (`/auth/login.php`)**:
- Stap 1: E-mailadres & wachtwoord (`password_hash` / `password_verify`).
- Stap 2: 6-cijferige TOTP verificatiecode (of herstelcode).
---
## 🚦 Omgevingsindicator (Dev vs Live)
In de linkerbovenhoek van elk scherm bevindt zich een opvallende flashy badge met live radar-ping animatie:
- **Development (Rood)**: Knippert rood met de tekst `DEVELOPMENT (DEV)` zodat u direct ziet dat u op de test/dev omgeving werkt.
- **Live (Groen)**: Knippert groen met de tekst `LIVE (MAIN)` voor de productie-omgeving.
### Hoe in te stellen per omgeving:
In het bestand **`includes/env_badge.php`**:
- **Op DEV**: `$environment = 'development';` *(staat standaard al zo ingesteld)*
- **Op MAIN / LIVE**: `$environment = 'live';` *(of zet `APP_ENV="live"` in `.env`)*
---
## 🚀 Kernmodules
1. **Dagplanning & Focus (`/planner.php`)**:
- **Dagdoel**: Leg uw nummer 1 focuspunt van de dag vast met één klik.
- **Focus Modus**: Ingebouwde Pomodoro/Stopwatch timer (25 min Focus, 50 min Deep Work, 5 min Pauze) met visuele voortgang en taakselectie.
- **Dagtaken & Prioriteiten**: Taken indelen op prioriteit (P1 Brandend, P2 Belangrijk, P3 Regulier), snel toevoegen en afvinken.
- **Tijdlijn & Time-blocking**: Uuroverzicht (08:00 - 18:00) met time-blocking voor taken en gesynchroniseerde Outlook meetings.
- **Dagnotities**: Snel kladblok voor reflecties en memo's.
2. **Brainstorm Canvas (`/brainstorm.php` & `/brainstorm_canvas.php`)**:
- Interactief whiteboard met sticky notes ingedeeld in 4 categorieën:
- 💡 **Ideeën & Concepten**
- ⚠️ **Knelpunten & Uitdagingen**
- 🚀 **Oplossingen & Features**
- ⚡ **Quick Wins**
- Stemmen (upvoting) op ideeën.
- **Zet om naar Roadmap**: Selecteer brainstormkaarten en zet ze direct om naar planbare roadmap-items met kwartaal, deadline en prioriteit.
3. **Roadmap & Tijdlijn (`/roadmap.php`)**:
- Kwartaaloverzicht (Q1, Q2, Q3, Q4) en statussen (Gepland, In Uitvoering, In Review, Voltooid).
- Voortgangsbalken (0 - 100%) en deadlines.
- Gekoppeld aan de oorspronkelijke brainstormkaarten en optioneel Jira Issue Keys (bijv. `FOC-101`).
4. **Mijn Profiel & Persoonlijke Koppelingen (`/profile.php`)**:
- **Microsoft Outlook & Teams**: Koppel met 1 klik uw persoonlijke werkagenda voor dagelijkse afsprakensynchronisatie en directe Teams meeting links.
- **Atlassian Jira**: Koppel uw persoonlijke Atlassian API token om aan u toegewezen tickets in te zien en in te plannen.
- **Browser Push Meldingen**: Configureer persoonlijke waarschuwingen (vooraankondigingstijd, herhalingsfrequentie, audio chime en live test).
- **Account & Beveiliging**: Wachtwoord wijzigen en 2FA beveiligingsstatus.
5. **Systeembeheer & Integraties Portaal (`/admin.php` - Admin only)**:
- Centrale Microsoft 365 Azure App Registratie (Client ID, Client Secret, Tenant ID, Admin Consent verlenen).
- Standaard Jira bedrijfsdomein voor alle teamleden.
- Systeem & Team Monitor (statistieken over actieve leden, agenda koppelingen en API-status).
- Extensibiliteit Hub voor toekomstige modules (AI Co-Pilot, werkdag standaarden).
6. **Team & Invites Beheer (`/users.php` - Admin only)**:
- Genereer beveiligde uitnodigingslinks voor collega's met kopieerknop.
- Overzicht van actieve gebruikers, rollen en 2FA-status.
---
## 🛠️ Installatie op uw Server
### 1. Bestanden uploaden
Push of upload alle bestanden naar de gewenste webdirectory op uw server (bijv. `/var/www/fociz` of `public_html`).
### 2. MariaDB Database configureren
Pas de databasegegevens aan in `.env` (of direct in `config/config.php`):
```env
DB_HOST="127.0.0.1"
DB_PORT="3306"
DB_NAME="fociz"
DB_USER="uw_mariadb_gebruiker"
DB_PASS="uw_mariadb_wachtwoord"
APP_URL="https://fociz.nl"
```
### 3. Automatische Tabellen Aanmaak
U hoeft **niet** handmatig SQL-commando's uit te voeren: zodra u de website voor het eerst opent in uw browser, detecteert Fociz automatisch MariaDB en worden alle tabellen en relaties uit `config/schema.sql` vanzelf aangemaakt!
*(Optioneel kunt u `config/schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).*
### 4. Eerste Beheerder Instellen
1. Open uw browser en ga naar `https://fociz.nl` (of `https://dev.fociz.nl`).
2. U wordt automatisch doorverwezen naar `/auth/setup.php`.
3. Vul uw naam, e-mailadres en gewenst wachtwoord in.
4. Scan de QR-code met Google Authenticator of Microsoft Authenticator.
5. Vul de 6-cijferige code in om te bevestigen.
6. **Klaar!** U kunt nu direct inloggen en collega's uitnodigen.
+753
View File
@@ -0,0 +1,753 @@
<?php
/**
* Fociz Systeembeheer & Centrale Integraties Portaal (Uitsluitend voor Beheerders)
*/
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/integrations/microsoft.php';
require_once __DIR__ . '/includes/integrations/jira.php';
require_once __DIR__ . '/includes/settings.php';
// Strikte autorisatie: alleen gebruikers met de rol 'ADMIN' hebben toegang
$adminUser = require_admin();
$currentUser = $adminUser;
$userId = (int)$adminUser['id'];
$db = get_db_connection();
$message = null;
$messageType = 'success';
if (!empty($_GET['success'])) {
$message = $_GET['success'];
$messageType = 'success';
} elseif (!empty($_GET['error'])) {
$message = $_GET['error'];
$messageType = 'error';
}
// POST Verwerking VÓÓR header output zodat redirects en opslaan vlekkeloos werken
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// 1. Centrale Azure App Registratie opslaan
if ($action === 'save_azure') {
$clientId = trim($_POST['client_id'] ?? '');
$clientSecret = trim($_POST['client_secret'] ?? '');
$tenantId = trim($_POST['tenant_id'] ?? '');
if (empty($tenantId) || $tenantId === 'common') {
$tenantId = 'planningsagenda.nl';
}
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtExisting->execute([$userId]);
$existing = $stmtExisting->fetch();
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
if (($clientSecret === '' || $clientSecret === '••••••••••••••••') && !empty($existingSettings['client_secret']) && $existingSettings['client_secret'] !== '••••••••••••••••') {
$clientSecret = $existingSettings['client_secret'];
}
$newSettings = array_merge($existingSettings, [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'tenant_id' => $tenantId,
]);
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'OUTLOOK', 1, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_demo_mode = VALUES(is_demo_mode),
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
$message = "Centrale Microsoft 365 Azure App instellingen zijn succesvol opgeslagen.";
$messageType = 'success';
}
// 2. Centraal Jira Bedrijfsdomein opslaan
elseif ($action === 'save_jira_domain') {
$rawDomain = trim($_POST['jira_domain'] ?? '');
$companyDomain = !empty($rawDomain) ? jira_normalize_domain($rawDomain) : '';
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmtExisting->execute([$userId]);
$existing = $stmtExisting->fetch();
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
$existingSettings['jira_domain'] = $companyDomain;
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'JIRA', 1, 0, ?, NOW())
ON DUPLICATE KEY UPDATE
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$userId, json_encode($existingSettings)]);
$message = "Standaard Atlassian Jira bedrijfsdomein is succesvol opgeslagen.";
$messageType = 'success';
}
// 3. Organisatie Werkdag Standaarden opslaan (Release v1.4)
elseif ($action === 'save_workday_settings') {
$workdayStart = trim($_POST['workday_start'] ?? '08:30');
$workdayEnd = trim($_POST['workday_end'] ?? '17:30');
$lunchStart = trim($_POST['lunch_start'] ?? '12:30');
$lunchEnd = trim($_POST['lunch_end'] ?? '13:00');
$pomoFocus = max(10, min(120, (int)($_POST['pomodoro_focus'] ?? 25)));
$pomoDeep = max(20, min(180, (int)($_POST['pomodoro_deep'] ?? 50)));
$pomoBreak = max(2, min(30, (int)($_POST['pomodoro_break'] ?? 5)));
$taskDuration = in_array((int)($_POST['default_task_duration'] ?? 30), [15, 30, 45, 60, 90, 120]) ? (int)$_POST['default_task_duration'] : 30;
set_system_setting('workday_start', $workdayStart);
set_system_setting('workday_end', $workdayEnd);
set_system_setting('lunch_start', $lunchStart);
set_system_setting('lunch_end', $lunchEnd);
set_system_setting('pomodoro_focus', $pomoFocus);
set_system_setting('pomodoro_deep', $pomoDeep);
set_system_setting('pomodoro_break', $pomoBreak);
set_system_setting('default_task_duration', $taskDuration);
$message = "Organisatie werkdagstandaarden zijn succesvol bijgewerkt.";
$messageType = 'success';
}
// 4. AI Co-Pilot instellingen opslaan (Release v1.5)
elseif ($action === 'save_ai_settings') {
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
$aiProvider = trim($_POST['ai_provider'] ?? 'openai');
$aiModel = trim($_POST['ai_model'] ?? 'gpt-4o-mini');
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
$currentAi = get_ai_settings();
if ($apiKeyIn !== '' && $apiKeyIn !== '••••••••••••••••') {
set_system_setting('ai_api_key', $apiKeyIn);
}
set_system_setting('ai_enabled', $aiEnabled);
set_system_setting('ai_provider', $aiProvider);
set_system_setting('ai_model', $aiModel);
$message = "AI Co-Pilot instellingen zijn succesvol opgeslagen.";
$messageType = 'success';
}
}
// Haal actuele configuratie op
$appConfig = microsoft_get_app_config();
$companyJiraDomain = jira_get_company_domain();
$workdaySettings = get_workday_settings();
$aiSettings = get_ai_settings();
// Redirect URI
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
// Admin Consent URL
$tenantForConsent = !empty($appConfig['tenant_id']) ? $appConfig['tenant_id'] : 'common';
$adminConsentUrl = "https://login.microsoftonline.com/" . urlencode($tenantForConsent) . "/adminconsent?client_id=" . urlencode($appConfig['client_id']) . "&redirect_uri=" . urlencode($redirectUri);
// Statistieken ophalen voor Systeem & Team Monitor
$totalUsers = (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn();
$totalAdmins = (int)$db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'")->fetchColumn();
$totalMembers = $totalUsers - $totalAdmins;
$pendingInvites = (int)$db->query("SELECT COUNT(*) FROM invites WHERE used_at IS NULL AND expires_at > NOW()")->fetchColumn();
$outlookConnectedCount = (int)$db->query("SELECT COUNT(*) FROM integrations WHERE provider = 'OUTLOOK' AND access_token IS NOT NULL")->fetchColumn();
$jiraConnectedCount = (int)$db->query("SELECT COUNT(*) FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_api_token%' AND settings NOT LIKE '%\"jira_api_token\":\"\"%'")->fetchColumn();
// Haal recente gebruikers op voor snelle inspectie
$recentUsers = $db->query("
SELECT u.id, u.name, u.email, u.role, u.created_at,
(SELECT COUNT(*) FROM integrations i WHERE i.user_id = u.id AND i.provider = 'OUTLOOK' AND i.access_token IS NOT NULL) as has_outlook,
(SELECT COUNT(*) FROM integrations i WHERE i.user_id = u.id AND i.provider = 'JIRA' AND i.settings LIKE '%jira_api_token%' AND i.settings NOT LIKE '%\"jira_api_token\":\"\"%') as has_jira
FROM users u
ORDER BY u.created_at DESC
LIMIT 6
")->fetchAll();
$pageTitle = 'Systeembeheer & Integraties — Fociz';
require_once __DIR__ . '/includes/header.php';
?>
<div class="space-y-8 max-w-7xl mx-auto pb-12">
<!-- Top Header Banner -->
<div class="relative overflow-hidden bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-8 shadow-xs backdrop-blur-xl transition">
<div class="absolute -top-24 -right-24 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute -bottom-24 -left-24 w-80 h-80 bg-sky-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div class="space-y-2">
<div class="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-700 dark:text-indigo-300 text-xs font-semibold tracking-wide">
<i data-lucide="shield-alert" class="w-3.5 h-3.5"></i>
<span>Beheerderspaneel • Alleen toegankelijk voor Beheer</span>
</div>
<h1 class="text-3xl sm:text-4xl font-black text-slate-900 dark:text-white tracking-tight">
Systeembeheer & Integraties
</h1>
<p class="text-sm text-slate-600 dark:text-slate-400 max-w-2xl leading-relaxed">
Beheer hier de centrale organisatiekoppelingen zoals Microsoft 365 Azure App Registratie en Jira, inspecteer de teamstatus en configureer toekomstige modules.
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<a href="/users.php" class="inline-flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800/90 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-700 text-xs font-semibold transition shadow-xs">
<i data-lucide="users" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Team & Invites (<?= $totalUsers ?>)</span>
</a>
<a href="/profile.php" class="inline-flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs">
<i data-lucide="user" class="w-4 h-4"></i>
<span>Mijn Persoonlijk Profiel</span>
</a>
</div>
</div>
</div>
<!-- Meldingen Banner -->
<?php if ($message): ?>
<div class="p-4 rounded-2xl flex items-start space-x-3 text-sm animate-in fade-in duration-200 <?= $messageType === 'success' ? 'bg-teal-50/80 dark:bg-teal-950/30 border border-teal-200 dark:border-teal-800/60 text-teal-800 dark:text-teal-200' : 'bg-red-500/10 border border-red-500/30 text-red-800 dark:text-red-200' ?>">
<i data-lucide="<?= $messageType === 'success' ? 'check-circle-2' : 'alert-triangle' ?>" class="w-5 h-5 flex-shrink-0 mt-0.5 <?= $messageType === 'success' ? 'text-teal-600 dark:text-teal-400' : 'text-red-600 dark:text-red-400' ?>"></i>
<div class="flex-1 font-medium"><?= htmlspecialchars($message) ?></div>
</div>
<?php endif; ?>
<!-- Stat kaarten rij -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-5 flex items-center space-x-4 shadow-xs transition">
<div class="w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 flex items-center justify-center text-indigo-600 dark:text-indigo-400 flex-shrink-0">
<i data-lucide="users" class="w-6 h-6"></i>
</div>
<div>
<span class="text-xs text-slate-500 dark:text-slate-400 font-medium block">Totaal Teamleden</span>
<span class="text-2xl font-black text-slate-900 dark:text-white"><?= $totalUsers ?></span>
<span class="text-[10px] text-slate-500 dark:text-slate-500 block"><?= $totalAdmins ?> Beheerders • <?= $totalMembers ?> Collega's</span>
</div>
</div>
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-5 flex items-center space-x-4 shadow-xs transition">
<div class="w-12 h-12 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center justify-center text-sky-600 dark:text-sky-400 flex-shrink-0">
<i data-lucide="calendar" class="w-6 h-6"></i>
</div>
<div>
<span class="text-xs text-slate-500 dark:text-slate-400 font-medium block">Outlook Live Gekoppeld</span>
<span class="text-2xl font-black text-slate-900 dark:text-white"><?= $outlookConnectedCount ?></span>
<span class="text-[10px] text-teal-600 dark:text-teal-400 font-medium block"><?= $appConfig['is_configured'] ? 'Azure App Actief' : 'Configuratie nodig' ?></span>
</div>
</div>
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-5 flex items-center space-x-4 shadow-xs transition">
<div class="w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 flex items-center justify-center text-indigo-600 dark:text-indigo-400 flex-shrink-0">
<i data-lucide="check-square" class="w-6 h-6"></i>
</div>
<div>
<span class="text-xs text-slate-500 dark:text-slate-400 font-medium block">Jira Gekoppeld</span>
<span class="text-2xl font-black text-slate-900 dark:text-white"><?= $jiraConnectedCount ?></span>
<span class="text-[10px] text-slate-500 dark:text-slate-500 block"><?= !empty($companyJiraDomain) ? 'Domein ingesteld' : 'Geen domein' ?></span>
</div>
</div>
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-5 flex items-center space-x-4 shadow-xs transition">
<div class="w-12 h-12 rounded-xl bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 flex items-center justify-center text-amber-600 dark:text-amber-400 flex-shrink-0">
<i data-lucide="mail" class="w-6 h-6"></i>
</div>
<div>
<span class="text-xs text-slate-500 dark:text-slate-400 font-medium block">Openstaande Invites</span>
<span class="text-2xl font-black text-slate-900 dark:text-white"><?= $pendingInvites ?></span>
<a href="/users.php" class="text-[10px] text-amber-600 dark:text-amber-400 hover:underline block font-medium">Beheer uitnodigingen &rarr;</a>
</div>
</div>
</div>
<!-- Hoofdgrid met Beheer Secties -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Linkerkolom: Integraties Beheer (8 cols) -->
<div class="lg:col-span-8 space-y-8">
<!-- 1. Microsoft 365 Azure App Registratie -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-xl bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800/60 flex items-center justify-center text-sky-600 dark:text-sky-400 font-bold shadow-xs">
<i data-lucide="cloud" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">Microsoft 365 & Azure App Registratie</h2>
<?php if ($appConfig['is_configured']): ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-950/40 border border-teal-200 dark:border-teal-800/60 text-teal-700 dark:text-teal-400 text-[10px] font-bold">
Geconfigureerd
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 text-amber-700 dark:text-amber-400 text-[10px] font-bold">
Configuratie Vereist
</span>
<?php endif; ?>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Centrale OAuth 2.0 instellingen voor de hele organisatie</p>
</div>
</div>
<?php if ($appConfig['is_configured']): ?>
<a href="<?= htmlspecialchars($adminConsentUrl) ?>" target="_blank" rel="noopener noreferrer" class="inline-flex items-center space-x-2 px-3.5 py-2 rounded-xl bg-sky-50 hover:bg-sky-100 dark:bg-sky-950/40 dark:hover:bg-sky-900/40 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-800/60 text-xs font-semibold transition" title="Verleen namens alle gebruikers beheerdersgoedkeuring in Azure">
<i data-lucide="shield-check" class="w-4 h-4 text-sky-600 dark:text-sky-400"></i>
<span>Admin Consent Verlenen</span>
<i data-lucide="external-link" class="w-3.5 h-3.5 text-sky-600 dark:text-sky-400"></i>
</a>
<?php endif; ?>
</div>
<form method="POST" class="mt-6 space-y-5">
<input type="hidden" name="action" value="save_azure">
<!-- Redirect URI Kopieerbox -->
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center space-x-1.5">
<i data-lucide="link" class="w-3.5 h-3.5 text-sky-500"></i>
<span>Redirect URI (Terugbel-URL voor Azure Portal)</span>
</label>
<span class="text-[10px] text-slate-500">Kopieer dit exact naar de Azure Web Redirect URIs</span>
</div>
<div class="flex items-center space-x-2">
<input type="text" readonly id="adminRedirectUri" value="<?= htmlspecialchars($redirectUri) ?>" class="flex-1 bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-xs font-mono text-indigo-600 dark:text-indigo-400 select-all focus:outline-none">
<button type="button" onclick="copyAdminRedirectUri()" class="px-3 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white text-xs font-semibold flex items-center space-x-1.5 transition border border-slate-200 dark:border-transparent cursor-pointer">
<i data-lucide="copy" class="w-3.5 h-3.5" id="copyIcon"></i>
<span id="copyText">Kopiëren</span>
</button>
</div>
</div>
<!-- Client ID & Tenant ID Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Application (Client) ID <span class="text-red-500">*</span>
</label>
<input type="text" name="client_id" required value="<?= htmlspecialchars($appConfig['client_id']) ?>" placeholder="bijv. 38314451-1d05-4205-88ec-..." class="w-full bg-white dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Directory (Tenant) ID / Domeinnaam
</label>
<input type="text" name="tenant_id" value="<?= htmlspecialchars((!empty($appConfig['tenant_id']) && $appConfig['tenant_id'] !== 'common') ? $appConfig['tenant_id'] : 'planningsagenda.nl') ?>" placeholder="bijv. planningsagenda.nl of Azure Tenant GUID" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 font-mono">
<span class="text-[10px] text-slate-500 dark:text-slate-400 mt-1 block">Vul uw domeinnaam in (<code class="text-indigo-600 dark:text-indigo-400 font-mono">planningsagenda.nl</code>) of de Directory (tenant) ID uit Azure. <em>Let op: Gebruik géén 'common' voor single-tenant apps.</em></span>
</div>
</div>
<!-- Client Secret met toggle -->
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-semibold text-slate-700 dark:text-slate-300">
Client Secret (Value) <span class="text-red-500">*</span>
</label>
<span class="text-[10px] text-slate-500">Wordt veilig en versleuteld opgeslagen</span>
</div>
<div class="relative">
<input type="password" id="adminClientSecret" name="client_secret" value="<?= !empty($appConfig['client_secret']) ? '••••••••••••••••' : '' ?>" placeholder="Voer de Secret Value in uit Azure..." class="w-full bg-white dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl pl-3.5 pr-10 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
<button type="button" onclick="toggleAdminSecret()" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
<i data-lucide="eye" class="w-4 h-4" id="eyeSecretIcon"></i>
</button>
</div>
</div>
<div class="pt-2 flex items-center justify-between">
<div class="text-[11px] text-slate-500 dark:text-slate-400">
Zodra opgeslagen kunnen al uw collega's met 1 klik hun Outlook agenda koppelen.
</div>
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
Azure Gegevens Opslaan
</button>
</div>
</form>
<!-- Azure Setup Documentatie Accordion -->
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80">
<button type="button" onclick="toggleAdminGuide()" class="w-full flex items-center justify-between text-xs font-semibold text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition cursor-pointer">
<span class="flex items-center space-x-2">
<i data-lucide="help-circle" class="w-4 h-4 text-sky-500"></i>
<span>Hoe configureer ik de Azure App Registratie in 4 stappen?</span>
</span>
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-200" id="guideArrow"></i>
</button>
<div id="adminGuideContent" class="hidden mt-4 p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 text-xs text-slate-600 dark:text-slate-300 space-y-3 leading-relaxed">
<div class="flex items-start space-x-2.5">
<span class="w-5 h-5 rounded-full bg-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center font-bold text-[10px] flex-shrink-0 mt-0.5">1</span>
<div>
<strong class="text-slate-900 dark:text-white">Azure Portal:</strong> Ga naar <a href="https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps" target="_blank" class="text-sky-600 dark:text-sky-400 underline">portal.azure.com</a> &rarr; <em>Microsoft Entra ID</em> &rarr; <em>App registrations</em> &rarr; <em>New registration</em>. Kies "Accounts in this organizational directory only" (single-tenant).
</div>
</div>
<div class="flex items-start space-x-2.5">
<span class="w-5 h-5 rounded-full bg-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center font-bold text-[10px] flex-shrink-0 mt-0.5">2</span>
<div>
<strong class="text-slate-900 dark:text-white">Redirect URI:</strong> Kies platform <strong>Web</strong> en vul exact in: <code class="text-indigo-600 dark:text-indigo-400 font-mono"><?= htmlspecialchars($redirectUri) ?></code>.
</div>
</div>
<div class="flex items-start space-x-2.5">
<span class="w-5 h-5 rounded-full bg-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center font-bold text-[10px] flex-shrink-0 mt-0.5">3</span>
<div>
<strong class="text-slate-900 dark:text-white">API Permissions:</strong> Ga naar <em>API permissions</em> &rarr; <em>Add a permission</em> &rarr; <em>Microsoft Graph</em> &rarr; <strong>Delegated permissions</strong> &rarr; Vink aan: <code class="text-sky-600 dark:text-sky-400">User.Read</code> en <code class="text-sky-600 dark:text-sky-400">Calendars.ReadWrite</code>.
</div>
</div>
<div class="flex items-start space-x-2.5">
<span class="w-5 h-5 rounded-full bg-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center font-bold text-[10px] flex-shrink-0 mt-0.5">4</span>
<div>
<strong class="text-slate-900 dark:text-white">Beheerdersgoedkeuring (Admin Consent):</strong> Klik op <em>"Grant admin consent for [Uw Organisatie]"</em>. Er verschijnt direct een groen vinkje bij alle regels. Hiermee hoeven individuele collega's geen beheerder meer te vragen!
</div>
</div>
</div>
</div>
</div>
<!-- 2. Atlassian Jira Bedrijfsinstellingen -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex items-center space-x-3.5 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
<i data-lucide="trello" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">Atlassian Jira Organisatie-instellingen</h2>
<?php if (!empty($companyJiraDomain)): ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-950/40 border border-teal-200 dark:border-teal-800/60 text-teal-700 dark:text-teal-400 text-[10px] font-bold">
Actief
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-[10px] font-bold">
Optioneel
</span>
<?php endif; ?>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Standaard Jira Cloud domein voor het hele team</p>
</div>
</div>
<form method="POST" class="mt-6 space-y-4">
<input type="hidden" name="action" value="save_jira_domain">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Standaard Jira Bedrijfsdomein
</label>
<input type="text" name="jira_domain" value="<?= htmlspecialchars($companyJiraDomain) ?>" placeholder="https://uwbedrijf.atlassian.net" class="w-full bg-white dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 font-mono">
<p class="text-[11px] text-slate-500 dark:text-slate-400 mt-1.5 leading-relaxed">
Wanneer u dit instelt, wordt dit domein automatisch vooraf ingevuld op de profielpagina van alle collega's. Zij hoeven dan enkel nog hun eigen persoonlijke e-mailadres en Atlassian API-token in te voeren.
</p>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
Jira Domein Opslaan
</button>
</div>
</form>
</div>
<!-- 3. Organisatie Werkdag Standaarden (Release v1.4) -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs space-y-6 transition">
<div class="flex items-center justify-between pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
<i data-lucide="clock" class="w-6 h-6"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">Werkdag & Dagplanning Standaarden</h2>
<p class="text-xs text-slate-500 dark:text-slate-400">Centrale kantoortijden, lunchpauzes en Pomodoro presets voor het team</p>
</div>
</div>
<span class="px-2.5 py-1 rounded-full bg-teal-50 dark:bg-teal-950/40 text-teal-700 dark:text-teal-400 text-xs font-bold border border-teal-200 dark:border-teal-800/60">
Release v1.4 Actief
</span>
</div>
<form method="POST" action="/admin.php" class="space-y-4">
<input type="hidden" name="action" value="save_workday_settings">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Werkdag uren -->
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-3">
<div class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="sun" class="w-3.5 h-3.5 text-amber-500"></i>
<span>Kantooruren / Tijdlijn Bereik</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Starttijd</label>
<input type="time" name="workday_start" value="<?= htmlspecialchars($workdaySettings['workday_start']) ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Eindtijd</label>
<input type="time" name="workday_end" value="<?= htmlspecialchars($workdaySettings['workday_end']) ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
<p class="text-[10px] text-slate-400 dark:text-slate-500">Bepaalt het standaard start- en einduur op de dagelijkse tijdlijn.</p>
</div>
<!-- Lunchpauze -->
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-3">
<div class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="coffee" class="w-3.5 h-3.5 text-amber-500"></i>
<span>Automatisch Lunchpauze Blok</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Start lunch</label>
<input type="time" name="lunch_start" value="<?= htmlspecialchars($workdaySettings['lunch_start']) ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Einde lunch</label>
<input type="time" name="lunch_end" value="<?= htmlspecialchars($workdaySettings['lunch_end']) ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
<p class="text-[10px] text-slate-400 dark:text-slate-500">Wordt visueel gemarkeerd als pauzeblok op de tijdlijn.</p>
</div>
<!-- Pomodoro presets -->
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-3">
<div class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="flame" class="w-3.5 h-3.5 text-rose-500"></i>
<span>Focus & Pomodoro Presets (minuten)</span>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-[10px] font-medium text-slate-600 dark:text-slate-400 mb-1">Focus</label>
<input type="number" name="pomodoro_focus" min="10" max="120" value="<?= (int)$workdaySettings['pomodoro_focus'] ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-[10px] font-medium text-slate-600 dark:text-slate-400 mb-1">Deep</label>
<input type="number" name="pomodoro_deep" min="20" max="180" value="<?= (int)$workdaySettings['pomodoro_deep'] ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-[10px] font-medium text-slate-600 dark:text-slate-400 mb-1">Pauze</label>
<input type="number" name="pomodoro_break" min="2" max="30" value="<?= (int)$workdaySettings['pomodoro_break'] ?>" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1.5 text-xs text-slate-900 dark:text-white font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
</div>
<!-- Standaard taakduur -->
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 space-y-3">
<div class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="check-square" class="w-3.5 h-3.5 text-indigo-500"></i>
<span>Standaard Taakduur bij Aanmaken</span>
</div>
<div>
<label class="block text-[11px] font-medium text-slate-600 dark:text-slate-400 mb-1">Standaard tijdslot</label>
<select name="default_task_duration" class="w-full bg-white dark:bg-[#111622] border border-slate-300 dark:border-slate-700 rounded-xl px-3 py-1.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="10" <?= $workdaySettings['default_task_duration'] == 10 ? 'selected' : '' ?>>10 minuten</option>
<option value="15" <?= $workdaySettings['default_task_duration'] == 15 ? 'selected' : '' ?>>15 minuten</option>
<option value="20" <?= $workdaySettings['default_task_duration'] == 20 ? 'selected' : '' ?>>20 minuten</option>
<option value="30" <?= $workdaySettings['default_task_duration'] == 30 ? 'selected' : '' ?>>30 minuten (Standaard)</option>
<option value="45" <?= $workdaySettings['default_task_duration'] == 45 ? 'selected' : '' ?>>45 minuten</option>
<option value="60" <?= $workdaySettings['default_task_duration'] == 60 ? 'selected' : '' ?>>60 minuten (1 uur)</option>
<option value="90" <?= $workdaySettings['default_task_duration'] == 90 ? 'selected' : '' ?>>90 minuten (1,5 uur)</option>
<option value="120" <?= $workdaySettings['default_task_duration'] == 120 ? 'selected' : '' ?>>120 minuten (2 uur)</option>
</select>
</div>
<p class="text-[10px] text-slate-400 dark:text-slate-500">Selectie bij het toevoegen van nieuwe dagtaken.</p>
</div>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
Werkdag Standaarden Opslaan
</button>
</div>
</form>
</div>
<!-- 4. AI & Brainstorm Co-Pilot (Release v1.5) -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs space-y-6 transition">
<div class="flex items-center justify-between pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-xl bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 flex items-center justify-center text-amber-600 dark:text-amber-400 font-bold shadow-xs">
<i data-lucide="sparkles" class="w-6 h-6"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">AI & Brainstorm Co-Pilot</h2>
<p class="text-xs text-slate-500 dark:text-slate-400">Intelligente assistentie voor post-it categorisatie, ideegeneratie en slimme planning</p>
</div>
</div>
<span class="px-2.5 py-1 rounded-full <?= $aiSettings['has_key'] ? 'bg-teal-50 dark:bg-teal-950/40 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60' : 'bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800/60' ?> text-xs font-bold">
<?= $aiSettings['has_key'] ? 'AI Actief (v1.5)' : 'Release v1.5 Sleutel Vereist' ?>
</span>
</div>
<form method="POST" action="/admin.php" class="space-y-4">
<input type="hidden" name="action" value="save_ai_settings">
<div class="p-3.5 rounded-2xl bg-amber-50/70 dark:bg-amber-950/20 border border-amber-200/70 dark:border-amber-800/40">
<label class="flex items-center space-x-2 text-xs text-amber-900 dark:text-amber-200 cursor-pointer select-none">
<input type="checkbox" name="ai_enabled" value="1" <?= $aiSettings['ai_enabled'] ? 'checked' : '' ?> class="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
<span class="font-bold">AI Co-Pilot inschakelen voor alle teamleden</span>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
<select name="ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini (Aanbevolen & Snel)</option>
<option value="gpt-4o" <?= $aiSettings['ai_model'] === 'gpt-4o' ? 'selected' : '' ?>>OpenAI GPT-4o (Krachtig & Uitgebreid)</option>
<option value="claude-3-5-sonnet-20241022" <?= $aiSettings['ai_model'] === 'claude-3-5-sonnet-20241022' ? 'selected' : '' ?>>Anthropic Claude 3.5 Sonnet</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">OpenAI / Provider API Sleutel</label>
<input
type="password"
name="ai_api_key"
value="<?= $aiSettings['has_key'] ? '••••••••••••••••' : '' ?>"
placeholder="sk-proj-..."
class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs font-mono placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
</div>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
AI Instellingen Opslaan
</button>
</div>
</form>
</div>
</div>
<!-- Rechterkolom: Systeem & Team Monitor (4 cols) -->
<div class="lg:col-span-4 space-y-6">
<!-- Team Status Overzicht -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 shadow-xs space-y-5 transition">
<div class="flex items-center justify-between pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-2.5">
<i data-lucide="activity" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Team & Integratiestatus</h3>
</div>
<a href="/users.php" class="text-[11px] text-indigo-600 dark:text-indigo-400 hover:underline font-semibold">
Alle gebruikers &rarr;
</a>
</div>
<div class="space-y-3">
<?php foreach ($recentUsers as $u): ?>
<div class="p-3 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/70 flex items-center justify-between">
<div class="flex items-center space-x-3 overflow-hidden">
<div class="w-8 h-8 rounded-full bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-700 dark:text-indigo-300 font-bold flex items-center justify-center text-xs flex-shrink-0">
<?= strtoupper(substr($u['name'], 0, 1)) ?>
</div>
<div class="truncate">
<span class="text-xs font-semibold text-slate-900 dark:text-white block truncate"><?= htmlspecialchars($u['name']) ?></span>
<span class="text-[10px] text-slate-500 dark:text-slate-400 block truncate"><?= htmlspecialchars($u['email']) ?></span>
</div>
</div>
<div class="flex items-center space-x-1.5 flex-shrink-0">
<span title="Outlook: <?= $u['has_outlook'] ? 'Live Gekoppeld' : 'Niet gekoppeld' ?>" class="w-5 h-5 rounded-md flex items-center justify-center text-[10px] <?= $u['has_outlook'] ? 'bg-sky-50 dark:bg-sky-950/50 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-800/60' : 'bg-slate-200 dark:bg-slate-800 text-slate-400 dark:text-slate-600' ?>">
<i data-lucide="calendar" class="w-3 h-3"></i>
</span>
<span title="Jira: <?= $u['has_jira'] ? 'Gekoppeld' : 'Niet gekoppeld' ?>" class="w-5 h-5 rounded-md flex items-center justify-center text-[10px] <?= $u['has_jira'] ? 'bg-indigo-50 dark:bg-indigo-950/50 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-200 dark:bg-slate-800 text-slate-400 dark:text-slate-600' ?>">
<i data-lucide="check-square" class="w-3 h-3"></i>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="pt-2">
<a href="/users.php" class="w-full flex items-center justify-center space-x-2 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 text-xs font-semibold transition border border-slate-200 dark:border-slate-700 shadow-xs">
<i data-lucide="user-plus" class="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuwe Collega Uitnodigen</span>
</a>
</div>
</div>
<!-- Systeem Gezondheid & Beveiliging -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 shadow-xs space-y-4 transition">
<div class="flex items-center space-x-2 text-teal-600 dark:text-teal-400 text-xs font-semibold">
<i data-lucide="shield-check" class="w-4 h-4"></i>
<span>Systeem & Beveiliging</span>
</div>
<div class="space-y-2.5 text-xs text-slate-700 dark:text-slate-300">
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Tweestapsverificatie (2FA)</span>
<span class="text-teal-600 dark:text-teal-400 font-bold flex items-center space-x-1">
<i data-lucide="check" class="w-3 h-3"></i>
<span>Verplicht</span>
</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Database Versie</span>
<span class="text-slate-500 dark:text-slate-400 font-mono text-[11px]">MariaDB 10.x</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Push Notificaties</span>
<span class="text-teal-600 dark:text-teal-400 font-bold">Actief</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Graph API Sync</span>
<span class="text-sky-600 dark:text-sky-400 font-bold">Live v1.0</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function copyAdminRedirectUri() {
const input = document.getElementById('adminRedirectUri');
if (!input) return;
input.select();
navigator.clipboard.writeText(input.value).then(() => {
const copyText = document.getElementById('copyText');
const copyIcon = document.getElementById('copyIcon');
if (copyText) copyText.innerText = 'Gekopieerd!';
if (copyIcon) copyIcon.setAttribute('data-lucide', 'check');
if (window.lucide) lucide.createIcons();
setTimeout(() => {
if (copyText) copyText.innerText = 'Kopiëren';
if (copyIcon) copyIcon.setAttribute('data-lucide', 'copy');
if (window.lucide) lucide.createIcons();
}, 2000);
});
}
function toggleAdminSecret() {
const input = document.getElementById('adminClientSecret');
const icon = document.getElementById('eyeSecretIcon');
if (!input || !icon) return;
if (input.type === 'password') {
input.type = 'text';
icon.setAttribute('data-lucide', 'eye-off');
} else {
input.type = 'password';
icon.setAttribute('data-lucide', 'eye');
}
if (window.lucide) lucide.createIcons();
}
function toggleAdminGuide() {
const content = document.getElementById('adminGuideContent');
const arrow = document.getElementById('guideArrow');
if (!content) return;
content.classList.toggle('hidden');
if (arrow) {
arrow.classList.toggle('rotate-180');
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* AJAX API voor AI Co-Pilot (Release v1.5)
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/ai.php';
require_once __DIR__ . '/../includes/settings.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Auto-categoriseer kaartjes in een brainstormsessie
if ($action === 'categorize_cards') {
$data = json_decode(file_get_contents('php://input'), true);
$sessionId = (int)($data['session_id'] ?? 0);
if (!$sessionId) {
echo json_encode(['error' => 'Sessie ID ontbreekt']);
exit;
}
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
$stmtSession->execute([$sessionId]);
$session = $stmtSession->fetch();
if (!$session) {
echo json_encode(['error' => 'Sessie niet gevonden']);
exit;
}
$stmtCards = $db->prepare("SELECT id, content, category FROM brainstorm_cards WHERE session_id = ?");
$stmtCards->execute([$sessionId]);
$cards = $stmtCards->fetchAll();
if (empty($cards)) {
echo json_encode(['error' => 'Er zijn nog geen kaartjes op het canvas om te categoriseren']);
exit;
}
$aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
$validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins'];
$updateStmt = $db->prepare("UPDATE brainstorm_cards SET category = ? WHERE id = ? AND session_id = ?");
$updatedCount = 0;
foreach ($aiRes['categorized'] as $item) {
$cardId = (int)($item['id'] ?? 0);
$newCat = trim($item['category'] ?? '');
if ($cardId > 0 && in_array($newCat, $validCategories)) {
$updateStmt->execute([$newCat, $cardId, $sessionId]);
$updatedCount++;
}
}
echo json_encode(['success' => true, 'updated' => $updatedCount]);
exit;
}
// 2. Automatisch ideeën genereren voor een sessie
if ($action === 'generate_ideas') {
$data = json_decode(file_get_contents('php://input'), true);
$sessionId = (int)($data['session_id'] ?? 0);
$count = min(6, max(2, (int)($data['count'] ?? 4)));
if (!$sessionId) {
echo json_encode(['error' => 'Sessie ID ontbreekt']);
exit;
}
$stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?");
$stmtSession->execute([$sessionId]);
$session = $stmtSession->fetch();
if (!$session) {
echo json_encode(['error' => 'Sessie niet gevonden']);
exit;
}
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
$insertStmt = $db->prepare("
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
");
$createdCards = [];
$authorName = 'AI Co-Pilot';
foreach ($aiRes['ideas'] as $ideaText) {
$ideaText = trim($ideaText);
if (!empty($ideaText)) {
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
$createdCards[] = [
'id' => (int)$db->lastInsertId(),
'content' => $ideaText,
'category' => 'Ideeën',
'color' => 'amber',
'author_name' => $authorName,
'votes' => 1,
];
}
}
echo json_encode(['success' => true, 'cards' => $createdCards]);
exit;
}
// 3. Brainstorm notitie omzetten naar Roadmap Item
if ($action === 'summarize_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true);
$content = trim($data['content'] ?? '');
$sessionTitle = trim($data['session_title'] ?? '');
if (empty($content)) {
echo json_encode(['error' => 'Geen inhoud opgegeven om samen te vatten']);
exit;
}
$aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
echo json_encode(['success' => true, 'item' => $aiRes['item']]);
exit;
}
// 4. Slimme dagindeling berekenen voor taken rondom Outlook afspraken
if ($action === 'smart_schedule') {
$data = json_decode(file_get_contents('php://input'), true);
$date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d');
// Haal daily plan op
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$user['id'], $date]);
$plan = $stmtPlan->fetch();
$planId = $plan['id'] ?? 0;
// Haal openstaande taken op voor de dag (inclusief fallback voor ongebonden taken op de huidige dag)
$isToday = ($date === date('Y-m-d'));
$stmtTasks = $db->prepare("
SELECT t.id, t.title, t.priority, t.duration_minutes
FROM tasks t
WHERE t.user_id = ? AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ") AND t.is_completed = 0
ORDER BY t.priority ASC, t.created_at ASC
");
$stmtTasks->execute([$user['id'], $planId]);
$tasks = $stmtTasks->fetchAll();
if (empty($tasks)) {
echo json_encode(['error' => 'Geen openstaande taken gevonden om in te delen']);
exit;
}
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$events = microsoft_fetch_today_events($user['id'], $date);
$workdaySettings = get_workday_settings();
$aiRes = fociz_ai_smart_schedule($tasks, $events, $workdaySettings);
if (!$aiRes['success']) {
echo json_encode(['error' => $aiRes['error']]);
exit;
}
// Werk de tijdsblokken bij in MariaDB
$updateStmt = $db->prepare("
UPDATE tasks
SET time_block_start = ?,
duration_minutes = COALESCE(?, duration_minutes)
WHERE id = ? AND user_id = ?
");
$scheduledCount = 0;
foreach ($aiRes['scheduled'] as $s) {
$taskId = (int)($s['id'] ?? 0);
$startTime = trim($s['time_block_start'] ?? '');
$duration = !empty($s['duration_minutes']) ? (int)$s['duration_minutes'] : null;
if ($taskId > 0 && preg_match('/^\d{1,2}:\d{2}$/', $startTime)) {
$updateStmt->execute([$startTime, $duration, $taskId, $user['id']]);
$scheduledCount++;
}
}
echo json_encode(['success' => true, 'scheduled_count' => $scheduledCount]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* AJAX API voor Brainstorm Sessies, Kaarten & Omzetten naar Roadmap
*/
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Brainstormkaart toevoegen
if ($action === 'add_card') {
$data = json_decode(file_get_contents('php://input'), true);
$sessionId = (int)($data['session_id'] ?? 0);
$content = trim($data['content'] ?? '');
$category = $data['category'] ?? 'Ideeën';
$color = $data['color'] ?? 'amber';
if (empty($content)) {
echo json_encode(['error' => 'Inhoud is verplicht']);
exit;
}
$stmt = $db->prepare("
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
VALUES (?, ?, ?, ?, ?, 0)
");
$stmt->execute([$sessionId, $content, $category, $color, $user['name']]);
$cardId = (int)$db->lastInsertId();
echo json_encode([
'success' => true,
'card' => [
'id' => $cardId,
'content' => $content,
'category' => $category,
'color' => $color,
'author_name' => $user['name'],
'votes' => 0,
'converted_to_roadmap_item_id' => null
]
]);
exit;
}
// 2. Stemmen / Upvote op kaart
if ($action === 'vote_card') {
$data = json_decode(file_get_contents('php://input'), true);
$cardId = (int)($data['card_id'] ?? 0);
$stmt = $db->prepare("UPDATE brainstorm_cards SET votes = votes + 1 WHERE id = ?");
$stmt->execute([$cardId]);
$stmtVotes = $db->prepare("SELECT votes FROM brainstorm_cards WHERE id = ?");
$stmtVotes->execute([$cardId]);
$votes = (int)$stmtVotes->fetchColumn();
echo json_encode(['success' => true, 'votes' => $votes]);
exit;
}
// 3. Kaart verwijderen
if ($action === 'delete_card') {
$data = json_decode(file_get_contents('php://input'), true);
$cardId = (int)($data['card_id'] ?? 0);
$stmt = $db->prepare("DELETE FROM brainstorm_cards WHERE id = ?");
$stmt->execute([$cardId]);
echo json_encode(['success' => true]);
exit;
}
// 4. Omzetten naar Roadmap!
if ($action === 'convert_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true);
$cardIds = $data['card_ids'] ?? [];
$quarter = $data['quarter'] ?? 'Q3';
$type = $data['type'] ?? 'FEATURE';
$priority = $data['priority'] ?? 'MEDIUM';
$dueDate = !empty($data['due_date']) ? $data['due_date'] : null;
if (empty($cardIds) || !is_array($cardIds)) {
echo json_encode(['error' => 'Selecteer ten minste één kaart om om te zetten.']);
exit;
}
$db->beginTransaction();
$convertedCount = 0;
foreach ($cardIds as $cardId) {
$cardId = (int)$cardId;
$stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?");
$stmtCard->execute([$cardId]);
$card = $stmtCard->fetch();
if ($card) {
// Maak titel (eerste zin of max 80 tekens)
$firstLine = explode("\n", trim($card['content']))[0];
$title = mb_substr($firstLine, 0, 80);
$stmtRoadmap = $db->prepare("
INSERT INTO roadmap_items (title, description, type, status, quarter, priority, progress, due_date, origin_brainstorm_card_id, created_by_id)
VALUES (?, ?, ?, 'PLANNED', ?, ?, 0, ?, ?, ?)
");
$stmtRoadmap->execute([
$title,
$card['content'],
$type,
$quarter,
$priority,
$dueDate,
$cardId,
$user['id']
]);
$roadmapId = (int)$db->lastInsertId();
// Koppel terug aan brainstorm kaart
$stmtUpdateCard = $db->prepare("UPDATE brainstorm_cards SET converted_to_roadmap_item_id = ? WHERE id = ?");
$stmtUpdateCard->execute([$roadmapId, $cardId]);
$convertedCount++;
}
}
$db->commit();
echo json_encode([
'success' => true,
'message' => "{$convertedCount} brainstorm items succesvol omgezet naar de Roadmap!",
'converted_count' => $convertedCount
]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
if ($db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* AJAX API voor Integraties (Outlook, Teams, Jira)
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
require_once __DIR__ . '/../includes/integrations/jira.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Jira Verbinding Testen & Optioneel Opslaan
if ($action === 'test_jira') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$domain = trim($input['jira_domain'] ?? '');
$email = trim($input['jira_email'] ?? '');
$apiToken = trim($input['jira_api_token'] ?? '');
$save = !empty($input['save']);
// Als er geen nieuw token is meegegeven, kijk of er een opgeslagen token is
if (empty($apiToken)) {
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmt->execute([$user['id']]);
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
$apiToken = $saved['jira_api_token'] ?? '';
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
if (empty($email)) $email = $saved['jira_email'] ?? '';
}
$res = jira_test_connection($domain, $email, $apiToken);
if ($res['success'] && $save) {
$jiraUser = $res['user'];
$settings = json_encode([
'jira_domain' => jira_normalize_domain($domain),
'jira_email' => $email,
'jira_api_token' => $apiToken,
'account_name' => $jiraUser['displayName'] ?? '',
'account_id' => $jiraUser['accountId'] ?? '',
]);
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'JIRA', 1, 0, ?, NOW())
ON DUPLICATE KEY UPDATE
is_enabled = 1,
is_demo_mode = 0,
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$user['id'], $settings]);
// Wis eventuele oude sessie cache
unset($_SESSION["jira_issues_user_{$user['id']}"]);
}
echo json_encode($res);
exit;
}
// 2. Jira Issues Ophalen (Toegewezen aan de gebruiker)
if ($action === 'get_jira_issues') {
$force = !empty($_GET['force']) || !empty($_POST['force']);
$res = jira_get_user_issues($user['id'], $force);
echo json_encode($res);
exit;
}
// 3. Outlook Agenda Afspraken Ophalen
if ($action === 'get_outlook_events') {
$date = $_GET['date'] ?? date('Y-m-d');
$events = microsoft_fetch_today_events($user['id'], $date);
echo json_encode([
'success' => true,
'date' => $date,
'events' => $events,
'count' => count($events),
]);
exit;
}
// 4. Integratie Ontkoppelen
if ($action === 'disconnect') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$provider = strtoupper(trim($input['provider'] ?? ''));
if ($provider === 'OUTLOOK') {
microsoft_disconnect($user['id']);
echo json_encode(['success' => true, 'message' => 'Microsoft Outlook ontkoppeld']);
exit;
}
if ($provider === 'JIRA') {
jira_disconnect($user['id']);
echo json_encode(['success' => true, 'message' => 'Atlassian Jira ontkoppeld']);
exit;
}
echo json_encode(['error' => 'Onbekende provider']);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* AJAX API voor Persoonlijke Agenda Notificaties
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
$today = date('Y-m-d');
try {
// 1. Instellingen ophalen
if ($action === 'get_settings') {
$settings = get_user_notification_settings((int)$user['id']);
echo json_encode([
'success' => true,
'settings' => $settings,
]);
exit;
}
// 2. Instellingen opslaan
if ($action === 'save_settings') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$saved = update_user_notification_settings((int)$user['id'], $input);
$settings = get_user_notification_settings((int)$user['id']);
echo json_encode([
'success' => $saved,
'settings' => $settings,
'message' => $saved ? 'Notificatievoorkeuren succesvol opgeslagen.' : 'Fout bij opslaan.',
]);
exit;
}
// 3. Actuele agenda-afspraken van vandaag ophalen voor notificatie-evaluatie
if ($action === 'get_events') {
$userId = (int)$user['id'];
$settings = get_user_notification_settings($userId);
// Controleer of Outlook gekoppeld is
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmtInt->execute([$userId]);
$outlookInt = $stmtInt->fetch();
$isLiveOutlook = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
$events = [];
if ($isLiveOutlook) {
$events = microsoft_fetch_today_events($userId, $today);
}
// Als er geen live Outlook events zijn, haal ook taken met tijdblokken op uit Fociz dagplanning
$stmtTasks = $db->prepare("
SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.source, t.external_url
FROM tasks t
LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id
WHERE t.user_id = ? AND (dp.plan_date = ? OR t.time_block_start IS NOT NULL) AND t.is_completed = 0
ORDER BY t.time_block_start ASC
");
$stmtTasks->execute([$userId, $today]);
$tasks = $stmtTasks->fetchAll();
foreach ($tasks as $t) {
if (!empty($t['time_block_start'])) {
$events[] = [
'id' => 'task_' . $t['id'],
'subject' => $t['title'],
'start' => $t['time_block_start'],
'end' => $t['time_block_end'] ?: date('H:i', strtotime($t['time_block_start'] . ' +30 minutes')),
'start_raw' => "{$today}T{$t['time_block_start']}:00",
'location' => 'Fociz Dagplanning',
'meeting_url' => $t['external_url'] ?? '/planner.php',
'is_all_day' => false,
'source' => 'FOCIZ_TASK',
];
}
}
// Als er nog steeds geen afspraken zijn (en demo mode actief), lever sandbox items zodat er altijd getest kan worden
if (empty($events) && (!empty($outlookInt['is_demo_mode']) || !$isLiveOutlook)) {
$currentHour = (int)date('H');
$nextHour = str_pad((string)(($currentHour + 1) % 24), 2, '0', STR_PAD_LEFT);
$events = [
[
'id' => 'sandbox_meeting_1',
'subject' => 'Focus Sync & Dagstart',
'start' => "{$nextHour}:00",
'end' => "{$nextHour}:30",
'start_raw' => "{$today}T{$nextHour}:00:00",
'location' => 'Microsoft Teams',
'meeting_url' => 'https://teams.microsoft.com',
'is_all_day' => false,
'source' => 'SANDBOX',
]
];
}
echo json_encode([
'success' => true,
'server_time' => date('H:i:s'),
'server_datetime' => date('Y-m-d H:i:s'),
'server_timestamp'=> time(),
'settings' => $settings,
'events' => $events,
'count' => count($events),
]);
exit;
}
echo json_encode(['error' => 'Ongeldige actie']);
exit;
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
]);
exit;
}
+460
View File
@@ -0,0 +1,460 @@
<?php
/**
* AJAX API voor Dagplanning & Taken
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
$today = date('Y-m-d');
try {
// 1. Dagdoel of notities opslaan
if ($action === 'save_goal') {
$data = json_decode(file_get_contents('php://input'), true);
$date = $data['date'] ?? $today;
$focusGoal = $data['focus_goal'] ?? '';
$notes = $data['notes'] ?? '';
$stmt = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date, focus_goal, notes)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE focus_goal = VALUES(focus_goal), notes = VALUES(notes)
");
$stmt->execute([$user['id'], $date, $focusGoal, $notes]);
echo json_encode(['success' => true]);
exit;
}
// 2. Taak toevoegen
if ($action === 'add_task') {
$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 = !empty($data['time_block_start']) ? trim($data['time_block_start']) : null;
$timeBlockEnd = !empty($data['time_block_end']) ? trim($data['time_block_end']) : null;
$durationMinutes = (int)($data['duration_minutes'] ?? 0);
$date = !empty($data['date']) ? trim($data['date']) : $today;
$syncOutlook = !empty($data['sync_to_outlook']);
if (empty($title)) {
echo json_encode(['error' => 'Titel is verplicht']);
exit;
}
// Als zowel start- als eindtijd zijn opgegeven, bereken exact de minuten
if (!empty($timeBlockStart) && !empty($timeBlockEnd)) {
$t1 = strtotime("2000-01-01 {$timeBlockStart}");
$t2 = strtotime("2000-01-01 {$timeBlockEnd}");
if ($t1 !== false && $t2 !== false && $t2 > $t1) {
$durationMinutes = (int)(($t2 - $t1) / 60);
}
}
if ($durationMinutes <= 0) {
$durationMinutes = 30;
}
// Als starttijd bekend is maar eindtijd nog niet, bereken eindtijd
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
}
}
// Haal of maak daily plan op
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$user['id'], $date]);
$planId = $db->lastInsertId();
$source = !empty($data['source']) ? substr(trim($data['source']), 0, 50) : 'FOCIZ';
$externalId = !empty($data['external_id']) ? substr(trim($data['external_id']), 0, 255) : null;
$externalUrl = !empty($data['external_url']) ? trim($data['external_url']) : null;
$outlookSyncError = null;
// Als synchronisatie naar Outlook gewenst is (of source is OUTLOOK) en nog geen extern ID heeft
if (($syncOutlook || $source === 'OUTLOOK') && empty($externalId)) {
// Als er geen starttijd is ingevuld maar wel sync gewenst is, gebruik 09:00 als veilige standaard
if (empty($timeBlockStart)) {
$timeBlockStart = '09:00';
}
$msResult = microsoft_create_event($user['id'], [
'subject' => $title,
'description' => "Gepland via Fociz Dagplanning ({$priority})",
'date' => $date,
'start_time' => $timeBlockStart,
'end_time' => $timeBlockEnd,
'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';
$timeBlockStart = $msResult['start_time'] ?? $timeBlockStart;
$timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd;
} else {
$outlookSyncError = $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook';
}
}
$stmt = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
");
$stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl]);
$taskId = (int)$db->lastInsertId();
echo json_encode([
'success' => true,
'task' => [
'id' => $taskId,
'title' => $title,
'priority' => $priority,
'time_block_start' => $timeBlockStart,
'time_block_end' => $timeBlockEnd,
'duration_minutes' => $durationMinutes,
'source' => $source,
'external_id' => $externalId,
'external_url' => $externalUrl
],
'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)),
'outlook_error' => $outlookSyncError
]);
exit;
}
// 2b. Taak bewerken (titel, prioriteit, starttijd, eindtijd, duur)
if ($action === 'update_task') {
$data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0);
$title = trim($data['title'] ?? '');
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
$timeBlockStart = !empty($data['time_block_start']) ? trim($data['time_block_start']) : null;
$timeBlockEnd = !empty($data['time_block_end']) ? trim($data['time_block_end']) : null;
$durationMinutes = (int)($data['duration_minutes'] ?? 0);
if (empty($title)) {
echo json_encode(['error' => 'Titel mag niet leeg zijn']);
exit;
}
// Haal bestaande taak op
$stmtTask = $db->prepare("SELECT * FROM tasks WHERE id = ? AND user_id = ?");
$stmtTask->execute([$taskId, $user['id']]);
$existingTask = $stmtTask->fetch();
if (!$existingTask) {
echo json_encode(['error' => 'Taak niet gevonden']);
exit;
}
if (!empty($timeBlockStart) && !empty($timeBlockEnd)) {
$t1 = strtotime("2000-01-01 {$timeBlockStart}");
$t2 = strtotime("2000-01-01 {$timeBlockEnd}");
if ($t1 !== false && $t2 !== false && $t2 > $t1) {
$durationMinutes = (int)(($t2 - $t1) / 60);
}
}
if ($durationMinutes <= 0) {
$durationMinutes = (int)($existingTask['duration_minutes'] ?: 30);
}
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
try {
$dtEnd = new DateTime("2000-01-01 {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
$dtEnd->modify("+{$durationMinutes} minutes");
$timeBlockEnd = $dtEnd->format('H:i');
} catch (Throwable $e) {}
}
// Indien gekoppeld aan Outlook, werk afspraak bij via Graph API
if (!empty($existingTask['external_id']) && $existingTask['source'] === 'OUTLOOK') {
try {
$stmtPlan = $db->prepare("SELECT plan_date FROM daily_plans WHERE id = ?");
$stmtPlan->execute([$existingTask['daily_plan_id']]);
$planDate = $stmtPlan->fetchColumn() ?: date('Y-m-d');
microsoft_update_event($user['id'], $existingTask['external_id'], [
'subject' => $title,
'date' => $planDate,
'start_time' => $timeBlockStart ?: '09:00',
'end_time' => $timeBlockEnd ?: '09:30',
]);
} catch (Throwable $t) {
error_log("Fout bij bijwerken Outlook event: " . $t->getMessage());
}
}
$stmtUpdate = $db->prepare("
UPDATE tasks
SET title = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ? AND user_id = ?
");
$stmtUpdate->execute([$title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $taskId, $user['id']]);
echo json_encode([
'success' => true,
'time_block_start' => $timeBlockStart,
'time_block_end' => $timeBlockEnd,
'duration_minutes' => $durationMinutes
]);
exit;
}
// 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');
$endTime = !empty($task['time_block_end']) ? $task['time_block_end'] : null;
$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,
'end_time' => $endTime,
'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);
$isCompleted = !empty($data['is_completed']) ? 1 : 0;
$status = $isCompleted ? 'DONE' : 'TODO';
$stmt = $db->prepare("
UPDATE tasks SET is_completed = ?, status = ?
WHERE id = ? AND user_id = ?
");
$stmt->execute([$isCompleted, $status, $taskId, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
// 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']]);
echo json_encode(['success' => true]);
exit;
}
// 6. Realtime Outlook synchronisatie & controle op gewijzigde/gewiste items
if ($action === 'check_outlook_sync') {
$rawInput = file_get_contents('php://input');
$data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : [];
if (empty($data)) {
$data = $_GET;
}
$planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : $today;
$weekStart = !empty($data['week_start']) ? trim($data['week_start']) : date('Y-m-d', strtotime('monday this week', strtotime($planDate)));
$weekEnd = !empty($data['week_end']) ? trim($data['week_end']) : date('Y-m-d', strtotime('sunday this week', strtotime($planDate)));
$lastHash = !empty($data['last_hash']) ? trim($data['last_hash']) : '';
// Controleer of Outlook gekoppeld is
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmtInt->execute([$user['id']]);
$integration = $stmtInt->fetch();
if (!$integration || empty($integration['access_token']) || !empty($integration['is_demo_mode'])) {
echo json_encode([
'success' => true,
'connected' => false,
'has_changes' => false,
'last_sync' => date('H:i:s'),
]);
exit;
}
// Haal live afspraken op voor de hele week
$weekLiveEvents = microsoft_fetch_events_range($user['id'], $weekStart, $weekEnd);
// Voer reconciliatie uit met de Fociz tasks tabel
$reconcileRes = microsoft_reconcile_tasks_with_outlook($user['id'], $weekStart, $weekEnd, $weekLiveEvents);
// Haal de geactualiseerde taken voor deze dag op
$stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$user['id'], $planDate]);
$planId = $stmtPlan->fetchColumn();
$tasks = [];
if ($planId) {
$stmtTasks = $db->prepare("
SELECT id, title, priority, time_block_start, time_block_end, duration_minutes, is_completed, status, source, external_id
FROM tasks
WHERE user_id = ? AND (daily_plan_id = ? " . ($planDate === $today ? "OR daily_plan_id IS NULL" : "") . ")
ORDER BY is_completed ASC, priority ASC, time_block_start ASC, created_at DESC
");
$stmtTasks->execute([$user['id'], $planId]);
$tasks = $stmtTasks->fetchAll();
}
// Filter events voor de geselecteerde dag die geen gesynchroniseerde Fociz taak zijn
$syncedIds = [];
foreach ($tasks as $t) {
if (!empty($t['external_id'])) {
$syncedIds[$t['external_id']] = true;
}
}
$dayEvents = [];
foreach ($weekLiveEvents as $e) {
if (($e['date'] ?? '') === $planDate) {
if (!empty($e['id']) && isset($syncedIds[$e['id']])) {
continue;
}
$dayEvents[] = [
'id' => $e['id'],
'start' => $e['start'],
'end' => $e['end'],
'subject' => $e['subject'],
];
}
}
// Bereken status hash voor de dag
$hashData = [
'events' => $dayEvents,
'tasks' => array_map(function($t) {
return [
'id' => $t['id'],
'title' => $t['title'],
'time_block_start' => $t['time_block_start'],
'time_block_end' => $t['time_block_end'],
'is_completed' => $t['is_completed'],
];
}, $tasks)
];
$currentHash = md5(json_encode($hashData));
$hasChanges = ($reconcileRes['deleted'] > 0 || $reconcileRes['updated'] > 0);
if (!empty($lastHash) && $lastHash !== $currentHash) {
$hasChanges = true;
}
echo json_encode([
'success' => true,
'connected' => true,
'has_changes' => $hasChanges,
'hash' => $currentHash,
'deleted_count' => $reconcileRes['deleted'],
'updated_count' => $reconcileRes['updated'],
'events_count' => count($dayEvents),
'tasks_count' => count($tasks),
'last_sync' => date('H:i:s'),
]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* AJAX API voor Roadmap Beheer
*/
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Roadmap item toevoegen
if ($action === 'add_item') {
$data = json_decode(file_get_contents('php://input'), true);
$title = trim($data['title'] ?? '');
$description = trim($data['description'] ?? '');
$quarter = $data['quarter'] ?? 'Q3';
$type = $data['type'] ?? 'FEATURE';
$priority = $data['priority'] ?? 'MEDIUM';
$dueDate = !empty($data['due_date']) ? $data['due_date'] : null;
$jiraKey = !empty($data['jira_key']) ? trim($data['jira_key']) : null;
if (empty($title)) {
echo json_encode(['error' => 'Titel is verplicht']);
exit;
}
$stmt = $db->prepare("
INSERT INTO roadmap_items (title, description, quarter, type, priority, status, progress, due_date, jira_issue_key, created_by_id)
VALUES (?, ?, ?, ?, ?, 'PLANNED', 0, ?, ?, ?)
");
$stmt->execute([$title, $description, $quarter, $type, $priority, $dueDate, $jiraKey, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
// 2. Status of voortgang bijwerken
if ($action === 'update_item') {
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$status = $data['status'] ?? null;
$progress = isset($data['progress']) ? (int)$data['progress'] : null;
$jiraKey = isset($data['jira_key']) ? trim($data['jira_key']) : null;
$fields = [];
$params = [];
if ($status !== null) {
$fields[] = "status = ?";
$params[] = $status;
}
if ($progress !== null) {
$fields[] = "progress = ?";
$params[] = max(0, min(100, $progress));
}
if ($jiraKey !== null) {
$fields[] = "jira_issue_key = ?";
$params[] = $jiraKey;
}
if (!empty($fields)) {
$params[] = $id;
$sql = "UPDATE roadmap_items SET " . implode(", ", $fields) . " WHERE id = ?";
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
echo json_encode(['success' => true]);
exit;
}
// 3. Item verwijderen
if ($action === 'delete_item') {
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$stmt = $db->prepare("DELETE FROM roadmap_items WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* Fociz Thema API Endpoint (Dark / Light / System)
*/
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth(true);
$userId = (int)$user['id'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$currentTheme = get_user_theme($userId);
echo json_encode([
'success' => true,
'theme' => $currentTheme,
]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$theme = $data['theme'] ?? ($_POST['theme'] ?? 'dark');
if (!in_array($theme, ['dark', 'light', 'system'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ongeldig thema. Kies dark, light of system.']);
exit;
}
$ok = update_user_theme($userId, $theme);
if ($ok) {
echo json_encode([
'success' => true,
'theme' => $theme,
'message' => 'Themavoorkeur succesvol opgeslagen.',
]);
} else {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Kon thema niet opslaan in database.']);
}
exit;
}
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
+418
View File
@@ -0,0 +1,418 @@
/**
* Fociz Browser Push Notificaties Engine
* Monitort actuele agenda-afspraken en stuurt browser notificaties op basis van persoonlijk profiel.
*/
(function () {
// 1. Globale state
window.focizNotifSettings = {
enabled: 1,
lead_time_minutes: 5,
repeat_count: 2,
repeat_interval_minutes: 2,
sound_enabled: 1
};
let cachedEvents = [];
let isFetchingEvents = false;
// 2. Service Worker Registratie
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => {
// Service worker geregistreerd
})
.catch(err => {
// Stille fallback indien niet op HTTPS of lokaal
});
}
// 3. Web Audio API Chime Synthesizer
window.playNotificationChime = function () {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const now = ctx.currentTime;
// Noot 1: 587.33 Hz (D5)
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = 'sine';
osc1.frequency.setValueAtTime(587.33, now);
gain1.gain.setValueAtTime(0.2, now);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc1.connect(gain1);
gain1.connect(ctx.destination);
osc1.start(now);
osc1.stop(now + 0.35);
// Noot 2: 880 Hz (A5)
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(880.0, now + 0.12);
gain2.gain.setValueAtTime(0.25, now + 0.12);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.12);
osc2.stop(now + 0.55);
} catch (e) {
console.warn('Audio chime kon niet afspelen:', e);
}
};
// 4. In-App Toast Banner
function showInAppToast(title, body, meetingUrl) {
let container = document.getElementById('focizToastContainer');
if (!container) {
container = document.createElement('div');
container.id = 'focizToastContainer';
container.className = 'fixed bottom-5 right-5 z-[9999] flex flex-col space-y-3 max-w-sm pointer-events-none';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'pointer-events-auto bg-slate-900/95 border border-blue-500/40 text-white p-4 rounded-2xl shadow-2xl backdrop-blur-md flex items-start space-x-3 transform translate-y-4 opacity-0 transition duration-300 ease-out';
const iconDiv = document.createElement('div');
iconDiv.className = 'w-9 h-9 rounded-xl bg-blue-500/20 text-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5 border border-blue-500/30';
iconDiv.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="6" stroke-width="2"/><line x1="3" y1="10" x2="21" y2="10" stroke-width="2"/></svg>';
const contentDiv = document.createElement('div');
contentDiv.className = 'flex-1 min-w-0';
const titleEl = document.createElement('div');
titleEl.className = 'text-xs font-bold text-white truncate';
titleEl.textContent = title;
const bodyEl = document.createElement('div');
bodyEl.className = 'text-[11px] text-slate-300 mt-0.5 leading-snug';
bodyEl.textContent = body;
contentDiv.appendChild(titleEl);
contentDiv.appendChild(bodyEl);
if (meetingUrl) {
const btn = document.createElement('a');
btn.href = meetingUrl;
btn.target = '_blank';
btn.className = 'inline-flex items-center space-x-1 mt-2 px-2.5 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold transition';
btn.innerHTML = '<span>Direct Deelnemen</span> &rarr;';
contentDiv.appendChild(btn);
}
const closeBtn = document.createElement('button');
closeBtn.className = 'text-slate-500 hover:text-white transition -mr-1 -mt-1 p-1';
closeBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
closeBtn.onclick = () => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
};
toast.appendChild(iconDiv);
toast.appendChild(contentDiv);
toast.appendChild(closeBtn);
container.appendChild(toast);
// Animaties
requestAnimationFrame(() => {
toast.classList.remove('opacity-0', 'translate-y-4');
});
// Automatisch verdwijnen na 10 seconden
setTimeout(() => {
if (toast.parentElement) {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
// 5. Browser Notificatie Verzenden
window.sendBrowserNotification = async function (title, body, meetingUrl, tag) {
if (!window.focizNotifSettings.enabled) return;
// Geluidssignaal
if (window.focizNotifSettings.sound_enabled) {
playNotificationChime();
}
// In-app toast banner
showInAppToast(title, body, meetingUrl);
// HTML5 Browser Push Notificatie
if ('Notification' in window && Notification.permission === 'granted') {
try {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const reg = await navigator.serviceWorker.ready;
if (reg && reg.showNotification) {
reg.showNotification(title, {
body: body,
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: tag || 'fociz-' + Date.now(),
data: { url: meetingUrl || '/planner.php' },
requireInteraction: false
});
return;
}
}
} catch (e) {
// Fallback
}
try {
const n = new Notification(title, {
body: body,
icon: '/favicon.ico',
tag: tag || 'fociz-' + Date.now()
});
n.onclick = function () {
window.focus();
if (meetingUrl) {
window.open(meetingUrl, '_blank');
}
n.close();
};
} catch (err) {
console.warn('Desktop notificatie kon niet worden getoond:', err);
}
}
};
// 6. Evaluatie van agenda-afspraken
function evaluateEvents() {
if (!window.focizNotifSettings.enabled) return;
if (!cachedEvents || cachedEvents.length === 0) return;
const now = Date.now();
const leadMs = (parseInt(window.focizNotifSettings.lead_time_minutes) || 5) * 60 * 1000;
const repeatCount = Math.max(1, parseInt(window.focizNotifSettings.repeat_count) || 2);
const intervalMs = (parseInt(window.focizNotifSettings.repeat_interval_minutes) || 2) * 60 * 1000;
cachedEvents.forEach(evt => {
if (!evt.start_raw && !evt.start) return;
// Bepaal starttijd in milliseconds
let startMs = 0;
if (evt.start_raw) {
startMs = new Date(evt.start_raw).getTime();
} else if (evt.start) {
const parts = evt.start.split(':');
const today = new Date();
today.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), 0, 0);
startMs = today.getTime();
}
if (!startMs || isNaN(startMs)) return;
// Check herhalingen
for (let i = 0; i < repeatCount; i++) {
// Eerste notificatie: start - leadMs
// Volgende notificaties: T_0 + i * intervalMs (tot maximaal starttijd)
let triggerMs = startMs - leadMs + (i * intervalMs);
if (triggerMs > startMs) {
triggerMs = startMs;
}
// Venster: notificatie is nu actueel (tussen triggerMs en 4 minuten na de start)
const isDue = (now >= triggerMs) && (now <= startMs + (4 * 60 * 1000));
if (isDue) {
const storageKey = `fociz_notif_${evt.id}_rep_${i}`;
const alreadySent = localStorage.getItem(storageKey);
if (!alreadySent) {
localStorage.setItem(storageKey, String(now));
const minutesLeft = Math.max(0, Math.round((startMs - now) / 60000));
let notifTitle = '';
let notifBody = '';
if (now >= startMs) {
notifTitle = `🚀 Nu gestart: ${evt.subject}`;
notifBody = `Afspraak is begonnen (${evt.start}) • ${evt.location || 'Teams'}`;
} else if (minutesLeft === 0) {
notifTitle = `⚡ Begint zo: ${evt.subject}`;
notifBody = `Start om ${evt.start} • ${evt.location || 'Teams'}`;
} else {
notifTitle = `📅 Over ${minutesLeft} min: ${evt.subject}`;
notifBody = `Aanvang ${evt.start} - ${evt.end} • ${evt.location || 'Teams'}`;
}
sendBrowserNotification(notifTitle, notifBody, evt.meeting_url, `fociz-event-${evt.id}-${i}`);
}
}
}
});
}
// 7. Data ophalen van backend
async function refreshEvents() {
if (isFetchingEvents) return;
isFetchingEvents = true;
try {
const res = await fetch('/api/notifications.php?action=get_events', { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
if (data && data.success) {
cachedEvents = data.events || [];
if (data.settings) {
window.focizNotifSettings = data.settings;
updateDotStatus();
}
evaluateEvents();
}
}
} catch (e) {
// Netwerkfout stil opvangen
} finally {
isFetchingEvents = false;
}
}
// 8. Dot indicator bijwerken op de bel in de header
function updateDotStatus() {
const dot = document.getElementById('focizNotificationDot');
if (!dot) return;
if ('Notification' in window && Notification.permission === 'granted' && window.focizNotifSettings.enabled) {
dot.classList.remove('hidden');
} else {
dot.classList.add('hidden');
}
}
// 9. Modal helpers
window.openNotificationModal = function () {
const modal = document.getElementById('focizNotificationModal');
if (!modal) return;
// Vul actuele waarden in formulier
const s = window.focizNotifSettings;
const enabledCheckbox = document.getElementById('notifEnabledInput');
const leadTimeSelect = document.getElementById('notifLeadTimeSelect');
const repeatCountSelect = document.getElementById('notifRepeatCountSelect');
const intervalSelect = document.getElementById('notifIntervalSelect');
const soundCheckbox = document.getElementById('notifSoundInput');
if (enabledCheckbox) enabledCheckbox.checked = !!parseInt(s.enabled);
if (leadTimeSelect) leadTimeSelect.value = String(s.lead_time_minutes);
if (repeatCountSelect) repeatCountSelect.value = String(s.repeat_count);
if (intervalSelect) intervalSelect.value = String(s.repeat_interval_minutes);
if (soundCheckbox) soundCheckbox.checked = !!parseInt(s.sound_enabled);
updatePermissionBanner();
modal.classList.remove('hidden');
modal.classList.add('flex');
};
window.closeNotificationModal = function () {
const modal = document.getElementById('focizNotificationModal');
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
};
function updatePermissionBanner() {
const grantedDiv = document.getElementById('notifPermGranted');
const requestDiv = document.getElementById('notifPermRequest');
const deniedDiv = document.getElementById('notifPermDenied');
if (!('Notification' in window)) {
if (grantedDiv) grantedDiv.classList.add('hidden');
if (requestDiv) requestDiv.classList.add('hidden');
if (deniedDiv) deniedDiv.classList.remove('hidden');
return;
}
const perm = Notification.permission;
if (perm === 'granted') {
if (grantedDiv) grantedDiv.classList.remove('hidden');
if (requestDiv) requestDiv.classList.add('hidden');
if (deniedDiv) deniedDiv.classList.add('hidden');
} else if (perm === 'denied') {
if (grantedDiv) grantedDiv.classList.add('hidden');
if (requestDiv) requestDiv.classList.add('hidden');
if (deniedDiv) deniedDiv.classList.remove('hidden');
} else {
if (grantedDiv) grantedDiv.classList.add('hidden');
if (requestDiv) requestDiv.classList.remove('hidden');
if (deniedDiv) deniedDiv.classList.add('hidden');
}
}
window.focizRequestPermission = async function () {
if (!('Notification' in window)) {
alert('Uw browser ondersteunt helaas geen HTML5 Web Notificaties.');
return;
}
try {
const res = await Notification.requestPermission();
updatePermissionBanner();
updateDotStatus();
if (res === 'granted') {
sendBrowserNotification('✅ Notificaties Ingeschakeld!', 'Fociz stuurt u nu pushberichten bij actuele afspraken.', null, 'fociz-test-perm');
}
} catch (e) {
console.error(e);
}
};
window.focizTestNotification = function () {
sendBrowserNotification('🔔 Test Notificatie Fociz', 'Dit is hoe uw agenda-herinnering eruit ziet. Het geluid en de melding werken perfect!', '/planner.php', 'fociz-test-manual');
};
window.saveNotificationPreferences = async function (e) {
if (e) e.preventDefault();
const btn = document.getElementById('saveNotifBtn');
if (btn) btn.disabled = true;
const payload = {
enabled: document.getElementById('notifEnabledInput').checked ? 1 : 0,
lead_time_minutes: parseInt(document.getElementById('notifLeadTimeSelect').value, 10),
repeat_count: parseInt(document.getElementById('notifRepeatCountSelect').value, 10),
repeat_interval_minutes: parseInt(document.getElementById('notifIntervalSelect').value, 10),
sound_enabled: document.getElementById('notifSoundInput').checked ? 1 : 0
};
try {
const res = await fetch('/api/notifications.php?action=save_settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const data = await res.json();
if (data && data.success) {
window.focizNotifSettings = data.settings;
updateDotStatus();
const statusMsg = document.getElementById('notifSaveStatus');
if (statusMsg) {
statusMsg.textContent = 'Voorkeuren succesvol opgeslagen!';
statusMsg.classList.remove('hidden');
setTimeout(() => statusMsg.classList.add('hidden'), 3000);
}
setTimeout(() => closeNotificationModal(), 1000);
}
}
} catch (err) {
alert('Fout bij opslaan van voorkeuren: ' + err.message);
} finally {
if (btn) btn.disabled = false;
}
};
// 10. Initialisatie
document.addEventListener('DOMContentLoaded', () => {
refreshEvents();
// Polling interval elke 60 seconden
setInterval(refreshEvents, 60000);
// Evaluatie interval elke 15 seconden
setInterval(evaluateEvents, 15000);
updateDotStatus();
});
})();
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Microsoft 365 OAuth 2.0 Callback Handler
* Verwerkt de autorisatiecode, wisselt tokens uit en slaat de verbinding op.
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
start_secure_session();
// 1. CSRF State validatie & Sessieherstel
$receivedState = $_GET['state'] ?? '';
$stateUserId = !empty($receivedState) ? verify_oauth_state($receivedState) : null;
// Als de sessie verloren is gegaan tijdens de externe cross-site redirect vanaf Microsoft,
// herstellen we de gebruiker veilig aan de hand van de cryptografisch ondertekende OAuth state!
if (empty($_SESSION['user_id']) && $stateUserId) {
try {
$db = get_db_connection();
$stmtUser = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
$stmtUser->execute([$stateUserId]);
$recoveredUser = $stmtUser->fetch();
if ($recoveredUser) {
$_SESSION['user_id'] = $recoveredUser['id'];
$_SESSION['user_name'] = $recoveredUser['name'];
$_SESSION['user_email'] = $recoveredUser['email'];
$_SESSION['user_role'] = $recoveredUser['role'];
$_SESSION['is_2fa_verified'] = true;
issue_remember_token((int)$recoveredUser['id']);
}
} catch (Throwable $e) {
// Foutafhandeling
}
}
// 2. Controleer authenticatie
$user = get_logged_in_user();
if (!$user) {
header('Location: /auth/login.php?error=' . urlencode('Sessie verlopen tijdens Microsoft koppeling. Log opnieuw in en probeer het nogmaals.'));
exit;
}
$db = get_db_connection();
// 3. Foutafhandeling vanuit Microsoft (bijv. gebruiker weigerde machtiging)
if (!empty($_GET['error'])) {
$errorDescription = $_GET['error_description'] ?? $_GET['error'];
header('Location: /profile.php?error=' . urlencode("Microsoft melding: {$errorDescription}"));
exit;
}
// 4. Valideer de state (via HMAC handtekening of fallback sessiestate)
$expectedState = $_SESSION['ms_oauth_state'] ?? '';
unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']);
if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_equals($expectedState, $receivedState))) {
header('Location: /profile.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.'));
exit;
}
// Afhandeling van beheerderstoestemming (Admin Consent)
if (!empty($_GET['admin_consent'])) {
header('Location: /admin.php?success=' . urlencode('Beheerdersgoedkeuring voor uw organisatie is succesvol verleend! U en uw collega\'s kunnen nu direct hun Outlook agenda koppelen via Mijn Profiel.'));
exit;
}
// 5. Autorisatiecode controleren
$code = $_GET['code'] ?? '';
if (empty($code)) {
header('Location: /profile.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.'));
exit;
}
// 4. Centrale instellingen ophalen
$appConfig = microsoft_get_app_config();
$clientId = $appConfig['client_id'];
$clientSecret = $appConfig['client_secret'];
$tenantId = (!empty($appConfig['tenant_id']) && $appConfig['tenant_id'] !== 'common') ? $appConfig['tenant_id'] : 'planningsagenda.nl';
if (empty($clientId) || empty($clientSecret)) {
header('Location: /profile.php?error=' . urlencode('Microsoft 365 configuratie (Client ID of Secret) ontbreekt in Fociz. Neem contact op met de beheerder.'));
exit;
}
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
// 5. Code omwisselen voor access & refresh tokens
$tokenResult = microsoft_exchange_code($code, $clientId, $clientSecret, $redirectUri, $tenantId);
if (!$tokenResult['success']) {
header('Location: /profile.php?error=' . urlencode('Token uitwisseling mislukt: ' . $tokenResult['error']));
exit;
}
$accessToken = $tokenResult['access_token'];
$refreshToken = $tokenResult['refresh_token'];
$expiresIn = $tokenResult['expires_in'];
$expiresAt = time() + $expiresIn;
// 6. Microsoft gebruikersprofiel ophalen
$profile = microsoft_get_user_profile($accessToken);
$accountEmail = $profile['mail'] ?? ($profile['userPrincipalName'] ?? $user['email']);
$accountName = $profile['displayName'] ?? 'Microsoft 365 Account';
// 7. Opslaan in MariaDB
$stmtExisting = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtExisting->execute([$user['id']]);
$existingRow = $stmtExisting->fetch();
$existingSettings = json_decode($existingRow['settings'] ?? '{}', true) ?: [];
$newSettings = array_merge($existingSettings, [
'expires_at' => $expiresAt,
'account_email' => $accountEmail,
'account_name' => $accountName,
]);
// Als de ingelogde gebruiker Beheerder (ADMIN) is, bewaren we ook de centrale app credentials
if ($user['role'] === 'ADMIN') {
$newSettings['client_id'] = $clientId;
$newSettings['client_secret'] = $clientSecret;
$newSettings['tenant_id'] = $tenantId;
}
$saveStmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, access_token, refresh_token, settings, last_sync_at)
VALUES (?, 'OUTLOOK', 1, 0, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_enabled = 1,
is_demo_mode = 0,
access_token = VALUES(access_token),
refresh_token = VALUES(refresh_token),
settings = VALUES(settings),
last_sync_at = NOW()
");
$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]);
// 8. Succesvol terugkeren naar de Persoonlijke Profiel Hub
header('Location: /profile.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!"));
exit;
+217
View File
@@ -0,0 +1,217 @@
<?php
/**
* Fociz Uitnodiging Accepteren & Account Registratie met 2FA
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
$token = trim($_GET['token'] ?? ($_POST['token'] ?? ''));
$invite = validate_invite($token);
if (!$invite) {
die("
<!DOCTYPE html>
<html lang='nl' class='dark'>
<head><script src='https://cdn.tailwindcss.com'></script></head>
<body class='bg-[#0c0f17] text-white min-h-screen flex items-center justify-center p-4'>
<div class='max-w-md bg-[#111622] border border-slate-800 rounded-2xl p-8 text-center shadow-xs'>
<h2 class='text-xl font-bold text-rose-400 mb-2'>Ongeldige Uitnodiging</h2>
<p class='text-sm text-slate-400 mb-6'>Deze uitnodigingslink is ongeldig, verlopen of al eerder gebruikt.</p>
<a href='/auth/login.php' class='px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition shadow-xs'>Naar Inloggen</a>
</div>
</body>
</html>
");
}
$error = null;
if (empty($_SESSION['invite_totp_secret'])) {
$_SESSION['invite_totp_secret'] = TOTP::generateSecret(16);
}
$secret = $_SESSION['invite_totp_secret'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$totpCode = trim($_POST['totp_code'] ?? '');
if (empty($name) || empty($password) || empty($totpCode)) {
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
} elseif (strlen($password) < 8) {
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
} elseif ($password !== $confirmPassword) {
$error = "Wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
$error = "Ongeldige 2FA verificatiecode. Controleer de tijd op uw smartphone.";
} else {
try {
$db = get_db_connection();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$backupCodes = TOTP::generateBackupCodes(8);
$db->beginTransaction();
// 1. Maak gebruiker aan
$stmt = $db->prepare("
INSERT INTO users (name, email, password_hash, role, two_factor_secret, two_factor_enabled, backup_codes)
VALUES (?, ?, ?, ?, ?, 1, ?)
");
$stmt->execute([
$name,
$invite['email'],
$passwordHash,
$invite['role'],
$secret,
json_encode($backupCodes)
]);
$newUserId = (int)$db->lastInsertId();
// 2. Markeer invite als gebruikt
$stmtInvite = $db->prepare("UPDATE invites SET used_at = NOW() WHERE id = ?");
$stmtInvite->execute([$invite['id']]);
$db->commit();
// Start sessie
$_SESSION['user_id'] = $newUserId;
$_SESSION['user_name'] = $name;
$_SESSION['user_email'] = $invite['email'];
$_SESSION['user_role'] = $invite['role'];
$_SESSION['is_2fa_verified'] = true;
issue_remember_token($newUserId);
unset($_SESSION['invite_totp_secret']);
header('Location: /planner.php');
exit;
} catch (Exception $e) {
if ($db->inTransaction()) $db->rollBack();
$error = "Fout bij registreren: " . $e->getMessage();
}
}
}
$otpAuthUrl = TOTP::getOtpAuthUrl($invite['email'], $secret, 'Fociz');
$qrCodeUrl = TOTP::getQrCodeUrl($otpAuthUrl, 180);
$pageTitle = "Fociz — Uitnodiging Accepteren";
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
<script>
(function() {
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
var isDark = true;
if (activeTheme === 'dark') isDark = true;
else if (activeTheme === 'light') isDark = false;
else if (activeTheme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: { 850: '#151f32', 950: '#070b12' }
}
}
}
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>body { font-family: 'Plus Jakarta Sans', sans-serif; }</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
<div class="w-full max-w-lg bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-2xl font-black text-slate-900 dark:text-white">Welkom bij Fociz</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
Uitgenodigd door <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-indigo-600 dark:text-indigo-400 font-mono text-xs bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 px-2 py-0.5 rounded-lg"><?= htmlspecialchars($invite['email']) ?></span>
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<form method="POST" action="/auth/invite.php" class="space-y-4">
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Uw naam</label>
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
</div>
<!-- 2FA Verplichting -->
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
<h3 class="text-sm font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
Verplichte Tweestapsverificatie (2FA)
</h3>
<p class="text-xs text-slate-500 dark:text-slate-400">
Scan deze QR-code in uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
</div>
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono">
Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Voer de 6-cijferige code uit uw app in:
</label>
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
</div>
</div>
<button type="submit" class="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Account Activeren & Inloggen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</form>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+256
View File
@@ -0,0 +1,256 @@
<?php
/**
* Fociz Veilige Login met Tweestapsverificatie (2FA)
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Als er nog geen admin is, direct naar setup
if (!has_admin_user()) {
header('Location: /auth/setup.php');
exit;
}
// Als al ingelogd (via actieve sessie of persistente cookie), ga direct naar planner
$currentUser = get_logged_in_user();
if ($currentUser) {
header('Location: /planner.php');
exit;
}
$error = null;
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
// Terug naar stap 1
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
header('Location: /auth/login.php');
exit;
}
// POST Verwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db = get_db_connection();
// STAP 1: Wachtwoord verificatie
if ($step === 1) {
$email = strtolower(trim($_POST['email'] ?? ''));
$password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) {
$error = "Vul e-mailadres en wachtwoord in.";
} else {
$stmt = $db->prepare("SELECT id, name, email, password_hash, two_factor_secret, two_factor_enabled FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name'];
$step = 2;
} else {
$error = "Onjuist e-mailadres of wachtwoord.";
}
}
}
// STAP 2: 2FA TOTP Code verificatie
elseif ($step === 2) {
$userId = $_SESSION['pending_2fa_user_id'] ?? null;
$totpCode = trim($_POST['totp_code'] ?? '');
if (!$userId) {
header('Location: /auth/login.php');
exit;
}
if (empty($totpCode)) {
$error = "Vul de 6-cijferige 2FA code in uit uw authenticator app.";
} else {
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
$isValid = false;
// Controleer reguliere TOTP code
if (!empty($user['two_factor_secret'])) {
$isValid = verify_totp($user['two_factor_secret'], $totpCode);
}
// Fallback: controleer 8-karakter backup codes
if (!$isValid && !empty($user['backup_codes'])) {
$backupCodes = json_decode($user['backup_codes'], true) ?: [];
$codeUpper = strtoupper($totpCode);
$key = array_search($codeUpper, $backupCodes);
if ($key !== false) {
$isValid = true;
// Verbruik de backup code
unset($backupCodes[$key]);
$stmtUpdate = $db->prepare("UPDATE users SET backup_codes = ? WHERE id = ?");
$stmtUpdate->execute([json_encode(array_values($backupCodes)), $userId]);
}
}
if ($isValid) {
// Volledige login succesvol
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_role'] = $user['role'];
$_SESSION['is_2fa_verified'] = true;
// Activeer persistente remember token (30 dagen blijf ingelogd)
issue_remember_token((int)$user['id']);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
header('Location: /planner.php');
exit;
} else {
$error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw.";
}
}
}
}
$pageTitle = "Fociz — Inloggen";
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage vóór DOM render -->
<script>
(function() {
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
var isDark = true;
if (activeTheme === 'dark') isDark = true;
else if (activeTheme === 'light') isDark = false;
else if (activeTheme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
850: '#151f32',
950: '#070b12',
}
}
}
}
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
<div class="w-full max-w-md bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
Veilig inloggen met tweestapsverificatie (2FA)
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($step === 1): ?>
<!-- STAP 1: E-mail & Wachtwoord -->
<form method="POST" action="/auth/login.php" class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres</label>
<div class="relative">
<input type="email" name="email" required autofocus placeholder="uw.naam@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord</label>
<div class="relative">
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
</div>
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Verder naar 2FA</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
</form>
<?php else: ?>
<!-- STAP 2: 2FA TOTP Code -->
<form method="POST" action="/auth/login.php" class="space-y-5">
<div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
<i data-lucide="key" class="w-6 h-6"></i>
</div>
<p class="text-sm text-slate-800 dark:text-slate-300 font-semibold">
Voer uw 6-cijferige verificatiecode in
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
Gekoppeld aan <strong class="text-slate-900 dark:text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
</p>
</div>
<div>
<input type="text" name="totp_code" required autofocus maxlength="9" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
<span class="text-[11px] text-slate-500 dark:text-slate-500 text-center block mt-1">
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
</span>
</div>
<div class="flex space-x-3">
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center cursor-pointer">
Terug
</a>
<button type="submit" class="w-2/3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Inloggen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</div>
</form>
<?php endif; ?>
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center text-xs text-slate-500">
Toegang tot Fociz is strikt op uitnodiging. Vraag uw beheerder om een uitnodigingslink.
</div>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Fociz Uitloggen
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Vernietig persistente login in database
if (!empty($_SESSION['user_id'])) {
try {
$db = get_db_connection();
ensure_remember_token_columns($db);
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
} catch (Throwable $e) {
// Stil negeren bij uitloggen
}
}
// Verwijder remember cookie
clear_remember_cookie();
// Vernietig alle sessie variabelen
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params["path"],
$params["domain"],
$params["secure"],
$params["httponly"]
);
}
session_destroy();
header('Location: /auth/login.php');
exit;
+50
View File
@@ -0,0 +1,50 @@
<?php
/**
* Startpunt voor Microsoft 365 OAuth 2.0 Flow
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
$user = require_auth();
$db = get_db_connection();
// Haal centrale Microsoft app configuratie op (env of admin instellingen)
$appConfig = microsoft_get_app_config();
$clientId = !empty($_GET['client_id']) ? trim($_GET['client_id']) : $appConfig['client_id'];
$tenantId = !empty($_GET['tenant_id']) ? trim($_GET['tenant_id']) : $appConfig['tenant_id'];
// Voorkom AADSTS50194: single-tenant Azure applicaties mogen NOOIT het /common endpoint gebruiken
if (empty($tenantId) || $tenantId === 'common') {
$userEmail = $user['email'] ?? '';
$parts = explode('@', $userEmail);
$domain = strtolower(trim(end($parts)));
if (!empty($domain) && !in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
$tenantId = $domain;
} else {
$tenantId = 'planningsagenda.nl';
}
}
if (empty($clientId)) {
$err = ($user['role'] === 'ADMIN')
? 'Vul eerst de Application (Client) ID en het Client Secret in bij het Systeembeheer om te koppelen met Microsoft 365.'
: 'Microsoft 365 is nog niet geconfigureerd door de beheerder van uw organisatie. Vraag uw beheerder om de koppeling in te stellen.';
header('Location: /profile.php?error=' . urlencode($err));
exit;
}
// Bepaal de dynamische of geconfigureerde redirect URI
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
// Genereer cryptografisch ondertekende OAuth state waarin de user_id en timestamp veilig zijn opgeslagen
$state = generate_oauth_state((int)$user['id']);
$_SESSION['ms_oauth_state'] = $state;
$_SESSION['ms_oauth_user_id'] = (int)$user['id'];
// Stuur gebruiker door naar Microsoft login
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state);
header("Location: {$authUrl}");
exit;
+242
View File
@@ -0,0 +1,242 @@
<?php
/**
* Fociz First-Run Admin Initialisatie Wizard
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Als er al een beheerder bestaat, sluit deze pagina direct af
if (has_admin_user()) {
header('Location: /auth/login.php');
exit;
}
$error = null;
$success = false;
$backupCodes = [];
$email = '';
// Haal of genereer een tijdelijke sessie-sleutel voor de 2FA configuratie
if (empty($_SESSION['setup_totp_secret'])) {
$_SESSION['setup_totp_secret'] = TOTP::generateSecret(16);
}
$secret = $_SESSION['setup_totp_secret'];
// Formulierverwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$email = strtolower(trim($_POST['email'] ?? ''));
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$totpCode = trim($_POST['totp_code'] ?? '');
if (empty($name) || empty($email) || empty($password) || empty($totpCode)) {
$error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht.";
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in.";
} elseif (strlen($password) < 8) {
$error = "Het wachtwoord moet minimaal 8 tekens bevatten.";
} elseif ($password !== $confirmPassword) {
$error = "De ingevoerde wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
$error = "Ongeldige 2FA verificatiecode. Zorg dat de tijd op uw telefoon gelijk loopt en probeer opnieuw.";
} else {
// Alles klopt: maak de allereerste Admin gebruiker aan in MariaDB!
try {
$db = get_db_connection();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$backupCodes = TOTP::generateBackupCodes(8);
$stmt = $db->prepare("
INSERT INTO users (name, email, password_hash, role, two_factor_secret, two_factor_enabled, backup_codes)
VALUES (?, ?, ?, 'ADMIN', ?, 1, ?)
");
$stmt->execute([
$name,
$email,
$passwordHash,
$secret,
json_encode($backupCodes)
]);
$adminId = (int)$db->lastInsertId();
// Automatische sessie starten
$_SESSION['user_id'] = $adminId;
$_SESSION['user_name'] = $name;
$_SESSION['user_email'] = $email;
$_SESSION['user_role'] = 'ADMIN';
$_SESSION['is_2fa_verified'] = true;
issue_remember_token($adminId);
unset($_SESSION['setup_totp_secret']);
$success = true;
} catch (Exception $e) {
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
}
}
}
$otpAuthUrl = TOTP::getOtpAuthUrl(!empty($email) ? $email : 'admin@fociz.local', $secret, 'Fociz');
$qrCodeUrl = TOTP::getQrCodeUrl($otpAuthUrl, 200);
$pageTitle = "Fociz — Eerste Initialisatie";
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<!-- Anti-flicker script: Laad direct het gekozen thema uit localStorage -->
<script>
(function() {
var activeTheme = localStorage.getItem('fociz_theme') || 'dark';
var isDark = true;
if (activeTheme === 'dark') isDark = true;
else if (activeTheme === 'light') isDark = false;
else if (activeTheme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: { 850: '#151f32', 950: '#070b12' }
}
}
}
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>body { font-family: 'Plus Jakarta Sans', sans-serif; }</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4 bg-[#f8fafc] dark:bg-[#0c0f17] text-slate-900 dark:text-slate-100 transition-colors">
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
<div class="w-full max-w-xl bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl shadow-xs backdrop-blur-xl p-8 transition">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800/60 text-indigo-600 dark:text-indigo-400 mb-3 shadow-xs">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Fociz Initialisatie</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">
Eenmalige configuratie van het hoofdbeheerdersaccount met verplichte 2FA
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-700 dark:text-red-400 text-sm">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($success): ?>
<!-- Stap 3: Voltooid met backup codes -->
<div class="text-center space-y-6">
<div class="w-16 h-16 bg-teal-50 dark:bg-teal-950/40 text-teal-600 dark:text-teal-400 rounded-2xl flex items-center justify-center mx-auto border border-teal-200 dark:border-teal-800/60 shadow-xs">
<i data-lucide="check-circle-2" class="w-10 h-10"></i>
</div>
<div>
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-1">Beheerder Geïnstalleerd!</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm">
Uw account is aangemaakt en direct beveiligd met 2FA. Bewaar deze eenmalige herstelcodes veilig:
</p>
</div>
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-2xl p-4 text-left">
<div class="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-800 dark:text-slate-300">
<?php foreach ($backupCodes as $code): ?>
<span class="bg-white dark:bg-[#111622] px-2.5 py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-center font-bold"><?= htmlspecialchars($code) ?></span>
<?php endforeach; ?>
</div>
</div>
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs space-x-2 cursor-pointer">
<span>Start met Fociz Dagplanning</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
<?php else: ?>
<form method="POST" action="/auth/setup.php" class="space-y-5">
<!-- Account Info -->
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Volledige naam</label>
<input type="text" name="name" required placeholder="Bijv. Jan de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beheerder E-mailadres</label>
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-sm transition">
</div>
</div>
</div>
<!-- 2FA QR Code & Koppeling -->
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800/80 text-center space-y-4">
<h3 class="text-sm font-bold uppercase tracking-wider flex items-center justify-center space-x-1.5 text-indigo-600 dark:text-indigo-400">
<i data-lucide="key" class="w-4 h-4"></i>
<span>Tweestapsverificatie (2FA) Koppelen</span>
</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 max-w-md mx-auto">
Scan onderstaande QR-code met uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xs border border-slate-200 dark:border-slate-800">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
</div>
<div class="bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800 rounded-xl p-2.5 text-xs text-slate-600 dark:text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
<span>Sleutel: <strong class="text-indigo-600 dark:text-indigo-400"><?= htmlspecialchars($secret) ?></strong></span>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Voer de 6-cijferige verificatiecode uit uw app in:
</label>
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl py-2 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 transition">
</div>
</div>
<button type="submit" class="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<span>Account & 2FA Bevestigen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</form>
<?php endif; ?>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
<?php
/**
* Fociz Brainstorm Sessies Overzicht
*/
require_once __DIR__ . '/includes/auth.php';
$currentUser = require_auth();
$db = get_db_connection();
// Verwerk nieuwe sessie aanmaken
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') {
$title = trim($_POST['title'] ?? '');
$theme = trim($_POST['theme'] ?? 'Product & Strategie');
$description = trim($_POST['description'] ?? '');
if (!empty($title)) {
$stmt = $db->prepare("
INSERT INTO brainstorm_sessions (title, theme, description, created_by_id)
VALUES (?, ?, ?, ?)
");
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
$newId = $db->lastInsertId();
header("Location: /brainstorm_canvas.php?id={$newId}");
exit;
} else {
$error = "Titel van de brainstormsessie is verplicht.";
}
}
// Haal alle sessies op
$stmtSessions = $db->query("
SELECT s.*, u.name as creator_name,
(SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count
FROM brainstorm_sessions s
JOIN users u ON s.created_by_id = u.id
ORDER BY s.created_at DESC
");
$sessions = $stmtSessions->fetchAll();
require_once __DIR__ . '/includes/header.php';
?>
<div class="space-y-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div>
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Creatie & Innovatie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white tracking-tight">
Brainstorm Sessies
</h1>
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1">
Brainstorm met collega's, verzamel ideeën en knelpunten, en transformeer ze direct tot een werkbare roadmap.
</p>
</div>
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs flex-shrink-0 cursor-pointer">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuwe Sessie Starten</span>
</button>
</div>
<?php if ($error): ?>
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-700 dark:text-rose-400 text-sm">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<!-- Grid met sessies -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<?php if (empty($sessions)): ?>
<div class="col-span-full bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-12 text-center shadow-xs">
<i data-lucide="lightbulb" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
<h3 class="text-base font-semibold text-slate-900 dark:text-white">Nog geen brainstormsessies gestart</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
Start uw eerste brainstormsessie om ideeën te verzamelen en ze met één klik om te vormen naar planningsitems.
</p>
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-xl text-xs transition cursor-pointer shadow-xs">
Sessie Starten
</button>
</div>
<?php else: ?>
<?php foreach ($sessions as $sess): ?>
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group bg-white hover:bg-slate-50/70 dark:bg-[#111622] dark:hover:bg-[#151b2a] border border-slate-200/80 hover:border-indigo-300 dark:border-slate-800 dark:hover:border-indigo-700/60 rounded-2xl p-5 shadow-xs hover:shadow-sm transition flex flex-col justify-between">
<div>
<div class="flex items-center justify-between mb-3">
<span class="px-2.5 py-0.5 rounded-md bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-400 font-medium text-[11px]">
<?= htmlspecialchars($sess['theme']) ?>
</span>
<span class="flex items-center space-x-1 text-slate-400 dark:text-slate-500 text-xs">
<i data-lucide="message-square" class="w-3.5 h-3.5"></i>
<span><?= $sess['cards_count'] ?> kaarten</span>
</span>
</div>
<h3 class="text-base font-bold text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-snug">
<?= htmlspecialchars($sess['title']) ?>
</h3>
<?php if (!empty($sess['description'])): ?>
<p class="text-slate-600 dark:text-slate-400 text-xs mt-2 line-clamp-2 leading-relaxed">
<?= htmlspecialchars($sess['description']) ?>
</p>
<?php endif; ?>
</div>
<div class="mt-5 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span>Door <?= htmlspecialchars($sess['creator_name']) ?></span>
<div class="flex items-center space-x-1 text-indigo-600 dark:text-indigo-400 group-hover:translate-x-1 transition font-medium">
<span>Open canvas</span>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</div>
</div>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Modal: Nieuwe sessie -->
<div id="newSessionModal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xl max-w-lg w-full transition">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="sparkles" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuwe Brainstormsessie</span>
</h3>
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="text-slate-400 hover:text-slate-600 dark:hover:text-white cursor-pointer">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form method="POST" action="/brainstorm.php" class="space-y-4">
<input type="hidden" name="action" value="create_session">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Onderwerp / Titel</label>
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Thema / Categorie</label>
<select name="theme" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="Product & Strategie">Product & Strategie</option>
<option value="Procesverbetering">Procesverbetering</option>
<option value="Klant & Service">Klant & Service</option>
<option value="Technologie & IT">Technologie & IT</option>
<option value="Marketing & Groei">Marketing & Groei</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Doel / Beschrijving</label>
<input type="text" name="description" placeholder="Doel van deze sessie..." class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
<div class="flex space-x-2 pt-2">
<button type="submit" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Sessie Aanmaken & Canvas Openen
</button>
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs transition cursor-pointer">
Annuleren
</button>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+560
View File
@@ -0,0 +1,560 @@
<?php
/**
* Fociz Interactief Brainstorm Canvas & Roadmap Converter
*/
require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/includes/settings.php';
$db = get_db_connection();
$aiSettings = get_ai_settings();
$sessionId = (int)($_GET['id'] ?? 0);
$stmtSession = $db->prepare("
SELECT s.*, u.name as creator_name
FROM brainstorm_sessions s
JOIN users u ON s.created_by_id = u.id
WHERE s.id = ?
");
$stmtSession->execute([$sessionId]);
$session = $stmtSession->fetch();
if (!$session) {
echo "<div class='p-8 text-center text-red-500'>Brainstormsessie niet gevonden. <a href='/brainstorm.php' class='underline'>Terug</a></div>";
require_once __DIR__ . '/includes/footer.php';
exit;
}
// Haal alle kaarten op van deze sessie
$stmtCards = $db->prepare("
SELECT * FROM brainstorm_cards
WHERE session_id = ?
ORDER BY votes DESC, created_at ASC
");
$stmtCards->execute([$sessionId]);
$cards = $stmtCards->fetchAll();
$categories = [
'Ideeën' => [
'title' => 'Ideeën & Concepten',
'icon' => 'lightbulb',
'color' => 'amber',
'border' => 'border-amber-200/80 dark:border-amber-800/40',
'bg' => 'bg-amber-50/60 dark:bg-amber-950/20',
'badge' => 'bg-amber-500/15 text-amber-800 dark:text-amber-300'
],
'Knelpunten' => [
'title' => 'Knelpunten & Uitdagingen',
'icon' => 'alert-triangle',
'color' => 'rose',
'border' => 'border-rose-200/80 dark:border-rose-800/40',
'bg' => 'bg-rose-50/60 dark:bg-rose-950/20',
'badge' => 'bg-rose-500/15 text-rose-800 dark:text-rose-300'
],
'Oplossingen' => [
'title' => 'Oplossingen & Features',
'icon' => 'rocket',
'color' => 'teal',
'border' => 'border-teal-200/80 dark:border-teal-800/40',
'bg' => 'bg-teal-50/60 dark:bg-teal-950/20',
'badge' => 'bg-teal-500/15 text-teal-800 dark:text-teal-300'
],
'Quick Wins' => [
'title' => 'Quick Wins (Direct)',
'icon' => 'zap',
'color' => 'indigo',
'border' => 'border-indigo-200/80 dark:border-indigo-800/40',
'bg' => 'bg-indigo-50/60 dark:bg-indigo-950/20',
'badge' => 'bg-indigo-500/15 text-indigo-800 dark:text-indigo-300'
],
];
?>
<div class="space-y-6 max-w-7xl mx-auto">
<!-- Top Bar -->
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-5 shadow-xs transition">
<div class="flex items-center space-x-4">
<a href="/brainstorm.php" class="p-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition cursor-pointer">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
</a>
<div>
<div class="flex items-center space-x-2 text-xs">
<span class="px-2.5 py-0.5 rounded-md bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-400 font-semibold border border-indigo-200 dark:border-indigo-500/20">
<?= htmlspecialchars($session['theme']) ?>
</span>
<span class="text-slate-400 dark:text-slate-500">Gestart door <?= htmlspecialchars($session['creator_name']) ?></span>
</div>
<h1 class="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white tracking-tight mt-0.5">
<?= htmlspecialchars($session['title']) ?>
</h1>
</div>
</div>
<div class="flex flex-wrap items-center gap-2.5">
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
<button onclick="aiGenerateIdeas(this)" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl bg-violet-50 hover:bg-violet-100 dark:bg-violet-950/50 dark:hover:bg-violet-900/50 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-800/60 text-xs font-semibold transition cursor-pointer shadow-xs" title="Genereer 4 innovatieve ideeën via AI Co-Pilot">
<i data-lucide="sparkles" class="w-4 h-4 text-violet-500"></i>
<span>💡 AI Ideeën</span>
</button>
<button onclick="aiCategorizeCards(this)" class="flex items-center space-x-1.5 px-3 py-2 rounded-xl bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/50 dark:hover:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60 text-xs font-semibold transition cursor-pointer shadow-xs" title="Sorteer alle kaartjes automatisch in Ideeën, Knelpunten, Oplossingen of Quick Wins">
<i data-lucide="wand-2" class="w-4 h-4 text-indigo-500"></i>
<span>✨ Auto-Categoriseer</span>
</button>
<?php endif; ?>
<button onclick="openAddCardModal()" class="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 text-xs font-semibold border border-slate-200 dark:border-slate-700 transition cursor-pointer shadow-xs">
<i data-lucide="plus" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuwe Kaart</span>
</button>
<!-- De Kernfeature: Zet om naar Roadmap! -->
<button onclick="openConvertModal()" class="flex items-center space-x-2 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs cursor-pointer">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Zet om naar Roadmap</span>
</button>
</div>
</div>
<!-- Whiteboard Categorie Kolommen -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5">
<?php foreach ($categories as $catKey => $catConfig):
$catCards = array_filter($cards, fn($c) => $c['category'] === $catKey);
?>
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-4 flex flex-col min-h-[500px] shadow-xs transition">
<!-- Column Header -->
<div class="flex items-center justify-between pb-3 mb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="<?= $catConfig['icon'] ?>" class="w-4 h-4 text-<?= $catConfig['color'] ?>-500"></i>
<h3 class="text-xs font-semibold text-slate-900 dark:text-white uppercase tracking-wider"><?= $catConfig['title'] ?></h3>
</div>
<span class="text-xs font-mono font-semibold px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">
<?= count($catCards) ?>
</span>
</div>
<!-- Cards Container -->
<div class="flex-1 space-y-3">
<?php if (empty($catCards)): ?>
<div class="py-8 text-center text-slate-400 dark:text-slate-600 text-xs">
Nog geen kaarten in deze categorie.
</div>
<?php else: ?>
<?php foreach ($catCards as $card): ?>
<div class="group relative rounded-xl p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> backdrop-blur-sm shadow-xs transition hover:-translate-y-0.5" id="card-<?= $card['id'] ?>">
<!-- Top: Checkbox voor selectie + Roadmap badge -->
<div class="flex items-center justify-between mb-2">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" value="<?= $card['id'] ?>" data-content="<?= htmlspecialchars($card['content'], ENT_QUOTES) ?>" class="card-checkbox w-4 h-4 rounded bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-indigo-600 focus:ring-0">
<span class="text-[10px] text-slate-500 dark:text-slate-400">Selecteer</span>
</label>
<?php if (!empty($card['converted_to_roadmap_item_id'])): ?>
<span class="px-2 py-0.5 rounded-md bg-teal-50 dark:bg-teal-500/15 text-teal-700 dark:text-teal-400 font-semibold text-[10px] flex items-center space-x-1 border border-teal-200 dark:border-teal-500/20">
<i data-lucide="check" class="w-3 h-3"></i>
<span>Op Roadmap</span>
</span>
<?php endif; ?>
</div>
<!-- Inhoud -->
<p class="text-xs text-slate-800 dark:text-slate-200 leading-relaxed whitespace-pre-wrap font-medium">
<?= htmlspecialchars($card['content']) ?>
</p>
<!-- Footer van kaart -->
<div class="mt-3 pt-2.5 border-t border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between text-[10px] text-slate-500 dark:text-slate-400">
<span><?= htmlspecialchars($card['author_name'] ?? 'Collega') ?></span>
<div class="flex items-center space-x-2">
<!-- Upvote button -->
<button onclick="voteCard(<?= $card['id'] ?>)" class="flex items-center space-x-1 px-2 py-1 rounded-lg bg-white/80 hover:bg-white dark:bg-slate-800/80 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 hover:text-amber-500 dark:hover:text-amber-400 border border-slate-200/80 dark:border-transparent transition shadow-xs cursor-pointer">
<i data-lucide="thumbs-up" class="w-3 h-3"></i>
<span id="votes-<?= $card['id'] ?>" class="font-bold"><?= $card['votes'] ?></span>
</button>
<!-- Delete button -->
<button onclick="deleteCard(<?= $card['id'] ?>)" class="p-1 rounded text-slate-400 hover:text-rose-600 dark:text-slate-500 dark:hover:text-rose-400 transition opacity-0 group-hover:opacity-100 cursor-pointer">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Quick add button at column bottom -->
<button onclick="openAddCardModal('<?= $catKey ?>')" class="mt-3 w-full py-2 rounded-xl border border-dashed border-slate-200 dark:border-slate-800 hover:border-indigo-300 dark:hover:border-indigo-700/60 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 text-xs transition flex items-center justify-center space-x-1 cursor-pointer">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span>Kaart toevoegen</span>
</button>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Modal: Nieuwe Kaart Toevoegen -->
<div id="addCardModal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800 rounded-2xl p-6 shadow-xl max-w-md w-full transition">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="sticky-note" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Brainstormkaart Toevoegen</span>
</h3>
<button onclick="closeAddCardModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-white cursor-pointer">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Categorie</label>
<select id="modalCardCategory" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="Ideeën">💡 Ideeën & Concepten</option>
<option value="Knelpunten">⚠️ Knelpunten & Uitdagingen</option>
<option value="Oplossingen">🚀 Oplossingen & Features</option>
<option value="Quick Wins">⚡ Quick Wins</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Idee / Beschrijving</label>
<textarea id="modalCardContent" rows="4" placeholder="Schrijf hier uw inzicht, idee of knelpunt..." class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"></textarea>
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitNewCard()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Plaats op Canvas
</button>
<button onclick="closeAddCardModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Zet om naar Roadmap -->
<div id="convertModal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800 rounded-2xl p-6 shadow-xl max-w-lg w-full transition">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="map" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Zet Brainstorm om naar Roadmap</span>
</h3>
<button onclick="closeConvertModal()" class="text-slate-400 hover:text-slate-600 dark:hover:text-white cursor-pointer">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="space-y-4">
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 rounded-xl p-3 text-xs text-indigo-800 dark:text-indigo-300">
<span id="selectedCountText" class="font-bold">0</span> kaarten geselecteerd om om te zetten naar planbare roadmap items.
</div>
<?php if ($aiSettings['ai_enabled'] && $aiSettings['has_key']): ?>
<div class="p-3 rounded-xl bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/60 flex items-center justify-between">
<div class="flex items-center space-x-2 text-xs text-violet-900 dark:text-violet-200">
<i data-lucide="sparkles" class="w-4 h-4 text-violet-500 flex-shrink-0"></i>
<span class="text-[11px] font-medium leading-tight">Laat AI Co-Pilot kwartaal, type & prioriteit adviseren</span>
</div>
<button type="button" onclick="aiSuggestRoadmapMeta(this)" id="aiSuggestBtn" class="px-2.5 py-1 bg-violet-600 hover:bg-violet-700 text-white rounded-lg text-xs font-semibold transition flex items-center space-x-1 cursor-pointer flex-shrink-0 shadow-xs">
<span>✨ AI Advies</span>
</button>
</div>
<?php endif; ?>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Doel Kwartaal</label>
<select id="roadmapQuarter" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="Q1">Q1 (Jan - Mrt)</option>
<option value="Q2">Q2 (Apr - Jun)</option>
<option value="Q3" selected>Q3 (Jul - Sep)</option>
<option value="Q4">Q4 (Okt - Dec)</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Item Type</label>
<select id="roadmapType" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="FEATURE" selected>Feature / Functionaliteit</option>
<option value="EPIC">Epic (Groot project)</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Oplos-Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
<select id="roadmapPriority" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="HIGH" selected>Hoog (Belangrijke milestone)</option>
<option value="CRITICAL">Kritiek (Must-have)</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag / Backlog</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum (Deadline)</label>
<input type="date" id="roadmapDueDate" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitConvertToRoadmap()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-2.5 px-4 rounded-xl text-xs transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<i data-lucide="check-circle" class="w-4 h-4"></i>
<span>Definitief Toevoegen aan Roadmap</span>
</button>
<button onclick="closeConvertModal()" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
</div>
<script>
const sessionId = <?= $sessionId ?>;
function openAddCardModal(defaultCategory = null) {
if (defaultCategory) {
document.getElementById('modalCardCategory').value = defaultCategory;
}
document.getElementById('addCardModal').classList.remove('hidden');
document.getElementById('modalCardContent').focus();
}
function closeAddCardModal() {
document.getElementById('addCardModal').classList.add('hidden');
document.getElementById('modalCardContent').value = '';
}
async function submitNewCard() {
const category = document.getElementById('modalCardCategory').value;
const content = document.getElementById('modalCardContent').value.trim();
if (!content) return;
const res = await fetch('/api/brainstorm.php?action=add_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ session_id: sessionId, category, content })
});
const data = await res.json();
if (data.success) {
location.reload();
}
}
async function voteCard(cardId) {
const res = await fetch('/api/brainstorm.php?action=vote_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cardId })
});
const data = await res.json();
if (data.success) {
document.getElementById(`votes-${cardId}`).innerText = data.votes;
}
}
async function deleteCard(cardId) {
if (!confirm('Kaart verwijderen?')) return;
await fetch('/api/brainstorm.php?action=delete_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cardId })
});
document.getElementById(`card-${cardId}`)?.remove();
}
// --- Roadmap Conversie Logica ---
function getSelectedCardIds() {
const checkboxes = document.querySelectorAll('.card-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function openConvertModal() {
const selectedIds = getSelectedCardIds();
if (selectedIds.length === 0) {
alert('Vink eerst minimaal één kaart aan om om te zetten naar de roadmap.');
return;
}
document.getElementById('selectedCountText').innerText = selectedIds.length;
document.getElementById('convertModal').classList.remove('hidden');
}
function closeConvertModal() {
document.getElementById('convertModal').classList.add('hidden');
}
async function submitConvertToRoadmap() {
const cardIds = getSelectedCardIds();
const quarter = document.getElementById('roadmapQuarter').value;
const type = document.getElementById('roadmapType').value;
const priority = document.getElementById('roadmapPriority').value;
const dueDate = document.getElementById('roadmapDueDate').value;
const res = await fetch('/api/brainstorm.php?action=convert_to_roadmap', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
card_ids: cardIds,
quarter,
type,
priority,
due_date: dueDate
})
});
const data = await res.json();
if (data.success) {
alert(data.message);
window.location.href = '/roadmap.php';
} else {
alert(data.error || 'Er is een fout opgetreden.');
}
}
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
async function aiGenerateIdeas(btn) {
if (!confirm('Wil je AI Co-Pilot 4 innovatieve ideeën laten genereren en direct op het canvas plaatsen?')) return;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Genereren...</span></span>';
btn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try {
const res = await fetch('/api/ai.php?action=generate_ideas', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ session_id: sessionId, count: 4 })
});
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert(data.error || 'Kon geen ideeën genereren via AI');
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
} catch(err) {
alert('Netwerkfout bij aanroepen van AI');
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
}
async function aiCategorizeCards(btn) {
if (!confirm('Wil je dat AI Co-Pilot alle kaartjes automatisch classificeert in de juiste kolom (Ideeën, Knelpunten, Oplossingen of Quick Wins)?')) return;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Indelen...</span></span>';
btn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try {
const res = await fetch('/api/ai.php?action=categorize_cards', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ session_id: sessionId })
});
const data = await res.json();
if (data.success) {
alert(`Succes! ${data.updated} kaartjes zijn door AI Co-Pilot opnieuw gecategoriseerd.`);
location.reload();
} else {
alert(data.error || 'Kon kaartjes niet categoriseren');
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
} catch(err) {
alert('Netwerkfout bij aanroepen van AI');
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
}
async function aiSuggestRoadmapMeta(btn) {
const checkboxes = document.querySelectorAll('.card-checkbox:checked');
if (checkboxes.length === 0) return;
const firstContent = checkboxes[0].dataset.content || '';
if (!firstContent) return;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Analyseren...</span></span>';
btn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try {
const res = await fetch('/api/ai.php?action=summarize_to_roadmap', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
content: firstContent,
session_title: '<?= htmlspecialchars(addslashes($session['title'])) ?>'
})
});
const data = await res.json();
if (data.success && data.item) {
if (data.item.quarter && document.getElementById('roadmapQuarter')) {
document.getElementById('roadmapQuarter').value = data.item.quarter;
}
if (data.item.type && document.getElementById('roadmapType')) {
document.getElementById('roadmapType').value = data.item.type;
}
if (data.item.priority && document.getElementById('roadmapPriority')) {
document.getElementById('roadmapPriority').value = data.item.priority;
}
if (btn) {
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld!</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}, 1800);
}
} else {
alert(data.error || 'Kon geen AI advies ophalen');
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
} catch(err) {
alert('Netwerkfout bij ophalen van AI advies');
if (btn) {
btn.innerHTML = origHtml;
btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+9
View File
@@ -0,0 +1,9 @@
{
"require": {
"php": "^8.2",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-mysqli": "*"
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* MariaDB PDO Databaseverbinding & Automatische Migratie
*/
require_once __DIR__ . '/config.php';
function get_db_connection(): PDO {
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
// 1. Controleer of de PDO MySQL/MariaDB driver aanwezig is in PHP
$availableDrivers = PDO::getAvailableDrivers();
if (!in_array('mysql', $availableDrivers)) {
$driversList = !empty($availableDrivers) ? implode(', ', $availableDrivers) : 'geen';
die("
<div style='font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background: #0c0f17; color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px;'>
<div style='max-width: 580px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5);'>
<h2 style='color: #ef4444; margin-top: 0;'>⚠️ PHP MySQL Driver Ontbreekt (pdo_mysql)</h2>
<p style='color: #94a3b8; font-size: 14px;'>De extensie <strong>pdo_mysql</strong> is niet actief in deze PHP-omgeving.</p>
</div>
</div>
");
}
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_TIMEOUT => 5,
];
if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
$options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = true;
}
$dsnWithDb = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
DB_HOST,
DB_PORT,
DB_NAME,
DB_CHARSET
);
// 2. CHECK: Bestaat de database al?
// We proberen eerst direct verbinding te maken met de database.
// Als de database al bestaat, lukt dit direct zonder database-aanmaakrechten te vereisen!
try {
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
// De database bestaat al! Controleer enkel of de tabellen al aanwezig zijn
ensure_tables_exist($pdo);
return $pdo;
} catch (PDOException $e) {
// Foutcode 1049 betekent: Database 'DB_NAME' bestaat nog NIET op de MariaDB server
if ($e->getCode() == 1049 || str_contains($e->getMessage(), 'Unknown database')) {
// Probeer de database eenmalig aan te maken indien de gebruiker CREATE rechten heeft
try {
$serverDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
$serverPdo = new PDO($serverDsn, DB_USER, DB_PASS, $options);
$serverPdo->exec(sprintf(
'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
DB_NAME
));
// Nu opnieuw verbinden met de nieuw aangemaakte database
$pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
ensure_tables_exist($pdo);
return $pdo;
} catch (PDOException $createEx) {
die("
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 500px; background: #111827; border: 1px solid #f59e0b; border-radius: 16px; padding: 30px; text-align: left;'>
<h2 style='color: #f59e0b; margin-top: 0;'>⚠️ Database '" . htmlspecialchars(DB_NAME) . "' bestaat nog niet</h2>
<p style='color: #94a3b8; font-size: 14px; line-height: 1.6;'>
De database <strong>" . htmlspecialchars(DB_NAME) . "</strong> bestaat nog niet op MariaDB server <strong>" . htmlspecialchars(DB_HOST) . "</strong> en de gebruiker <em>" . htmlspecialchars(DB_USER) . "</em> heeft geen rechten om automatisch databases aan te maken.
</p>
<p style='color: #cbd5e1; font-size: 13px;'>
Maak de database <code>" . htmlspecialchars(DB_NAME) . "</code> eenmalig aan via phpMyAdmin, HeidiSQL of de MariaDB CLI:
</p>
<code style='display: block; background: #000; color: #4ade80; padding: 10px; border-radius: 8px; font-size: 12px; margin: 10px 0;'>
CREATE DATABASE IF NOT EXISTS `" . htmlspecialchars(DB_NAME) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
</code>
</div>
</div>
");
}
}
// Andere verbindingsfout (bijv. verkeerd wachtwoord, poort of host onbereikbaar)
die("
<div style='font-family: sans-serif; background: #0c0f17; color: #fff; padding: 40px; text-align: center; min-height: 100vh; display: flex; align-items: center; justify-content: center;'>
<div style='max-width: 520px; background: #111827; border: 1px solid #ef4444; border-radius: 16px; padding: 30px; text-align: left;'>
<h2 style='color: #ef4444; margin-top: 0;'>MariaDB Verbindingsfout (" . htmlspecialchars((string)$e->getCode()) . ")</h2>
<p style='color: #cbd5e1; font-size: 14px;'>" . htmlspecialchars($e->getMessage()) . "</p>
<div style='background: #0b0f17; border: 1px solid #1f2937; padding: 15px; border-radius: 10px; font-size: 13px; margin-top: 15px;'>
Host: <strong>" . htmlspecialchars(DB_HOST) . "</strong><br>
Poort: <strong>" . htmlspecialchars(DB_PORT) . "</strong><br>
Database: <strong>" . htmlspecialchars(DB_NAME) . "</strong><br>
Gebruiker: <strong>" . htmlspecialchars(DB_USER) . "</strong>
</div>
</div>
</div>
");
}
}
/**
* Zorgt ervoor dat alle benodigde tabellen uit schema.sql aanwezig zijn in MariaDB
* Voert niets uit als de tabellen al bestaan!
*/
function ensure_tables_exist(PDO $pdo): void {
try {
// Controleer of de hoofdtabel 'users' al bestaat
$result = $pdo->query("SHOW TABLES LIKE 'users'");
if ($result && $result->rowCount() > 0) {
// 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;
}
// Tabellen ontbreken nog: voer schema.sql uit statement voor statement
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
if (file_exists($schemaFile)) {
$sql = file_get_contents($schemaFile);
$statements = array_filter(array_map('trim', explode(';', $sql)));
foreach ($statements as $stmt) {
// Verwijder eventuele SQL commentaren aan het begin
$stmtClean = preg_replace('/^--.*$/m', '', $stmt);
$stmtClean = trim($stmtClean);
if (!empty($stmtClean)) {
$pdo->exec($stmtClean);
}
}
}
} catch (Exception $e) {
error_log("Fout bij aanmaken tabellen: " . $e->getMessage());
}
}
+135
View File
@@ -0,0 +1,135 @@
-- Fociz MariaDB Databaseschema
-- UTF-8 / utf8mb4 ondersteuning voor emoticons, sticky notes en internationale tekens
CREATE TABLE IF NOT EXISTS `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(120) NOT NULL,
`email` VARCHAR(191) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) NOT NULL,
`role` ENUM('ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER',
`two_factor_secret` VARCHAR(120) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`backup_codes` TEXT NULL,
`remember_token` VARCHAR(64) NULL,
`remember_token_expires_at` DATETIME NULL,
`notification_settings` TEXT NULL,
`avatar_url` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `invites` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`token` VARCHAR(64) NOT NULL UNIQUE,
`email` VARCHAR(191) NOT NULL,
`role` ENUM('ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER',
`expires_at` DATETIME NOT NULL,
`used_at` DATETIME NULL,
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`token`),
CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `daily_plans` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`plan_date` DATE NOT NULL,
`focus_goal` TEXT NULL,
`secondary_goals` TEXT NULL,
`notes` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_date` (`user_id`, `plan_date`),
CONSTRAINT `fk_dailyplans_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `tasks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`daily_plan_id` INT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`status` ENUM('TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED') NOT NULL DEFAULT 'TODO',
`priority` ENUM('P1', 'P2', 'P3') NOT NULL DEFAULT 'P2',
`time_block_start` VARCHAR(10) NULL,
`time_block_end` VARCHAR(10) NULL,
`duration_minutes` INT NULL DEFAULT 30,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
`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,
INDEX (`user_id`),
CONSTRAINT `fk_tasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tasks_plan` FOREIGN KEY (`daily_plan_id`) REFERENCES `daily_plans` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `brainstorm_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`theme` VARCHAR(100) NOT NULL DEFAULT 'Algemeen',
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`created_by_id`),
CONSTRAINT `fk_brainstorm_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`session_id` INT NOT NULL,
`content` TEXT NOT NULL,
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
`color` VARCHAR(20) NOT NULL DEFAULT 'amber',
`votes` INT NOT NULL DEFAULT 0,
`author_name` VARCHAR(120) NULL,
`converted_to_roadmap_item_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`session_id`),
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `roadmap_items` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`type` VARCHAR(50) NOT NULL DEFAULT 'FEATURE',
`status` VARCHAR(50) NOT NULL DEFAULT 'PLANNED',
`quarter` VARCHAR(20) NOT NULL DEFAULT 'Q3',
`priority` VARCHAR(20) NOT NULL DEFAULT 'MEDIUM',
`progress` INT NOT NULL DEFAULT 0,
`start_date` DATE NULL,
`due_date` DATE NULL,
`jira_issue_key` VARCHAR(50) NULL,
`origin_brainstorm_card_id` INT NULL,
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`created_by_id`),
CONSTRAINT `fk_roadmap_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `integrations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`provider` ENUM('OUTLOOK', 'TEAMS', 'JIRA') NOT NULL,
`is_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_demo_mode` TINYINT(1) NOT NULL DEFAULT 1,
`access_token` TEXT NULL,
`refresh_token` TEXT NULL,
`settings` TEXT NULL,
`last_sync_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_provider` (`user_id`, `provider`),
CONSTRAINT `fk_integrations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(64) PRIMARY KEY,
`setting_value` TEXT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* Fociz AI Co-Pilot Engine (Release v1.5)
* Beheert intelligente assistentie voor Brainstorming en Dagplanning.
*/
require_once __DIR__ . '/http.php';
require_once __DIR__ . '/settings.php';
/**
* Voert een gestructureerde chat completion uit met de geconfigureerde AI provider
*
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int]
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string]
*/
function fociz_ai_chat(array $messages, array $options = []): array {
$aiSettings = get_ai_settings();
if (empty($aiSettings['ai_api_key'])) {
return [
'success' => false,
'error' => 'Geen AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
];
}
$apiKey = $aiSettings['ai_api_key'];
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
$temperature = $options['temperature'] ?? 0.3;
$maxTokens = $options['max_tokens'] ?? 1500;
$jsonMode = !empty($options['json_mode']);
$payload = [
'model' => $model,
'messages' => $messages,
'temperature' => $temperature,
'max_tokens' => $maxTokens,
];
if ($jsonMode) {
$payload['response_format'] = ['type' => 'json_object'];
}
$response = fociz_http_request('https://api.openai.com/v1/chat/completions', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Content-Type' => 'application/json',
],
'body' => $payload,
'timeout' => 35,
]);
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})");
error_log("fociz_ai_chat fout: {$errMsg}");
return [
'success' => false,
'error' => $errMsg,
];
}
$rawContent = trim($response['json']['choices'][0]['message']['content']);
$parsedJson = null;
if ($jsonMode) {
$parsedJson = json_decode($rawContent, true);
}
return [
'success' => true,
'content' => $rawContent,
'json' => $parsedJson,
'model' => $response['json']['model'] ?? $model,
];
}
/**
* Categoriseert sticky notes automatisch in:
* - 'Ideeën'
* - 'Knelpunten'
* - 'Oplossingen'
* - 'Quick Wins'
*/
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array {
if (empty($cards)) {
return ['success' => true, 'categorized' => []];
}
$cardItems = [];
foreach ($cards as $c) {
$cardItems[] = [
'id' => $c['id'],
'content' => $c['content'],
'category' => $c['category'] ?? 'Ideeën',
];
}
$systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n"
. "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n"
. "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n"
. "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n"
. "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n"
. "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n"
. "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n"
. "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'.";
$userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
}
return ['success' => true, 'categorized' => $aiRes['json']['categorized']];
}
/**
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
*/
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): array {
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\n"
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
}
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
}
/**
* Zet brainstorm kaartjes om in een scherp Roadmap Item
*/
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = ''): array {
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
. "Retourneer een JSON-object met:\n"
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
. "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n"
. "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n"
. "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n"
. "- 'quarter': 'Q3' of 'Q4'";
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
}
return ['success' => true, 'item' => $aiRes['json']];
}
/**
* Berekent een slimme dagindeling voor open taken rondom bestaande afspraken
*/
function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySettings): array {
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:30')[0];
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
. "Regels:\n"
. "1. Plan GEEN taken tijdens bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
$userPrompt = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n"
. "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE);
$aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['json_mode' => true]);
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
}
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
}
+459
View File
@@ -0,0 +1,459 @@
<?php
/**
* Authenticatie & Autorisatie Guard voor Fociz
*/
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/totp.php';
/**
* Bepaalt of de huidige verbinding HTTPS gebruikt (inclusief reverse proxies / OpenResty / Docker)
*/
function is_https_request(): bool {
if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') {
return true;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
return true;
}
if (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && strtolower($_SERVER['HTTP_X_FORWARDED_SSL']) === 'on') {
return true;
}
if (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443) {
return true;
}
if (defined('APP_URL') && str_starts_with(APP_URL, 'https://')) {
return true;
}
return false;
}
function start_secure_session(): void {
if (session_status() === PHP_SESSION_NONE) {
// Zorg voor een ruime sessieduur (30 dagen)
ini_set('session.gc_maxlifetime', '2592000');
ini_set('session.cookie_lifetime', '2592000');
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
$isSecure = is_https_request();
session_set_cookie_params([
'lifetime' => 30 * 24 * 60 * 60, // 30 dagen
'path' => '/',
'domain' => '',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
}
}
/**
* Controleert of er al een beheerder bestaat in MariaDB
*/
function has_admin_user(): bool {
try {
$db = get_db_connection();
$stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'");
return ((int)$stmt->fetchColumn()) > 0;
} catch (Throwable $e) {
return false;
}
}
/**
* Garandeert dat de kolommen voor persistent sessiebeheer aanwezig zijn in MariaDB
*/
function ensure_remember_token_columns(PDO $db): void {
static $columnsChecked = false;
if ($columnsChecked) return;
$columnsChecked = true;
try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'");
if (!$result || $result->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `remember_token` VARCHAR(64) NULL AFTER `backup_codes`, ADD COLUMN `remember_token_expires_at` DATETIME NULL AFTER `remember_token`");
}
} catch (Throwable $e) {
// Stil negeren indien al aanwezig of tijdens migraties
}
}
/**
* Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
*/
function ensure_notification_columns(PDO $db): void {
static $notifColumnsChecked = false;
if ($notifColumnsChecked) return;
$notifColumnsChecked = true;
try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'notification_settings'");
if (!$result || $result->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `notification_settings` TEXT NULL AFTER `remember_token_expires_at`");
}
} catch (Throwable $e) {
// Stil negeren indien al aanwezig
}
}
/**
* Garandeert dat de kolom voor weergavethema (dark, light, system) aanwezig is in MariaDB
*/
function ensure_theme_column(PDO $db): void {
static $themeChecked = false;
if ($themeChecked) return;
$themeChecked = true;
try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'theme'");
if (!$result || $result->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `theme` VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER `notification_settings`");
}
} catch (Throwable $e) {
// Stil negeren indien al aanwezig
}
}
/**
* Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie
*/
function issue_remember_token(int $userId): string {
$rawToken = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $rawToken);
$expiresAt = date('Y-m-d H:i:s', time() + (30 * 86400)); // 30 dagen geldig
$db = get_db_connection();
ensure_remember_token_columns($db);
$stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ? WHERE id = ?");
$stmt->execute([$tokenHash, $expiresAt, $userId]);
$isSecure = is_https_request();
setcookie('fociz_remember', $rawToken, [
'expires' => time() + (30 * 86400),
'path' => '/',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
return $rawToken;
}
/**
* Verwijdert de persistente remember cookie
*/
function clear_remember_cookie(): void {
$isSecure = is_https_request();
setcookie('fociz_remember', '', [
'expires' => time() - 86400,
'path' => '/',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
}
/**
* Haalt de huidige ingelogde gebruiker op uit de database.
* Herstelt automatisch de sessie als een geldige 'fociz_remember' cookie aanwezig is.
*/
function get_logged_in_user(): ?array {
start_secure_session();
$db = get_db_connection();
ensure_remember_token_columns($db);
// 1. Controleer actieve PHP sessie
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
if ($user) {
return $user;
}
// Gebruiker bestaat niet meer in database
$_SESSION = [];
}
// 2. Controleer persistente 'remember me' cookie (voorkomt ongewenst uitloggen bij refresh of herstart)
if (!empty($_COOKIE['fociz_remember'])) {
$rawToken = trim($_COOKIE['fociz_remember']);
$tokenHash = hash('sha256', $rawToken);
$stmt = $db->prepare("
SELECT id, name, email, role, two_factor_enabled, created_at
FROM users
WHERE remember_token = ? AND remember_token_expires_at > NOW()
");
$stmt->execute([$tokenHash]);
$user = $stmt->fetch();
if ($user) {
// Herstel volledige PHP sessie
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_role'] = $user['role'];
$_SESSION['is_2fa_verified'] = true;
// Verleng direct de geldigheid van de remember token voor nog eens 30 dagen
issue_remember_token((int)$user['id']);
return $user;
} else {
clear_remember_cookie();
}
}
return null;
}
/**
* Beschermt pagina's: dwingt login + 2FA af
*/
function require_auth(bool $json = false): array {
start_secure_session();
$isAjax = $json ||
(isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) ||
(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');
// 1. Controleer of eerste setup nodig is
if (!has_admin_user()) {
if ($isAjax) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Setup vereist']);
exit;
}
header('Location: /auth/setup.php');
exit;
}
// 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is
$user = get_logged_in_user();
if (!$user) {
if ($isAjax) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Niet geauthenticeerd of sessie verlopen']);
exit;
}
header('Location: /auth/login.php');
exit;
}
return $user;
}
/**
* Dwingt beheerder (ADMIN) rol af
*/
function require_admin(): array {
$user = require_auth();
if ($user['role'] !== 'ADMIN') {
header('Location: /planner.php');
exit;
}
return $user;
}
/**
* Maakt een nieuwe uitnodiging aan voor een collega
*/
function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string {
$db = get_db_connection();
$token = bin2hex(random_bytes(24));
$expiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
$stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]);
return $token;
}
/**
* Valideert een invite token
*/
function validate_invite(string $token): ?array {
$db = get_db_connection();
$stmt = $db->prepare("
SELECT i.*, u.name as inviter_name, u.email as inviter_email
FROM invites i
JOIN users u ON i.created_by_id = u.id
WHERE i.token = ?
");
$stmt->execute([$token]);
$invite = $stmt->fetch();
if (!$invite) return null;
if ($invite['used_at'] !== null) return null;
if (strtotime($invite['expires_at']) < time()) return null;
return $invite;
}
/**
* CSRF token helpers
*/
function get_csrf_token(): string {
start_secure_session();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(?string $token): bool {
start_secure_session();
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Genereert een cryptografisch ondertekende OAuth state parameter waarin de user_id en timestamp veilig zijn opgeslagen.
* Dit voorkomt CSRF en maakt sessieherstel mogelijk bij cross-site OAuth redirects.
*/
function generate_oauth_state(int $userId): string {
$secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026');
$payload = [
'uid' => $userId,
'ts' => time(),
'rnd' => bin2hex(random_bytes(8))
];
$json = json_encode($payload);
$b64 = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
$sig = hash_hmac('sha256', $b64, $secret);
return $b64 . '.' . $sig;
}
/**
* Valideert een ontvangen OAuth state parameter en geeft bij succes het geverifieerde user_id terug.
* Geeft null terug bij ongeldige handtekening of verlopen state (> 30 minuten).
*/
function verify_oauth_state(string $state): ?int {
$secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026');
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
return null;
}
[$b64, $sig] = $parts;
$expectedSig = hash_hmac('sha256', $b64, $secret);
if (!hash_equals($expectedSig, $sig)) {
return null;
}
$remainder = strlen($b64) % 4;
if ($remainder) {
$b64 .= str_repeat('=', 4 - $remainder);
}
$json = base64_decode(strtr($b64, '-_', '+/'));
$payload = json_decode($json, true);
if (!is_array($payload) || empty($payload['uid']) || empty($payload['ts'])) {
return null;
}
// Geldigheidsduur: maximaal 30 minuten (1800 seconden)
if (time() - (int)$payload['ts'] > 1800) {
return null;
}
return (int)$payload['uid'];
}
/**
* Haalt de persoonlijke notificatie-instellingen van een gebruiker op
*/
function get_user_notification_settings(int $userId): array {
$db = get_db_connection();
ensure_notification_columns($db);
$defaults = [
'enabled' => 1,
'lead_time_minutes' => 5, // 0, 2, 5, 10, 15, 30
'repeat_count' => 2, // 1, 2, 3, 5
'repeat_interval_minutes' => 2, // 1, 2, 3, 5
'sound_enabled' => 1, // 0 of 1
];
try {
$stmt = $db->prepare("SELECT notification_settings FROM users WHERE id = ?");
$stmt->execute([$userId]);
$json = $stmt->fetchColumn();
if (!empty($json)) {
$saved = json_decode($json, true);
if (is_array($saved)) {
return array_merge($defaults, $saved);
}
}
} catch (Throwable $e) {
error_log("get_user_notification_settings error: " . $e->getMessage());
}
return $defaults;
}
/**
* Slaat de persoonlijke notificatie-instellingen van een gebruiker op
*/
function update_user_notification_settings(int $userId, array $settings): bool {
$db = get_db_connection();
ensure_notification_columns($db);
$current = get_user_notification_settings($userId);
$merged = [
'enabled' => !empty($settings['enabled']) ? 1 : 0,
'lead_time_minutes' => max(0, min(60, (int)($settings['lead_time_minutes'] ?? $current['lead_time_minutes']))),
'repeat_count' => max(1, min(10, (int)($settings['repeat_count'] ?? $current['repeat_count']))),
'repeat_interval_minutes' => max(1, min(30, (int)($settings['repeat_interval_minutes'] ?? $current['repeat_interval_minutes']))),
'sound_enabled' => !empty($settings['sound_enabled']) ? 1 : 0,
];
try {
$stmt = $db->prepare("UPDATE users SET notification_settings = ? WHERE id = ?");
return $stmt->execute([json_encode($merged), $userId]);
} catch (Throwable $e) {
error_log("update_user_notification_settings error: " . $e->getMessage());
return false;
}
}
/**
* Haalt het voorkeursthema op voor een gebruiker ('dark', 'light' of 'system')
*/
function get_user_theme(int $userId): string {
$db = get_db_connection();
ensure_theme_column($db);
try {
$stmt = $db->prepare("SELECT theme FROM users WHERE id = ?");
$stmt->execute([$userId]);
$theme = $stmt->fetchColumn();
if ($theme && in_array($theme, ['dark', 'light', 'system'])) {
return $theme;
}
} catch (Throwable $e) {
error_log("get_user_theme error: " . $e->getMessage());
}
return 'dark';
}
/**
* Slaat het voorkeursthema van een gebruiker op ('dark', 'light' of 'system')
*/
function update_user_theme(int $userId, string $theme): bool {
$db = get_db_connection();
ensure_theme_column($db);
$theme = in_array($theme, ['dark', 'light', 'system']) ? $theme : 'dark';
try {
$stmt = $db->prepare("UPDATE users SET theme = ? WHERE id = ?");
return $stmt->execute([$theme, $userId]);
} catch (Throwable $e) {
error_log("update_user_theme error: " . $e->getMessage());
return false;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* =========================================================================
* FOCIZ OMGEVINGSINDICATOR (DEV VS MAIN/LIVE)
* =========================================================================
*
* Dit bestandje bepaalt de flashy notificatie links bovenin het scherm.
*
* - OP DE DEV OMGEVING: $environment = 'development'; (Rood met 'Development' + radar ping)
* - OP DE MAIN OMGEVING: $environment = 'live'; (Groen met 'Live' + radar ping)
*
* U kunt dit hieronder aanpassen, of instellen via APP_ENV in .env / .env.example!
*/
require_once __DIR__ . '/../config/config.php';
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
$environment = defined('APP_ENV') ? APP_ENV : 'live';
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
$environment = fociz_env('APP_ENV');
}
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
?>
<!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN -->
<div id="fociz-env-badge" class="fixed top-2.5 left-2.5 z-[9999] pointer-events-auto select-none transition-all duration-300 hover:scale-105">
<?php if ($isLive): ?>
<!-- LIVE OMGEVING (TEAL) -->
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/90 border border-teal-500/50 shadow-xs backdrop-blur-md group">
<!-- Pulsing Teal Radar Indicator -->
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-teal-500"></span>
</span>
<span class="text-[10px] font-bold tracking-wider text-teal-400 uppercase flex items-center space-x-1">
<span>Live</span>
</span>
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-teal-500/15 text-teal-300 border border-teal-500/30">
MAIN
</span>
</div>
<?php else: ?>
<!-- DEVELOPMENT OMGEVING (ROOD) -->
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-red-500/70 shadow-lg shadow-red-500/30 backdrop-blur-md group">
<!-- Pulsing Red Radar Indicator -->
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-80"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500 shadow-sm shadow-red-500"></span>
</span>
<span class="text-[11px] font-black tracking-wider text-red-400 uppercase flex items-center space-x-1">
<span>Development</span>
</span>
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-red-500/20 text-red-300 border border-red-500/30">
DEV
</span>
</div>
<?php endif; ?>
</div>
+26
View File
@@ -0,0 +1,26 @@
</main>
<footer class="border-t border-slate-200/80 dark:border-slate-800/60 bg-white/40 dark:bg-transparent backdrop-blur-sm py-5 text-center text-xs text-slate-500 transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center space-x-2">
<span class="font-bold text-slate-800 dark:text-slate-300">Fociz</span>
<span>&bull;</span>
<span class="text-slate-500 dark:text-slate-400">Focus & Structuur Systeem</span>
</div>
<div>
<span class="text-slate-400 dark:text-slate-500">Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
</div>
</div>
</footer>
<!-- Fociz Agenda Notificaties Engine -->
<script src="/assets/js/notifications.js"></script>
<script>
// Lucide Icons initialiseren
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
</script>
</body>
</html>
+365
View File
@@ -0,0 +1,365 @@
<?php
/**
* Header & Navigatiebalk voor Fociz (Dual-Mode: Dark & Light, Executive Styling)
*/
require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? require_auth();
$currentPage = basename($_SERVER['PHP_SELF']);
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Dagplanning & Roadmap') ?></title>
<!-- Anti-Flicker Theme Script: direct in head zodat er nooit een flits optreedt -->
<script>
(function() {
try {
const serverTheme = <?= json_encode($currentUserTheme ?? 'dark') ?>;
const savedTheme = localStorage.getItem('fociz_theme') || serverTheme || 'dark';
let effective = savedTheme;
if (savedTheme === 'system') {
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch(e) {}
})();
</script>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
}
}
}
}
}
</script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
html.dark body {
background-color: #0c0f17;
color: #f8fafc;
}
html:not(.dark) body {
background-color: #f8fafc;
color: #0f172a;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
letter-spacing: -0.01em;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
html.dark ::-webkit-scrollbar-track { background: #0c0f17; }
html.dark ::-webkit-scrollbar-thumb { background: #1e2638; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #2d3852; }
html:not(.dark) ::-webkit-scrollbar-track { background: #f8fafc; }
html:not(.dark) ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
html:not(.dark) ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body class="min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white transition-colors duration-150">
<?php
// Include omgevingsindicator (Dev = Rood, Live = Groen)
require_once __DIR__ . '/env_badge.php';
?>
<?php if ($currentUser): ?>
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Brand Logo (met linker padding zodat het dev/live badge vrij staat) -->
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
<a href="/planner.php" class="flex items-center space-x-2.5 group">
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-500/20 group-hover:scale-105 transition">
F
</div>
<div class="flex flex-col">
<span class="text-lg font-bold tracking-tight text-slate-900 dark:text-white leading-none">Fociz</span>
<span class="text-[9px] text-indigo-600 dark:text-indigo-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
</div>
</a>
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Dagplanning</span>
</a>
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'roadmap.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Roadmap</span>
</a>
<a href="/profile.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'profile.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="user" class="w-4 h-4"></i>
<span>Mijn Profiel</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'users.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Team</span>
</a>
<a href="/admin.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'admin.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="shield-check" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Beheer</span>
</a>
<?php endif; ?>
</nav>
</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="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60" title="Mijn Profiel & Instellingen">
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs">
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
</div>
</div>
<div class="text-left hidden sm:block">
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-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-indigo-600 dark:group-hover:text-indigo-400 transition hidden sm:block"></i>
</a>
<!-- Uitloggen -->
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
</a>
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="lightbulb" class="w-4 h-4 mb-0.5"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
<span>Roadmap</span>
</a>
<a href="/profile.php" class="flex flex-col items-center py-1 <?= $currentPage === 'profile.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="user" class="w-4 h-4 mb-0.5"></i>
<span>Profiel</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
<span>Beheer</span>
</a>
<?php endif; ?>
</div>
</div>
</header>
<!-- Fociz Agenda Notificaties Modal (Dual-Mode) -->
<div id="focizNotificationModal" class="hidden fixed inset-0 z-50 bg-black/60 dark:bg-black/80 backdrop-blur-sm items-center justify-center p-4">
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<!-- Modal Header -->
<div class="p-5 border-b border-slate-100 dark:border-slate-800/80 flex items-center justify-between">
<div class="flex items-center space-x-2.5">
<div class="w-9 h-9 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">
<i data-lucide="bell-ring" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Agenda Push Meldingen</h3>
<p class="text-[11px] text-slate-500 dark:text-slate-400">Persoonlijke waarschuwingen bij actuele afspraken</p>
</div>
</div>
<button type="button" onclick="closeNotificationModal()" class="text-slate-400 hover:text-slate-700 dark:hover:text-white p-1 rounded-lg transition">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<!-- Inhoud Formulier -->
<form onsubmit="saveNotificationPreferences(event)" class="p-5 space-y-4 text-xs">
<!-- Browser Permissie Banner -->
<div id="notifPermGranted" class="hidden p-3 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 text-indigo-800 dark:text-indigo-300 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="check-circle" class="w-4 h-4 text-indigo-600 dark:text-indigo-400 flex-shrink-0"></i>
<span class="text-[11px]">Browser pushberichten zijn actief</span>
</div>
<button type="button" onclick="focizTestNotification()" class="px-2 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 hover:bg-indigo-200 dark:hover:bg-indigo-500/30 text-indigo-800 dark:text-indigo-300 font-semibold text-[10px] transition">
Test Melding
</button>
</div>
<div id="notifPermRequest" class="p-3 rounded-xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/30 text-sky-900 dark:text-sky-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="bell" class="w-4 h-4 text-sky-600 dark:text-sky-400 flex-shrink-0"></i>
<span class="text-[11px]">Toestemming browser vereist</span>
</div>
<button type="button" onclick="focizRequestPermission()" class="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-[10px] transition shadow-xs">
Inschakelen
</button>
</div>
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-300 text-[11px] leading-relaxed">
<i data-lucide="alert-triangle" class="w-4 h-4 text-rose-600 dark:text-rose-400 inline mr-1 -mt-0.5"></i>
Browser meldingen zijn geblokkeerd. Klik op het slotje links in de adresbalk om meldingen toe te staan.
</div>
<!-- Toggle Meldingen Inschakelen -->
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div>
<span class="font-semibold text-slate-900 dark:text-white block">Meldingen Actief</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="notifEnabledInput" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<!-- Vooraankondigingstijd -->
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Hoeveel tijd van tevoren waarschuwen?
</label>
<select id="notifLeadTimeSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="0">0 minuten (Precies op aanvangstijd)</option>
<option value="2">2 minuten van tevoren</option>
<option value="5" selected>5 minuten van tevoren (Aanbevolen)</option>
<option value="10">10 minuten van tevoren</option>
<option value="15">15 minuten van tevoren</option>
<option value="30">30 minuten van tevoren</option>
</select>
</div>
<!-- Aantal Herhalingen & Interval Grid -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Aantal herhalingen
</label>
<select id="notifRepeatCountSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="1">1x (Alleen 1e melding)</option>
<option value="2" selected>2x (Aanbevolen)</option>
<option value="3">3x</option>
<option value="5">5x (Intensief)</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
Herhalingsinterval
</label>
<select id="notifIntervalSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="1">Elke 1 minuut</option>
<option value="2" selected>Elke 2 minuten</option>
<option value="3">Elke 3 minuten</option>
<option value="5">Elke 5 minuten</option>
</select>
</div>
</div>
<!-- Geluidssignaal Toggle -->
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="volume-2" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<div>
<span class="font-semibold text-slate-900 dark:text-white block">Geluidssignaal</span>
<span class="text-[10px] text-slate-500 dark:text-slate-400">Zachte chime afspelen bij melding</span>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="notifSoundInput" checked class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div id="notifSaveStatus" class="hidden text-indigo-600 dark:text-indigo-400 text-center font-semibold text-[11px] py-1"></div>
<!-- Knoppen -->
<div class="pt-2 flex items-center space-x-2">
<button type="button" onclick="closeNotificationModal()" class="flex-1 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold transition">
Annuleren
</button>
<button type="submit" id="saveNotifBtn" class="flex-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition shadow-sm shadow-indigo-600/20">
Voorkeuren Opslaan
</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
/**
* Universele Thema-controller voor Fociz
*/
function focizSetTheme(theme, saveServer = true) {
localStorage.setItem('fociz_theme', theme);
let effective = theme;
if (theme === 'system') {
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Update Lucide pictogrammen in header
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Update profiel preview selectoren als aanwezig op pagina
if (typeof updateProfileThemeUI === 'function') {
updateProfileThemeUI(theme);
}
if (saveServer) {
fetch('/api/theme.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: theme })
}).catch(() => {});
}
}
function focizQuickToggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
const nextTheme = isDark ? 'light' : 'dark';
focizSetTheme(nextTheme, true);
}
</script>
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* Fociz Pure PHP HTTP Client Helper
* Ondersteunt cURL met automatische fallback naar stream wrappers (file_get_contents).
*/
if (!function_exists('fociz_http_request')) {
/**
* Voert een HTTP request uit en retourneert de statuscode, body en geparsede JSON.
*
* @param string $url
* @param array $options [
* 'method' => 'GET'|'POST'|'PUT'|'DELETE',
* 'headers' => ['Header-Name: Value'] of ['Header-Name' => 'Value'],
* 'body' => string|array,
* 'timeout' => int (seconden, default 12),
* 'verify_ssl' => bool (default true)
* ]
* @return array ['status' => int, 'body' => string, 'json' => ?array, 'error' => ?string]
*/
function fociz_http_request(string $url, array $options = []): array {
$method = strtoupper($options['method'] ?? 'GET');
$timeout = (int)($options['timeout'] ?? 12);
$verifySsl = $options['verify_ssl'] ?? true;
// Normaliseer headers naar 'Name: Value' regels
$rawHeaders = $options['headers'] ?? [];
$headerLines = [];
foreach ($rawHeaders as $k => $v) {
if (is_numeric($k)) {
$headerLines[] = $v;
} else {
$headerLines[] = "{$k}: {$v}";
}
}
// Bereid payload voor
$body = $options['body'] ?? null;
if (is_array($body)) {
$hasJsonHeader = false;
foreach ($headerLines as $h) {
if (stripos($h, 'application/json') !== false) {
$hasJsonHeader = true;
break;
}
}
if ($hasJsonHeader) {
$body = json_encode($body);
} else {
$body = http_build_query($body);
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
}
}
// Voeg standaard User-Agent toe als die er niet is
$hasUserAgent = false;
foreach ($headerLines as $h) {
if (stripos($h, 'User-Agent:') !== false) {
$hasUserAgent = true;
break;
}
}
if (!$hasUserAgent) {
$headerLines[] = 'User-Agent: Fociz-Integrations/1.0';
}
// 1. Probeer cURL indien beschikbaar
if (extension_loaded('curl')) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifySsl ? 2 : 0);
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$curlError = curl_error($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($responseBody === false && !empty($curlError)) {
return [
'status' => $statusCode ?: 0,
'body' => '',
'json' => null,
'error' => "cURL fout: {$curlError}"
];
}
$parsedJson = json_decode((string)$responseBody, true);
return [
'status' => $statusCode,
'body' => (string)$responseBody,
'json' => is_array($parsedJson) ? $parsedJson : null,
'error' => null
];
}
// 2. Fallback: PHP Streams (file_get_contents)
$contextOptions = [
'http' => [
'method' => $method,
'header' => implode("\r\n", $headerLines),
'timeout' => $timeout,
'ignore_errors' => true // Zorgt dat we ook bij 4xx / 5xx responses de body en statuscode kunnen lezen
],
'ssl' => [
'verify_peer' => $verifySsl,
'verify_peer_name' => $verifySsl
]
];
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$contextOptions['http']['content'] = $body;
}
$context = stream_context_create($contextOptions);
$responseBody = @file_get_contents($url, false, $context);
$statusCode = 0;
if (!empty($http_response_header)) {
// Zoek de laatste HTTP status regel (bij eventuele redirects)
foreach (array_reverse($http_response_header) as $hdr) {
if (preg_match('#HTTP/\d\.\d\s+(\d{3})#i', $hdr, $matches)) {
$statusCode = (int)$matches[1];
break;
}
}
}
if ($responseBody === false) {
$lastErr = error_get_last();
return [
'status' => $statusCode ?: 0,
'body' => '',
'json' => null,
'error' => 'Stream fout: ' . ($lastErr['message'] ?? 'Kan verbinding niet voltooien')
];
}
$parsedJson = json_decode((string)$responseBody, true);
return [
'status' => $statusCode,
'body' => (string)$responseBody,
'json' => is_array($parsedJson) ? $parsedJson : null,
'error' => null
];
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
/**
* Atlassian Jira Cloud REST API Integratie voor Fociz
* Beheert verbindingstest, ophalen van aan de gebruiker toegewezen issues en import naar dagtaken.
*/
require_once __DIR__ . '/../http.php';
require_once __DIR__ . '/../../config/database.php';
/**
* Normaliseert het Jira domein naar een schone URL zonder slash aan het einde
*/
function jira_normalize_domain(string $domain): string {
$domain = trim($domain);
if (!preg_match('#^https?://#i', $domain)) {
$domain = 'https://' . $domain;
}
return rtrim($domain, '/');
}
/**
* Haalt het centrale bedrijfsdomein voor Atlassian Jira op
*/
function jira_get_company_domain(): string {
$domain = fociz_env('JIRA_DOMAIN', '');
if (!empty($domain)) {
return jira_normalize_domain($domain);
}
try {
$db = get_db_connection();
// Zoek bij beheerder(s) in de integrations tabel
$stmt = $db->query("
SELECT i.settings
FROM integrations i
JOIN users u ON i.user_id = u.id
WHERE u.role = 'ADMIN' AND i.provider = 'JIRA' AND i.settings IS NOT NULL
ORDER BY i.updated_at DESC
LIMIT 1
");
$row = $stmt->fetch();
if (!empty($row['settings'])) {
$settings = json_decode($row['settings'], true) ?: [];
if (!empty($settings['jira_domain'])) {
return jira_normalize_domain($settings['jira_domain']);
}
}
// Fallback naar een willekeurige JIRA rij
$fallback = $db->query("
SELECT settings FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_domain%' LIMIT 1
")->fetch();
if (!empty($fallback['settings'])) {
$settings = json_decode($fallback['settings'], true) ?: [];
if (!empty($settings['jira_domain'])) {
return jira_normalize_domain($settings['jira_domain']);
}
}
} catch (Throwable $e) {
error_log("jira_get_company_domain error: " . $e->getMessage());
}
return '';
}
/**
* Genereert de Basic Authentication header voor Atlassian REST API
*/
function jira_get_auth_header(string $email, string $apiToken): string {
return 'Basic ' . base64_encode(trim($email) . ':' . trim($apiToken));
}
/**
* Test de verbinding met Atlassian Jira Cloud via /rest/api/3/myself
*/
function jira_test_connection(string $domain, string $email, string $apiToken): array {
$cleanDomain = jira_normalize_domain($domain);
if (empty($cleanDomain) || empty($email) || empty($apiToken)) {
return [
'success' => false,
'error' => 'Vul een Jira domein, e-mailadres en Atlassian API token in.',
];
}
$authHeader = jira_get_auth_header($email, $apiToken);
$url = $cleanDomain . '/rest/api/3/myself';
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
// Fallback naar api v2 indien v3 niet beschikbaar is
if ($response['status'] === 404) {
$urlV2 = $cleanDomain . '/rest/api/2/myself';
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
}
if ($response['status'] === 200 && is_array($response['json'])) {
$user = $response['json'];
return [
'success' => true,
'user' => [
'displayName' => $user['displayName'] ?? 'Atlassian Gebruiker',
'emailAddress' => $user['emailAddress'] ?? $email,
'accountId' => $user['accountId'] ?? ($user['key'] ?? ''),
'active' => $user['active'] ?? true,
'timeZone' => $user['timeZone'] ?? 'Europe/Amsterdam',
],
];
}
$errMsg = 'Verbinding mislukt met Jira.';
if ($response['status'] === 401) {
$errMsg = 'Authenticatie geweigerd (HTTP 401). Controleer uw e-mailadres en zorg dat u een geldig Atlassian API token gebruikt.';
} elseif ($response['status'] === 403) {
$errMsg = 'Toegang geweigerd (HTTP 403). Uw account heeft onvoldoende rechten op dit Jira domein.';
} elseif ($response['status'] === 404) {
$errMsg = 'Jira Cloud domein niet gevonden (HTTP 404). Controleer het ingevoerde domein (bijv. uwbedrijf.atlassian.net).';
} elseif (!empty($response['error'])) {
$errMsg = "Netwerkfout: {$response['error']}";
}
return [
'success' => false,
'error' => $errMsg,
'status' => $response['status'],
];
}
/**
* Haalt alle actieve issues op die zijn toegewezen aan de ingelogde Jira gebruiker
*/
function jira_fetch_assigned_issues(string $domain, string $email, string $apiToken, int $maxResults = 50): array {
$cleanDomain = jira_normalize_domain($domain);
$authHeader = jira_get_auth_header($email, $apiToken);
// JQL: Alle openstaande taken toegewezen aan mij, gesorteerd op recent bijgewerkt
$jql = 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC';
$params = [
'jql' => $jql,
'maxResults' => min($maxResults, 100),
'fields' => 'summary,status,priority,issuetype,parent,project,duedate,updated',
];
$url = $cleanDomain . '/rest/api/3/search?' . http_build_query($params);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
// Fallback naar api v2
if ($response['status'] === 404) {
$urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($params);
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
}
if ($response['status'] !== 200 || !isset($response['json']['issues'])) {
return [
'success' => false,
'error' => $response['json']['errorMessages'][0] ?? ($response['error'] ?? 'Kan Jira tickets niet ophalen'),
'issues' => [],
];
}
$rawIssues = $response['json']['issues'];
$issues = [];
foreach ($rawIssues as $item) {
$fields = $item['fields'] ?? [];
$key = $item['key'] ?? '';
$summary = $fields['summary'] ?? '(Geen titel)';
$statusName = $fields['status']['name'] ?? 'To Do';
$priorityName = $fields['priority']['name'] ?? 'Medium';
$issueTypeName = $fields['issuetype']['name'] ?? 'Taak';
$projectName = $fields['project']['name'] ?? 'Project';
$dueDate = $fields['duedate'] ?? null;
$issues[] = [
'key' => $key,
'summary' => $summary,
'status' => $statusName,
'priority' => $priorityName,
'issue_type' => $issueTypeName,
'project' => $projectName,
'due_date' => $dueDate,
'url' => "{$cleanDomain}/browse/{$key}",
'source' => 'JIRA_LIVE',
];
}
return [
'success' => true,
'issues' => $issues,
'total' => count($issues),
];
}
/**
* Haalt de Jira tickets op voor een Fociz gebruiker
* Schakelt naadloos tussen live Jira tickets en sandbox demotickets
*/
function jira_get_user_issues(int $userId, bool $forceRefresh = false): array {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
// Als de integratie niet is ingesteld of in demo mode staat, geef interactieve fallback issues terug
if (!$integration || !empty($integration['is_demo_mode'])) {
return [
'is_demo' => true,
'success' => true,
'issues' => [
[
'key' => 'FOC-101',
'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren',
'status' => 'In Progress',
'priority' => 'High',
'issue_type' => 'Story',
'project' => 'Fociz Platform',
'due_date' => date('Y-m-d', strtotime('+2 days')),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-102',
'summary' => 'Interactief whiteboard canvas voor brainstormsessies',
'status' => 'To Do',
'priority' => 'High',
'issue_type' => 'Feature',
'project' => 'Fociz Platform',
'due_date' => date('Y-m-d', strtotime('+4 days')),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-103',
'summary' => 'Koppeling met Microsoft Outlook Calendar & Teams Graph API',
'status' => 'In Review',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Integrations Hub',
'due_date' => date('Y-m-d'),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-104',
'summary' => 'Roadmap kwartaalweergave en voortgangsindicatoren',
'status' => 'To Do',
'priority' => 'Medium',
'issue_type' => 'Story',
'project' => 'Roadmap Module',
'due_date' => date('Y-m-d', strtotime('+7 days')),
'url' => '#',
'source' => 'SANDBOX',
],
],
];
}
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
$domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain();
$email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', '');
$apiToken = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', '');
if (empty($domain) || empty($email) || empty($apiToken)) {
return [
'is_demo' => false,
'success' => false,
'error' => 'Jira configuratie is onvolledig.',
'issues' => [],
];
}
// Sessiecache voor 90 seconden om onnodige Jira API calls bij snelle pagina refreshes te beperken
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
$cacheKey = "jira_issues_user_{$userId}";
$cached = $_SESSION[$cacheKey] ?? null;
if (!$forceRefresh && $cached && is_array($cached) && ($cached['timestamp'] ?? 0) > (time() - 90)) {
return [
'is_demo' => false,
'success' => true,
'issues' => $cached['issues'],
'from_cache' => true,
];
}
$result = jira_fetch_assigned_issues($domain, $email, $apiToken, 50);
if ($result['success']) {
$_SESSION[$cacheKey] = [
'timestamp' => time(),
'issues' => $result['issues'],
];
// Werk last_sync_at bij
$stmtUpdate = $db->prepare("UPDATE integrations SET last_sync_at = NOW() WHERE id = ?");
$stmtUpdate->execute([$integration['id']]);
return [
'is_demo' => false,
'success' => true,
'issues' => $result['issues'],
];
}
return [
'is_demo' => false,
'success' => false,
'error' => $result['error'],
'issues' => [],
];
}
/**
* Ontkoppelt Jira integratie voor een gebruiker
*/
function jira_disconnect(int $userId): bool {
$db = get_db_connection();
$stmt = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
unset($_SESSION["jira_issues_user_{$userId}"]);
return $stmt->execute([$userId]);
}
+885
View File
@@ -0,0 +1,885 @@
<?php
/**
* Microsoft 365 / Graph API Integratie voor Fociz
* Beheert OAuth2 authenticatie, token refresh en kalendersynchronisatie.
*/
require_once __DIR__ . '/../http.php';
require_once __DIR__ . '/../../config/database.php';
const MS_OAUTH_AUTHORIZE_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize';
const MS_OAUTH_TOKEN_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token';
const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
const MS_DEFAULT_SCOPES = 'offline_access User.Read Calendars.ReadWrite';
/**
* Haalt de centrale Microsoft App Registratie configuratie op (Client ID, Secret, Tenant ID)
* Prioriteit:
* 1. Environment variabelen (.env of server env)
* 2. Opgeslagen configuratie van een Beheerder (ADMIN) in de integrations tabel
* 3. Fallback naar integrations tabel waar settings client_id bevat
*/
function microsoft_get_app_config(): array {
$clientId = fociz_env('MICROSOFT_CLIENT_ID', '');
$clientSecret = fociz_env('MICROSOFT_CLIENT_SECRET', '');
$tenantId = fociz_env('MICROSOFT_TENANT_ID', '');
try {
$db = get_db_connection();
$adminOrgDomain = '';
// Zoek bij actieve beheerder(s) in de integrations tabel
$stmt = $db->query("
SELECT i.settings, u.email as admin_email
FROM integrations i
JOIN users u ON i.user_id = u.id
WHERE u.role = 'ADMIN' AND i.provider = 'OUTLOOK' AND i.settings IS NOT NULL
ORDER BY i.updated_at DESC
LIMIT 5
");
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
if (!empty($row['admin_email']) && empty($adminOrgDomain)) {
$emailParts = explode('@', $row['admin_email']);
$domain = strtolower(trim(end($emailParts)));
if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
$adminOrgDomain = $domain;
}
}
if (!empty($row['settings'])) {
$saved = json_decode($row['settings'], true) ?: [];
if (empty($clientId) && !empty($saved['client_id'])) {
$clientId = trim($saved['client_id']);
}
if (empty($clientSecret) && !empty($saved['client_secret'])) {
$clientSecret = trim($saved['client_secret']);
}
if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') {
$tenantId = trim($saved['tenant_id']);
}
if (!empty($clientId) && !empty($clientSecret) && !empty($tenantId) && $tenantId !== 'common') {
break;
}
}
}
// Fallback: zoek een willekeurige OUTLOOK rij met ingevulde client_id
if (empty($clientId) || empty($clientSecret) || empty($tenantId) || $tenantId === 'common') {
$stmtFallback = $db->query("
SELECT settings
FROM integrations
WHERE provider = 'OUTLOOK' AND settings LIKE '%client_id%'
ORDER BY updated_at DESC
LIMIT 3
");
$fallbackRows = $stmtFallback->fetchAll();
foreach ($fallbackRows as $fbRow) {
if (!empty($fbRow['settings'])) {
$saved = json_decode($fbRow['settings'], true) ?: [];
if (empty($clientId) && !empty($saved['client_id'])) {
$clientId = trim($saved['client_id']);
}
if (empty($clientSecret) && !empty($saved['client_secret'])) {
$clientSecret = trim($saved['client_secret']);
}
if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') {
$tenantId = trim($saved['tenant_id']);
}
}
}
}
// Als tenantId nog leeg of 'common' is, leid het domein af van de beheerder
if (empty($tenantId) || $tenantId === 'common') {
if (empty($adminOrgDomain)) {
$firstAdminEmail = $db->query("SELECT email FROM users WHERE role = 'ADMIN' ORDER BY id ASC LIMIT 1")->fetchColumn();
if ($firstAdminEmail) {
$parts = explode('@', $firstAdminEmail);
$domain = strtolower(trim(end($parts)));
if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
$adminOrgDomain = $domain;
}
}
}
if (!empty($adminOrgDomain)) {
$tenantId = $adminOrgDomain;
}
}
} catch (Throwable $e) {
error_log("microsoft_get_app_config error: " . $e->getMessage());
}
// Default tenantId voor de organisatie
if (empty($tenantId) || $tenantId === 'common') {
$tenantId = 'planningsagenda.nl';
}
return [
'client_id' => $clientId ?: '',
'client_secret' => $clientSecret ?: '',
'tenant_id' => $tenantId,
'is_configured' => !empty($clientId) && !empty($clientSecret),
];
}
/**
* Genereert de Microsoft OAuth2 login URL
*/
function microsoft_get_auth_url(string $clientId, string $redirectUri, string $tenantId = '', string $state = ''): string {
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
$baseUrl = sprintf(MS_OAUTH_AUTHORIZE_URL, urlencode($tenant));
$params = [
'client_id' => trim($clientId),
'response_type' => 'code',
'redirect_uri' => trim($redirectUri),
'response_mode' => 'query',
'scope' => MS_DEFAULT_SCOPES,
'state' => $state,
'prompt' => 'select_account',
];
return $baseUrl . '?' . http_build_query($params);
}
/**
* Wisselt de ontvangen autorisatiecode om voor access- en refresh tokens
*/
function microsoft_exchange_code(string $code, string $clientId, string $clientSecret, string $redirectUri, string $tenantId = ''): array {
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($tenant));
$payload = [
'client_id' => trim($clientId),
'client_secret' => trim($clientSecret),
'code' => trim($code),
'redirect_uri' => trim($redirectUri),
'grant_type' => 'authorization_code',
'scope' => MS_DEFAULT_SCOPES,
];
$response = fociz_http_request($tokenUrl, [
'method' => 'POST',
'body' => $payload,
'timeout' => 15,
]);
if ($response['status'] !== 200 || empty($response['json']['access_token'])) {
$errorMsg = $response['json']['error_description'] ?? ($response['error'] ?? 'Onbekende fout bij token uitwisseling');
return [
'success' => false,
'error' => $errorMsg,
];
}
return [
'success' => true,
'access_token' => $response['json']['access_token'],
'refresh_token' => $response['json']['refresh_token'] ?? null,
'expires_in' => (int)($response['json']['expires_in'] ?? 3600),
];
}
/**
* Haalt het Microsoft gebruikersprofiel op via Graph API
*/
function microsoft_get_user_profile(string $accessToken): ?array {
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me', [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Accept' => 'application/json',
],
'timeout' => 10,
]);
if ($response['status'] === 200 && is_array($response['json'])) {
return [
'displayName' => $response['json']['displayName'] ?? 'Microsoft 365 Gebruiker',
'mail' => $response['json']['mail'] ?? ($response['json']['userPrincipalName'] ?? ''),
'id' => $response['json']['id'] ?? '',
];
}
return null;
}
/**
* Haalt een geldige access_token op voor de gebruiker; vernieuwt automatisch indien verlopen
*/
function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?string {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
if (!$integration || empty($integration['access_token'])) {
return null;
}
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
$expiresAt = (int)($settings['expires_at'] ?? 0);
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
if (!$forceRefresh && $expiresAt > (time() + 240)) {
return $integration['access_token'];
}
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
$refreshToken = $integration['refresh_token'] ?? '';
$appConfig = microsoft_get_app_config();
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
return !$forceRefresh ? ($integration['access_token'] ?? null) : null;
}
$cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl';
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($cleanTenant));
$payload = [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'scope' => MS_DEFAULT_SCOPES,
];
$response = fociz_http_request($tokenUrl, [
'method' => 'POST',
'body' => $payload,
'timeout' => 15,
]);
if ($response['status'] === 200 && !empty($response['json']['access_token'])) {
$newAccessToken = $response['json']['access_token'];
$newRefreshToken = $response['json']['refresh_token'] ?? $refreshToken;
$expiresIn = (int)($response['json']['expires_in'] ?? 3600);
$settings['expires_at'] = time() + $expiresIn;
$updateStmt = $db->prepare("
UPDATE integrations
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
WHERE id = ?
");
$updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]);
return $newAccessToken;
}
error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json'])));
// Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken
if (!empty($integration['access_token'])) {
return $integration['access_token'];
}
return null;
}
/**
* Haalt agenda-afspraken van Microsoft Outlook op voor een datumbereik (bijv. een hele week)
*/
function microsoft_fetch_events_range(int $userId, string $startDate, string $endDate): array {
$token = microsoft_get_valid_token($userId);
if (!$token) {
return [];
}
try {
$tzLocal = new DateTimeZone('Europe/Amsterdam');
$tzUtc = new DateTimeZone('UTC');
$dtStart = new DateTime("{$startDate} 00:00:00", $tzLocal);
$dtEnd = new DateTime("{$endDate} 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 = "{$startDate}T00:00:00Z";
$endIso = "{$endDate}T23:59:59Z";
}
$url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([
'startDateTime' => $startIso,
'endDateTime' => $endIso,
'$select' => 'id,subject,bodyPreview,start,end,location,isAllDay,webLink,onlineMeeting,isCancelled',
'$orderby' => 'start/dateTime',
'$top' => '100',
]);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 12,
]);
// Retry bij 401 Unauthorized
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 12,
]);
}
}
if ($response['status'] !== 200 || empty($response['json']['value'])) {
if ($response['status'] !== 200) {
error_log("microsoft_fetch_events_range error (HTTP {$response['status']}): " . ($response['error'] ?? json_encode($response['json'])));
}
return [];
}
$rawEvents = $response['json']['value'];
$events = [];
foreach ($rawEvents as $e) {
if (!empty($e['isCancelled'])) {
continue;
}
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
$eventDate = '';
$startTime = '';
$endTime = '';
if (!empty($rawStart)) {
$eventDate = substr($rawStart, 0, 10);
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));
}
}
$meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com');
if (empty($meetingUrl) && !empty($e['bodyPreview'])) {
if (preg_match('#https://teams\.microsoft\.com/l/meetup-join/[^\s"\'<>]+#i', $e['bodyPreview'], $m)) {
$meetingUrl = $m[0];
}
}
$locationName = $e['location']['displayName'] ?? '';
if (empty($locationName) && !empty($e['onlineMeeting']['joinUrl'])) {
$locationName = 'Microsoft Teams';
}
$events[] = [
'id' => $e['id'] ?? uniqid('ms_'),
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
'date' => $eventDate ?: $startDate,
'start' => $startTime ?: '09:00',
'end' => $endTime ?: '10:00',
'start_raw' => $rawStart,
'end_raw' => $rawEnd,
'location' => $locationName ?: 'Geen locatie',
'meeting_url' => $meetingUrl,
'web_link' => $e['webLink'] ?? '',
'is_all_day' => !empty($e['isAllDay']),
'source' => 'OUTLOOK_LIVE',
];
}
return $events;
}
/**
* Haalt agenda-afspraken van Microsoft Outlook op voor de opgegeven datum
*/
function microsoft_fetch_today_events(int $userId, ?string $date = null): array {
$targetDate = $date ?: date('Y-m-d');
return microsoft_fetch_events_range($userId, $targetDate, $targetDate);
}
/**
* 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. Koppel uw account via Mijn Profiel.'];
}
$subject = trim($eventData['subject'] ?? 'Fociz Dagplanning Taak');
$description = trim($eventData['description'] ?? 'Gepland via Fociz Dagplanning & Structuur');
// Normaliseer datum naar strikt YYYY-MM-DD
$date = !empty($eventData['date']) ? trim($eventData['date']) : date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d', strtotime($date) ?: time());
}
// Normaliseer starttijd naar strikt HH:mm met 2-cijferig uur
$rawStart = !empty($eventData['start_time']) ? trim($eventData['start_time']) : '09:00';
if (preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawStart, $m)) {
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
} elseif (preg_match('/^(\d{1,2})$/', $rawStart, $m)) {
$startTime = sprintf('%02d:00', (int)$m[1]);
} else {
$startTime = '09:00';
}
// Duur
$duration = (int)($eventData['duration_minutes'] ?? 30);
if ($duration <= 0) $duration = 30;
// Normaliseer eindtijd naar strikt HH:mm
$rawEnd = !empty($eventData['end_time']) ? trim($eventData['end_time']) : '';
if (!empty($rawEnd) && preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawEnd, $m)) {
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
} else {
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,
]);
// Retry bij 401 Unauthorized (token verlopen)
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$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 (user {$userId}): {$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]);
}
/**
* Werkt een bestaande afspraak bij in Microsoft Outlook via Graph API (PATCH)
*/
function microsoft_update_event(int $userId, string $eventId, array $eventData): bool {
if (empty($eventId)) {
return false;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return false;
}
$payload = [];
if (!empty($eventData['subject'])) {
$payload['subject'] = $eventData['subject'];
}
$date = !empty($eventData['date']) ? $eventData['date'] : date('Y-m-d');
$startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00';
$endTime = !empty($eventData['end_time']) ? $eventData['end_time'] : '09:30';
if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $startTime, $m)) {
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
}
if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $endTime, $m)) {
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
}
$payload['start'] = [
'dateTime' => "{$date}T{$startTime}:00",
'timeZone' => 'Europe/Amsterdam',
];
$payload['end'] = [
'dateTime' => "{$date}T{$endTime}:00",
'timeZone' => 'Europe/Amsterdam',
];
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
$response = fociz_http_request($url, [
'method' => 'PATCH',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 12,
]);
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$response = fociz_http_request($url, [
'method' => 'PATCH',
'headers' => [
'Authorization' => "Bearer {$freshToken}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 12,
]);
}
}
return in_array($response['status'], [200, 204]);
}
/**
* Ontkoppelt Microsoft integratie voor een gebruiker
* Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig.
*/
function microsoft_disconnect(int $userId): bool {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
if (!$integration) {
return true;
}
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
// Verwijder gebruikers-specifieke token gegevens
unset($settings['account_email'], $settings['account_name'], $settings['expires_at']);
// Als er centrale app credentials in staan (bijv. van beheerder), behouden we deze netjes
if (!empty($settings['client_id']) || !empty($settings['client_secret'])) {
$updateStmt = $db->prepare("
UPDATE integrations
SET access_token = NULL, refresh_token = NULL, settings = ?, last_sync_at = NULL, is_enabled = 1, is_demo_mode = 0
WHERE id = ?
");
return $updateStmt->execute([json_encode($settings), $integration['id']]);
} else {
$delStmt = $db->prepare("DELETE FROM integrations WHERE id = ?");
return $delStmt->execute([$integration['id']]);
}
}
/**
* Haalt één specifieke afspraak op uit Microsoft Outlook via Graph API.
* Retourneert:
* - array met event data indien gevonden (HTTP 200)
* - false indien definitief verwijderd / geannuleerd (HTTP 404 of isCancelled)
* - null indien er een netwerk/server error optreedt (beschermt taken tegen onterecht wissen)
*/
function microsoft_get_single_event(int $userId, string $eventId): false|array|null {
if (empty($eventId)) {
return false;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return null;
}
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId) . '?' . http_build_query([
'$select' => 'id,subject,start,end,isCancelled',
]);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 8,
]);
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 8,
]);
}
}
if ($response['status'] === 404) {
return false; // Definitief niet meer gevonden in Outlook
}
if ($response['status'] === 200 && is_array($response['json'])) {
$e = $response['json'];
if (!empty($e['isCancelled'])) {
return false; // Geannuleerd
}
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
$eventDate = !empty($rawStart) ? substr($rawStart, 0, 10) : '';
$startTime = '';
$endTime = '';
if (!empty($rawStart) && preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
$startTime = $m[1];
}
if (!empty($rawEnd) && preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
$endTime = $m[1];
}
return [
'id' => $e['id'],
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
'date' => $eventDate,
'start' => $startTime ?: '09:00',
'end' => $endTime ?: '10:00',
'start_raw' => $rawStart,
'end_raw' => $rawEnd,
];
}
return null;
}
/**
* Synchroniseert en reconciled Fociz taken met de live Microsoft Outlook agenda.
*
* - Als een gekoppelde afspraak in Outlook is verwijderd of geannuleerd, wordt de taak in Fociz gewist.
* - Als een afspraak in Outlook van tijd, datum of titel is gewijzigd, wordt de taak in Fociz bijgewerkt.
*
* @param int $userId
* @param string $startDate Datumbereik begin (Y-m-d)
* @param string $endDate Datumbereik einde (Y-m-d)
* @param array $liveEvents Reeds opgehaalde Outlook events via microsoft_fetch_events_range
* @return array ['deleted' => int, 'updated' => int]
*/
function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate, string $endDate, array $liveEvents): array {
$db = get_db_connection();
$deletedCount = 0;
$updatedCount = 0;
// 1. Indexeer live events op id
$liveEventMap = [];
foreach ($liveEvents as $evt) {
if (!empty($evt['id'])) {
$liveEventMap[$evt['id']] = $evt;
}
}
// 2. Haal alle taken op voor deze gebruiker in dit datumbereik met een external_id (gekoppeld aan Outlook)
$stmt = $db->prepare("
SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.duration_minutes, t.external_id, dp.plan_date, dp.id as daily_plan_id
FROM tasks t
JOIN daily_plans dp ON t.daily_plan_id = dp.id
WHERE t.user_id = ?
AND t.source = 'OUTLOOK'
AND t.external_id IS NOT NULL
AND t.external_id != ''
AND dp.plan_date BETWEEN ? AND ?
");
$stmt->execute([$userId, $startDate, $endDate]);
$syncedTasks = $stmt->fetchAll();
foreach ($syncedTasks as $task) {
$extId = $task['external_id'];
if (isset($liveEventMap[$extId])) {
// Het event bestaat in het huidige week/datumbereik!
$evt = $liveEventMap[$extId];
$dateChanged = (!empty($evt['date']) && $evt['date'] !== $task['plan_date']);
$titleChanged = (!empty($evt['subject']) && $evt['subject'] !== $task['title']);
$startChanged = (!empty($evt['start']) && $evt['start'] !== $task['time_block_start']);
$endChanged = (!empty($evt['end']) && $evt['end'] !== $task['time_block_end']);
if ($dateChanged || $titleChanged || $startChanged || $endChanged) {
$targetPlanId = (int)$task['daily_plan_id'];
// Als datum gewijzigd is, verplaats naar de daily_plan van de nieuwe datum
if ($dateChanged) {
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$userId, $evt['date']]);
$targetPlanId = (int)$db->lastInsertId();
}
// Bereken duur
$newStart = !empty($evt['start']) ? $evt['start'] : $task['time_block_start'];
$newEnd = !empty($evt['end']) ? $evt['end'] : $task['time_block_end'];
$newDuration = (int)$task['duration_minutes'];
if (!empty($newStart) && !empty($newEnd)) {
$durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60;
if ($durCalc > 0) {
$newDuration = (int)$durCalc;
}
}
$newTitle = !empty($evt['subject']) ? $evt['subject'] : $task['title'];
$updateStmt = $db->prepare("
UPDATE tasks
SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ? AND user_id = ?
");
$updateStmt->execute([$targetPlanId, $newTitle, $newStart, $newEnd, $newDuration, $task['id'], $userId]);
$updatedCount++;
}
} else {
// Event niet gevonden in het weekbereik!
// Controleer specifiek of het event in Outlook gewist is of naar een andere week verplaatst
$singleEvent = microsoft_get_single_event($userId, $extId);
if ($singleEvent === false) {
// Event is definitief verwijderd of geannuleerd in Outlook!
$delStmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$delStmt->execute([$task['id'], $userId]);
$deletedCount++;
} elseif (is_array($singleEvent)) {
// Event bestaat nog, maar staat op een andere datum (buiten huidig bereik)
$newDate = $singleEvent['date'];
if (!empty($newDate)) {
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$userId, $newDate]);
$newPlanId = (int)$db->lastInsertId();
$newStart = $singleEvent['start'];
$newEnd = $singleEvent['end'];
$newDuration = (int)$task['duration_minutes'];
if (!empty($newStart) && !empty($newEnd)) {
$durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60;
if ($durCalc > 0) $newDuration = (int)$durCalc;
}
$updateStmt = $db->prepare("
UPDATE tasks
SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
WHERE id = ? AND user_id = ?
");
$updateStmt->execute([$newPlanId, $singleEvent['subject'], $newStart, $newEnd, $newDuration, $task['id'], $userId]);
$updatedCount++;
}
}
}
}
return [
'deleted' => $deletedCount,
'updated' => $updatedCount,
];
}
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* Centrale Systeeminstellingen Helper voor Fociz
* Beheert organisatie-brede werkdagstandaarden (v1.4) en AI configuratie (v1.5).
*/
require_once __DIR__ . '/../config/database.php';
/**
* Zorgt ervoor dat de system_settings tabel bestaat in MariaDB
*/
function ensure_system_settings_table(?PDO $db = null): void {
static $ensured = false;
if ($ensured) return;
try {
$pdo = $db ?: get_db_connection();
$pdo->exec("
CREATE TABLE IF NOT EXISTS `system_settings` (
`setting_key` VARCHAR(64) PRIMARY KEY,
`setting_value` TEXT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$ensured = true;
} catch (Throwable $e) {
error_log("ensure_system_settings_table error: " . $e->getMessage());
}
}
/**
* Haalt een enkele instelling op uit MariaDB
*/
function get_system_setting(string $key, $default = null) {
try {
$db = get_db_connection();
ensure_system_settings_table($db);
$stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
$stmt->execute([$key]);
$val = $stmt->fetchColumn();
if ($val === false || $val === null) {
return $default;
}
// Decodeer JSON indien van toepassing
$decoded = json_decode($val, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
return $val;
} catch (Throwable $e) {
error_log("get_system_setting error for {$key}: " . $e->getMessage());
return $default;
}
}
/**
* Slaat een instelling op in MariaDB
*/
function set_system_setting(string $key, $value): bool {
try {
$db = get_db_connection();
ensure_system_settings_table($db);
$valToStore = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
$stmt = $db->prepare("
INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
");
return $stmt->execute([$key, $valToStore]);
} catch (Throwable $e) {
error_log("set_system_setting error for {$key}: " . $e->getMessage());
return false;
}
}
/**
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
*/
function get_workday_settings(): array {
return [
'workday_start' => get_system_setting('workday_start', '08:30'),
'workday_end' => get_system_setting('workday_end', '17:30'),
'lunch_start' => get_system_setting('lunch_start', '12:30'),
'lunch_end' => get_system_setting('lunch_end', '13:00'),
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
];
}
/**
* Haalt AI configuratie-instellingen op (Release v1.5)
*/
function get_ai_settings(): array {
$envKey = fociz_env('OPENAI_API_KEY', '');
$savedKey = get_system_setting('ai_api_key', '');
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
return [
'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)),
'ai_provider' => get_system_setting('ai_provider', 'openai'),
'ai_api_key' => $apiKey,
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
'has_key' => !empty($apiKey),
];
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Pure PHP TOTP (RFC 6238) & 2FA Implementatie
* Volledig compatibel met Google Authenticator, Microsoft Authenticator & 1Password
*/
class TOTP {
private static string $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Genereert een willekeurige 16-teken Base32 geheime sleutel
*/
public static function generateSecret(int $length = 16): string {
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= self::$base32Chars[ord($bytes[$i]) % 32];
}
return $secret;
}
/**
* Decodeert een Base32 string naar binaire data
*/
private static function base32Decode(string $b32): string {
$b32 = strtoupper(trim($b32));
$buffer = 0;
$bufferBits = 0;
$binary = '';
for ($i = 0; $i < strlen($b32); $i++) {
$char = $b32[$i];
$val = strpos(self::$base32Chars, $char);
if ($val === false) continue; // Negeer spaties/ongeldige tekens
$buffer = ($buffer << 5) | $val;
$bufferBits += 5;
if ($bufferBits >= 8) {
$bufferBits -= 8;
$binary .= chr(($buffer >> $bufferBits) & 0xFF);
}
}
return $binary;
}
/**
* Berekent de 6-cijferige TOTP code voor een bepaald tijdsinterval
*/
public static function getCode(string $secret, ?int $timestamp = null): string {
if ($timestamp === null) {
$timestamp = time();
}
$timeCounter = floor($timestamp / 30);
$binaryKey = self::base32Decode($secret);
// Converteer tijdsinterval naar 8-byte big-endian binaire string
$binaryTime = pack('N*', 0) . pack('N*', $timeCounter);
$hash = hash_hmac('sha1', $binaryTime, $binaryKey, true);
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
$binaryCode = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
(ord($hash[$offset + 3]) & 0xFF)
);
$otp = $binaryCode % 1000000;
return str_pad((string)$otp, 6, '0', STR_PAD_LEFT);
}
/**
* Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter)
*/
public static function verifyCode(string $secret, string $userInputCode, int $window = 1): bool {
$userInputCode = trim(str_replace(' ', '', $userInputCode));
if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) {
return false;
}
$currentTime = time();
for ($i = -$window; $i <= $window; $i++) {
$calcCode = self::getCode($secret, $currentTime + ($i * 30));
if (hash_equals($calcCode, $userInputCode)) {
return true;
}
}
return false;
}
/**
* Maakt de otpauth:// URL aan voor authenticator apps
*/
public static function getOtpAuthUrl(string $email, string $secret, string $issuer = 'Fociz'): string {
return sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
rawurlencode($issuer),
rawurlencode($email),
$secret,
rawurlencode($issuer)
);
}
/**
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
*/
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
return sprintf(
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
urlencode($otpAuthUrl),
$size
);
}
/**
* Genereert een lijst met eenmalige herstelcodes
*/
public static function generateBackupCodes(int $count = 8): array {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$bytes = bin2hex(random_bytes(4));
$codes[] = strtoupper(substr($bytes, 0, 4) . '-' . substr($bytes, 4, 4));
}
return $codes;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* Fociz Entrypoint
*/
require_once __DIR__ . '/includes/auth.php';
start_secure_session();
if (!has_admin_user()) {
header('Location: /auth/setup.php');
exit;
}
$user = get_logged_in_user();
if ($user) {
header('Location: /planner.php');
exit;
}
header('Location: /auth/login.php');
exit;
+19
View File
@@ -0,0 +1,19 @@
<?php
/**
* Fociz Integraties Doorverwijzer
* Beheerders worden automatisch doorgeleid naar Systeembeheer (/admin.php).
* Collega's worden automatisch doorgeleid naar hun Persoonlijk Profiel (/profile.php).
*/
require_once __DIR__ . '/includes/auth.php';
$user = require_auth();
$queryString = !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : '';
if ($user['role'] === 'ADMIN') {
header('Location: /admin.php' . $queryString);
} else {
header('Location: /profile.php' . $queryString);
}
exit;
+1717
View File
File diff suppressed because it is too large Load Diff
+944
View File
@@ -0,0 +1,944 @@
<?php
/**
* Fociz Persoonlijk Profiel & Koppelingen Portaal (Voor alle gebruikers: Beheerders & Collega's)
*/
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/integrations/microsoft.php';
require_once __DIR__ . '/includes/integrations/jira.php';
// Iedere ingelogde gebruiker heeft toegang tot zijn/haar eigen profiel
$user = require_auth();
$userId = (int)$user['id'];
$db = get_db_connection();
$message = null;
$messageType = 'success';
if (!empty($_GET['success'])) {
$message = $_GET['success'];
$messageType = 'success';
} elseif (!empty($_GET['error'])) {
$message = $_GET['error'];
$messageType = 'error';
}
// POST Verwerking VÓÓR headers zodat redirects en mutaties soepel verlopen
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// 1. Outlook koppelen via OAuth
if ($action === 'connect_outlook') {
$appConfig = microsoft_get_app_config();
if (!$appConfig['is_configured']) {
$message = ($user['role'] === 'ADMIN')
? "Configureer eerst de Application (Client) ID en het Client Secret in het Systeembeheer."
: "Microsoft 365 is nog niet ingesteld door de beheerder van uw organisatie. Vraag uw beheerder om de koppeling te activeren.";
$messageType = 'error';
} else {
header("Location: /auth/oauth_microsoft.php");
exit;
}
}
// 2. Outlook ontkoppelen (alleen persoonlijke tokens van deze gebruiker wissen)
elseif ($action === 'disconnect_outlook') {
microsoft_disconnect($userId);
header('Location: /profile.php?success=' . urlencode('Uw Microsoft Outlook agenda is succesvol ontkoppeld.'));
exit;
}
// 2b. Outlook synchronisatievoorkeuren opslaan
elseif ($action === 'save_outlook_settings') {
$syncInterval = (int)($_POST['sync_interval_seconds'] ?? 30);
$stmtExisting = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtExisting->execute([$userId]);
$existing = $stmtExisting->fetch();
$settings = json_decode($existing['settings'] ?? '{}', true) ?: [];
$settings['sync_interval_seconds'] = $syncInterval;
$stmtUp = $db->prepare("UPDATE integrations SET settings = ? WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtUp->execute([json_encode($settings), $userId]);
$message = "Uw Microsoft Outlook synchronisatievoorkeur is succesvol opgeslagen.";
$messageType = 'success';
}
// 3. Jira Opslaan & Testen
elseif ($action === 'save_jira') {
$companyDomain = jira_get_company_domain();
$rawDomain = trim($_POST['jira_domain'] ?? '');
$jiraDomain = !empty($rawDomain) ? jira_normalize_domain($rawDomain) : $companyDomain;
$jiraEmail = trim($_POST['jira_email'] ?? '');
$jiraToken = trim($_POST['jira_api_token'] ?? '');
// Haal bestaande instellingen op
$stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmtExisting->execute([$userId]);
$existing = $stmtExisting->fetch();
$existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
if (($jiraToken === '' || $jiraToken === '••••••••••••••••') && !empty($existingSettings['jira_api_token']) && $existingSettings['jira_api_token'] !== '••••••••••••••••') {
$jiraToken = $existingSettings['jira_api_token'];
}
$accountName = $existingSettings['account_name'] ?? '';
$accountId = $existingSettings['account_id'] ?? '';
if (!empty($jiraDomain) && !empty($jiraEmail) && !empty($jiraToken)) {
$testRes = jira_test_connection($jiraDomain, $jiraEmail, $jiraToken);
if (!$testRes['success']) {
$message = "Jira Verbindingsfout: " . $testRes['error'];
$messageType = 'error';
} else {
$accountName = $testRes['user']['displayName'] ?? 'Jira Gebruiker';
$accountId = $testRes['user']['accountId'] ?? '';
$message = "Verbinding met Jira geslaagd! Ingelogd als: {$accountName}";
$messageType = 'success';
}
} else {
$message = "Jira instellingen opgeslagen.";
$messageType = 'success';
}
$newSettings = [
'jira_domain' => $jiraDomain,
'jira_email' => $jiraEmail,
'jira_api_token' => $jiraToken,
'account_name' => $accountName,
'account_id' => $accountId,
];
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'JIRA', 1, 0, ?, NOW())
ON DUPLICATE KEY UPDATE
is_demo_mode = 0,
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$userId, json_encode($newSettings)]);
unset($_SESSION["jira_issues_user_{$userId}"]);
}
// 4. Jira ontkoppelen
elseif ($action === 'disconnect_jira') {
jira_disconnect($userId);
header('Location: /profile.php?success=' . urlencode('Atlassian Jira is succesvol ontkoppeld.'));
exit;
}
// 5. Notificatievoorkeuren opslaan
elseif ($action === 'save_notifications') {
$settings = [
'enabled' => isset($_POST['notif_enabled']) ? 1 : 0,
'lead_time_minutes' => (int)($_POST['lead_time_minutes'] ?? 5),
'repeat_count' => (int)($_POST['repeat_count'] ?? 2),
'repeat_interval_minutes' => (int)($_POST['repeat_interval_minutes'] ?? 2),
'sound_enabled' => isset($_POST['sound_enabled']) ? 1 : 0,
];
update_user_notification_settings($userId, $settings);
$message = "Uw persoonlijke notificatievoorkeuren zijn succesvol opgeslagen.";
$messageType = 'success';
}
// 6. Profielnaam bijwerken
elseif ($action === 'update_profile') {
$name = trim($_POST['name'] ?? '');
if (strlen($name) < 2) {
$message = "Voer een geldige naam in van minimaal 2 tekens.";
$messageType = 'error';
} else {
$stmt = $db->prepare("UPDATE users SET name = ? WHERE id = ?");
$stmt->execute([$name, $userId]);
$_SESSION['user_name'] = $name;
$user['name'] = $name;
$message = "Uw profielnaam is succesvol bijgewerkt.";
$messageType = 'success';
}
}
// 7. Wachtwoord wijzigen
elseif ($action === 'change_password') {
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$stmtU = $db->prepare("SELECT password_hash FROM users WHERE id = ?");
$stmtU->execute([$userId]);
$storedHash = $stmtU->fetchColumn();
if (!password_verify($currentPassword, $storedHash)) {
$message = "Uw huidige wachtwoord is niet correct ingevoerd.";
$messageType = 'error';
} elseif (strlen($newPassword) < 8) {
$message = "Het nieuwe wachtwoord moet minimaal 8 tekens lang zijn.";
$messageType = 'error';
} elseif ($newPassword !== $confirmPassword) {
$message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen.";
$messageType = 'error';
} else {
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$stmtUp->execute([$newHash, $userId]);
$message = "Uw wachtwoord is succesvol gewijzigd.";
$messageType = 'success';
}
}
}
// Actuele gegevens ophalen
$stmtUser = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmtUser->execute([$userId]);
$userProfile = $stmtUser->fetch();
// Outlook status ophalen
$stmtOutlook = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmtOutlook->execute([$userId]);
$outlookRow = $stmtOutlook->fetch();
$isOutlookConnected = !empty($outlookRow['access_token']) && empty($outlookRow['is_demo_mode']);
$outlookSettings = json_decode($outlookRow['settings'] ?? '{}', true) ?: [];
// Jira status ophalen
$stmtJira = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmtJira->execute([$userId]);
$jiraRow = $stmtJira->fetch();
$jiraSettings = json_decode($jiraRow['settings'] ?? '{}', true) ?: [];
$isJiraConnected = !empty($jiraSettings['jira_api_token']);
$companyJiraDomain = jira_get_company_domain();
$activeJiraDomain = !empty($jiraSettings['jira_domain']) ? $jiraSettings['jira_domain'] : $companyJiraDomain;
// Centrale Azure App configuratie status
$appConfig = microsoft_get_app_config();
// Notificatie-instellingen
$notifSettings = get_user_notification_settings($userId);
// Thema ophalen
$userTheme = get_user_theme($userId);
$pageTitle = 'Mijn Profiel & Koppelingen — Fociz';
require_once __DIR__ . '/includes/header.php';
?>
<div class="space-y-8 max-w-5xl mx-auto pb-12">
<!-- Profiel Header Card -->
<div class="relative overflow-hidden bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-8 shadow-xs transition">
<div class="absolute -top-20 -right-20 w-72 h-72 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="relative z-10 flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div class="flex items-center space-x-4 sm:space-x-5">
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 shadow-sm flex-shrink-0">
<div class="w-full h-full rounded-2xl bg-white dark:bg-[#0c0f17] flex items-center justify-center font-bold text-2xl sm:text-3xl text-indigo-600 dark:text-indigo-400 uppercase">
<?= strtoupper(substr($userProfile['name'], 0, 1)) ?>
</div>
</div>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white tracking-tight">
<?= htmlspecialchars($userProfile['name']) ?>
</h1>
<?php if ($userProfile['role'] === 'ADMIN'): ?>
<span class="px-2.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs font-semibold flex items-center space-x-1">
<i data-lucide="shield" class="w-3 h-3"></i>
<span>Beheerder</span>
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold flex items-center space-x-1">
<i data-lucide="user" class="w-3 h-3"></i>
<span>Collega</span>
</span>
<?php endif; ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/10 border border-teal-200 dark:border-teal-500/20 text-teal-700 dark:text-teal-400 text-xs font-semibold flex items-center space-x-1">
<i data-lucide="shield-check" class="w-3 h-3"></i>
<span>2FA Beveiligd</span>
</span>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400 flex items-center space-x-2">
<span><?= htmlspecialchars($userProfile['email']) ?></span>
<span>•</span>
<span>Lid sinds <?= date('d-m-Y', strtotime($userProfile['created_at'])) ?></span>
</p>
</div>
</div>
<?php if ($userProfile['role'] === 'ADMIN'): ?>
<div class="flex-shrink-0">
<a href="/admin.php" class="inline-flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-700 text-xs font-semibold transition shadow-xs">
<i data-lucide="shield-alert" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Systeembeheer Openen</span>
</a>
</div>
<?php endif; ?>
</div>
</div>
<!-- Meldingen Banner -->
<?php if ($message): ?>
<div class="p-4 rounded-xl flex items-start space-x-3 text-sm animate-in fade-in duration-200 <?= $messageType === 'success' ? 'bg-teal-50 dark:bg-teal-500/10 border border-teal-200 dark:border-teal-500/20 text-teal-800 dark:text-teal-200' : 'bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 text-rose-800 dark:text-rose-200' ?>">
<i data-lucide="<?= $messageType === 'success' ? 'check-circle-2' : 'alert-triangle' ?>" class="w-5 h-5 flex-shrink-0 mt-0.5 <?= $messageType === 'success' ? 'text-teal-600 dark:text-teal-400' : 'text-rose-600 dark:text-rose-400' ?>"></i>
<div class="flex-1 font-medium"><?= htmlspecialchars($message) ?></div>
</div>
<?php endif; ?>
<!-- Sectie 0: Weergave & Thema Instellingen (Dark & Light mode) -->
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
<i data-lucide="palette" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Weergave & Thema</h2>
<span id="themeStatusBadge" class="px-2.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 text-indigo-700 dark:text-indigo-400 text-[10px] font-semibold">
Actief: <?= ucfirst($userTheme) ?>
</span>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Kies uw gewenste uiterlijk: rustig donker grafiet, kraakhelder licht of synchroniseer met uw apparaat.</p>
</div>
</div>
</div>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Optie 1: Donker (Dark) -->
<button type="button" onclick="selectThemePreference('dark')" id="themeBtn-dark" class="theme-card relative text-left p-4 rounded-xl border-2 transition duration-150 flex flex-col justify-between group hover:shadow-md cursor-pointer bg-[#0c0f17] border-slate-800 text-white">
<div class="w-full h-24 rounded-lg bg-[#111622] p-2.5 border border-slate-800/80 mb-3 flex flex-col justify-between overflow-hidden shadow-inner">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<span class="w-2 h-2 rounded-full bg-rose-400/80"></span>
<span class="w-2 h-2 rounded-full bg-amber-400/80"></span>
<span class="w-2 h-2 rounded-full bg-teal-400/80"></span>
</div>
<span class="text-[9px] font-mono text-slate-500">fociz.dark</span>
</div>
<div class="space-y-1.5">
<div class="h-2 w-3/4 bg-indigo-500/60 rounded"></div>
<div class="h-2 w-1/2 bg-slate-800 rounded"></div>
</div>
<div class="flex space-x-1">
<div class="h-3 w-8 bg-slate-800 rounded"></div>
<div class="h-3 w-10 bg-indigo-500/20 rounded"></div>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-white flex items-center space-x-1.5">
<i data-lucide="moon" class="w-4 h-4 text-indigo-400"></i>
<span>Donkere Modus</span>
</span>
<span id="themeCheck-dark" class="theme-check hidden w-5 h-5 rounded-full bg-indigo-600 text-white items-center justify-center shadow-xs">
<i data-lucide="check" class="w-3.5 h-3.5 stroke-[3]"></i>
</span>
</div>
<p class="text-[11px] text-slate-400 mt-1 leading-relaxed">
Diepe neutrale contrasten, rustig voor de ogen in de avond of donkere werkplek.
</p>
</div>
</button>
<!-- Optie 2: Licht (Light) -->
<button type="button" onclick="selectThemePreference('light')" id="themeBtn-light" class="theme-card relative text-left p-4 rounded-xl border-2 transition duration-150 flex flex-col justify-between group hover:shadow-md cursor-pointer bg-[#f8fafc] border-slate-200 text-slate-900">
<div class="w-full h-24 rounded-lg bg-white p-2.5 border border-slate-200/90 mb-3 flex flex-col justify-between overflow-hidden shadow-xs">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<span class="w-2 h-2 rounded-full bg-rose-400/80"></span>
<span class="w-2 h-2 rounded-full bg-amber-400/80"></span>
<span class="w-2 h-2 rounded-full bg-teal-400/80"></span>
</div>
<span class="text-[9px] font-mono text-slate-400">fociz.light</span>
</div>
<div class="space-y-1.5">
<div class="h-2 w-3/4 bg-indigo-600/50 rounded"></div>
<div class="h-2 w-1/2 bg-slate-200 rounded"></div>
</div>
<div class="flex space-x-1">
<div class="h-3 w-8 bg-slate-100 rounded"></div>
<div class="h-3 w-10 bg-indigo-50 rounded"></div>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-slate-900 flex items-center space-x-1.5">
<i data-lucide="sun" class="w-4 h-4 text-amber-500"></i>
<span>Lichte Modus</span>
</span>
<span id="themeCheck-light" class="theme-check hidden w-5 h-5 rounded-full bg-indigo-600 text-white items-center justify-center shadow-xs">
<i data-lucide="check" class="w-3.5 h-3.5 stroke-[3]"></i>
</span>
</div>
<p class="text-[11px] text-slate-500 mt-1 leading-relaxed">
Kraakhelder, fris off-white en professioneel voor overdag en felle kantoorverlichting.
</p>
</div>
</button>
<!-- Optie 3: Systeem (Auto) -->
<button type="button" onclick="selectThemePreference('system')" id="themeBtn-system" class="theme-card relative text-left p-4 rounded-xl border-2 transition duration-150 flex flex-col justify-between group hover:shadow-md cursor-pointer bg-slate-50 dark:bg-[#111622] border-slate-200 dark:border-slate-800">
<div class="w-full h-24 rounded-lg border border-slate-200 dark:border-slate-800 mb-3 flex overflow-hidden shadow-xs">
<!-- Linker helft licht -->
<div class="w-1/2 bg-[#f8fafc] p-2 flex flex-col justify-between border-r border-slate-200">
<div class="flex space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-slate-300"></span>
<span class="w-1.5 h-1.5 rounded-full bg-slate-300"></span>
</div>
<div class="h-2 w-3/4 bg-indigo-600/40 rounded"></div>
<div class="h-2 w-1/2 bg-slate-200 rounded"></div>
</div>
<!-- Rechter helft donker -->
<div class="w-1/2 bg-[#0c0f17] p-2 flex flex-col justify-between">
<div class="flex justify-end space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-slate-700"></span>
<span class="w-1.5 h-1.5 rounded-full bg-slate-700"></span>
</div>
<div class="h-2 w-3/4 bg-indigo-500/50 rounded"></div>
<div class="h-2 w-1/2 bg-slate-800 rounded"></div>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="laptop" class="w-4 h-4 text-sky-500"></i>
<span>Systeem Volgen</span>
</span>
<span id="themeCheck-system" class="theme-check hidden w-5 h-5 rounded-full bg-indigo-600 text-white items-center justify-center shadow-xs">
<i data-lucide="check" class="w-3.5 h-3.5 stroke-[3]"></i>
</span>
</div>
<p class="text-[11px] text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">
Schakelt automatisch mee met uw Windows, macOS of browser voorkeur.
</p>
</div>
</button>
</div>
</div>
<!-- Sectie 1: Mijn Microsoft 365 Outlook Agenda -->
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 flex items-center justify-center text-sky-600 dark:text-sky-400 font-bold shadow-xs">
<i data-lucide="calendar" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Microsoft 365 Agenda</h2>
<?php if ($isOutlookConnected): ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse"></span>
<span>Live Verbonden</span>
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-[10px] font-semibold">
Niet Gekoppeld
</span>
<?php endif; ?>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Synchroniseer uw agenda-afspraken automatisch met uw dagplanning</p>
</div>
</div>
<?php if ($isOutlookConnected): ?>
<div class="flex items-center space-x-2">
<a href="/auth/oauth_microsoft.php" class="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 text-xs font-medium transition" title="Opnieuw autoriseren bij Microsoft">
Opnieuw Verbinden
</a>
<form method="POST" onsubmit="return confirm('Weet u zeker dat u uw Microsoft Outlook agenda wilt ontkoppelen?');">
<input type="hidden" name="action" value="disconnect_outlook">
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
Ontkoppelen
</button>
</form>
</div>
<?php endif; ?>
</div>
<div class="mt-6">
<?php if ($isOutlookConnected): ?>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 space-y-1.5">
<span class="text-[10px] uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400 block">Gekoppeld Microsoft Account</span>
<span class="text-sm font-bold text-slate-900 dark:text-white block truncate"><?= htmlspecialchars($outlookSettings['account_name'] ?? 'Microsoft Gebruiker') ?></span>
<span class="text-xs text-indigo-600 dark:text-indigo-400 block truncate"><?= htmlspecialchars($outlookSettings['account_email'] ?? $userProfile['email']) ?></span>
</div>
<div class="p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 space-y-1.5">
<span class="text-[10px] uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400 block">Microsoft Teams Koppeling</span>
<span class="text-sm font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="video" class="w-4 h-4 text-sky-500"></i>
<span>Automatische Meeting Links</span>
</span>
<span class="text-xs text-slate-500 dark:text-slate-400 block">Vergaderlinks in uw agenda worden direct klikbaar in uw dagplanning</span>
</div>
</div>
<form method="POST" class="mt-4 p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<input type="hidden" name="action" value="save_outlook_settings">
<div class="space-y-1">
<label for="sync_interval_seconds" class="text-xs font-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5 text-sky-500"></i>
<span>Automatische Agenda Synchronisatie</span>
</label>
<p class="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
Bepaalt hoe vaak het dagplanningscherm in de achtergrond controleert op gewijzigde of verwijderde Outlook afspraken.
</p>
</div>
<div class="flex items-center space-x-2">
<?php $currentInterval = (int)($outlookSettings['sync_interval_seconds'] ?? 30); ?>
<select id="sync_interval_seconds" name="sync_interval_seconds" class="px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-xs focus:ring-2 focus:ring-sky-500 focus:outline-hidden">
<option value="15" <?= $currentInterval === 15 ? 'selected' : '' ?>>Elke 15 seconden (Real-time)</option>
<option value="30" <?= $currentInterval === 30 ? 'selected' : '' ?>>Elke 30 seconden (Aanbevolen)</option>
<option value="60" <?= $currentInterval === 60 ? 'selected' : '' ?>>Elke 60 seconden (1 minuut)</option>
<option value="120" <?= $currentInterval === 120 ? 'selected' : '' ?>>Elke 2 minuten</option>
<option value="0" <?= $currentInterval === 0 ? 'selected' : '' ?>>Handmatig (en bij vensterfocus)</option>
</select>
<button type="submit" class="px-4 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 text-white font-semibold text-xs transition shadow-2xs whitespace-nowrap cursor-pointer">
Opslaan
</button>
</div>
</form>
<div class="mt-4 p-3.5 rounded-xl bg-teal-50 dark:bg-teal-500/10 border border-teal-200 dark:border-teal-500/20 text-teal-800 dark:text-teal-300 text-xs flex items-center justify-between">
<div class="flex items-center space-x-2">
<i data-lucide="check-circle" class="w-4 h-4 text-teal-600 dark:text-teal-400 flex-shrink-0"></i>
<span>Uw agenda synchroniseert live. Afspraken van vandaag verschijnen direct in uw <a href="/planner.php" class="underline font-semibold text-slate-900 dark:text-white">Dagplanning</a>.</span>
</div>
<a href="/planner.php" class="px-3 py-1 rounded-lg bg-teal-500/20 hover:bg-teal-500/30 text-teal-800 dark:text-teal-200 font-semibold text-xs transition flex-shrink-0">
Naar Dagplanning &rarr;
</a>
</div>
<?php else: ?>
<div class="p-6 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800 text-center space-y-4">
<div class="w-14 h-14 rounded-2xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 text-sky-600 dark:text-sky-400 flex items-center justify-center mx-auto">
<i data-lucide="calendar-plus" class="w-7 h-7"></i>
</div>
<div class="max-w-md mx-auto space-y-1">
<h3 class="text-base font-bold text-slate-900 dark:text-white">Verbind uw Microsoft Outlook Agenda</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">
Krijg uw dagelijkse afspraken automatisch te zien in Fociz en ontvang herinneringen vóórdat uw vergaderingen beginnen.
</p>
</div>
<?php if ($appConfig['is_configured']): ?>
<div>
<form method="POST">
<input type="hidden" name="action" value="connect_outlook">
<button type="submit" class="inline-flex items-center space-x-2 px-6 py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition shadow-xs cursor-pointer">
<i data-lucide="log-in" class="w-4 h-4"></i>
<span>Koppel Mijn Outlook Agenda</span>
</button>
</form>
<span class="text-[11px] text-slate-500 block mt-2">U wordt veilig doorgestuurd naar Microsoft om in te loggen</span>
</div>
<?php else: ?>
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300 text-xs max-w-lg mx-auto">
<i data-lucide="alert-circle" class="w-4 h-4 text-amber-500 inline mr-1 -mt-0.5"></i>
Microsoft 365 koppeling is nog niet geconfigureerd door de beheerder.
<?php if ($userProfile['role'] === 'ADMIN'): ?>
<a href="/admin.php" class="underline font-bold text-slate-900 dark:text-white ml-1">Stel Azure in via Systeembeheer &rarr;</a>
<?php else: ?>
Vraag uw beheerder om dit in te schakelen.
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- Sectie 2: Mijn Atlassian Jira Koppeling -->
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
<i data-lucide="check-square" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Atlassian Jira Koppeling</h2>
<?php if ($isJiraConnected): ?>
<span class="px-2.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-500/15 border border-teal-200 dark:border-teal-500/30 text-teal-700 dark:text-teal-400 text-[10px] font-semibold">
Verbonden
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-[10px] font-semibold">
Niet Gekoppeld
</span>
<?php endif; ?>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Importeer uw toegewezen Jira issues direct naar uw dagelijkse takenlijst</p>
</div>
</div>
<?php if ($isJiraConnected): ?>
<form method="POST" onsubmit="return confirm('Weet u zeker dat u Jira wilt ontkoppelen?');">
<input type="hidden" name="action" value="disconnect_jira">
<button type="submit" class="px-3 py-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-500/10 dark:hover:bg-rose-500/20 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20 text-xs font-medium transition cursor-pointer">
Jira Ontkoppelen
</button>
</form>
<?php endif; ?>
</div>
<form method="POST" class="mt-6 space-y-4">
<input type="hidden" name="action" value="save_jira">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Jira Cloud Domein
</label>
<input type="text" name="jira_domain" value="<?= htmlspecialchars($activeJiraDomain) ?>" placeholder="https://uwbedrijf.atlassian.net" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500 font-mono">
<span class="text-[10px] text-slate-500 mt-1 block">Standaard ingevuld door beheerder indien beschikbaar</span>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Uw Jira E-mailadres
</label>
<input type="email" name="jira_email" value="<?= htmlspecialchars($jiraSettings['jira_email'] ?? $userProfile['email']) ?>" placeholder="naam@bedrijf.nl" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-semibold text-slate-700 dark:text-slate-300">
Persoonlijk Atlassian API Token
</label>
<a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener noreferrer" class="text-[10px] text-indigo-600 dark:text-indigo-400 hover:underline flex items-center space-x-1">
<span>API Token Aanmaken</span>
<i data-lucide="external-link" class="w-3 h-3"></i>
</a>
</div>
<div class="relative">
<input type="password" id="jiraApiTokenInput" name="jira_api_token" value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : '' ?>" placeholder="Plak hier uw Atlassian API token..." class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-3.5 pr-10 py-2.5 text-xs text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<button type="button" onclick="toggleJiraToken()" class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
<i data-lucide="eye" class="w-4 h-4" id="eyeJiraIcon"></i>
</button>
</div>
</div>
<div class="pt-2 flex items-center justify-between">
<?php if ($isJiraConnected && !empty($jiraSettings['account_name'])): ?>
<span class="text-xs text-teal-600 dark:text-teal-400 flex items-center space-x-1.5">
<i data-lucide="check" class="w-4 h-4"></i>
<span>Verbonden als: <strong><?= htmlspecialchars($jiraSettings['account_name']) ?></strong></span>
</span>
<?php else: ?>
<span class="text-[11px] text-slate-500 dark:text-slate-400">Verbinding wordt automatisch gevalideerd bij het opslaan</span>
<?php endif; ?>
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs cursor-pointer">
Jira Opslaan & Testen
</button>
</div>
</form>
</div>
<!-- Sectie 3: Mijn Meldingen & Agenda Push Notificaties -->
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex items-center space-x-3.5 pb-6 border-b border-slate-100 dark:border-slate-800/80">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs">
<i data-lucide="bell" class="w-6 h-6"></i>
</div>
<div>
<h2 class="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">Mijn Meldingen & Agenda Herinneringen</h2>
<p class="text-xs text-slate-500 dark:text-slate-400">Krijg realtime meldingen vanuit uw browser zodra een afspraak nadert</p>
</div>
</div>
<div class="mt-6 space-y-5">
<!-- Browser Permissie Banner in Profiel -->
<div id="profileNotifPermGranted" class="hidden p-4 rounded-xl bg-teal-50 dark:bg-teal-500/10 border border-teal-200 dark:border-teal-500/30 text-teal-800 dark:text-teal-300 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex items-center space-x-2.5">
<i data-lucide="check-circle" class="w-5 h-5 text-teal-600 dark:text-teal-400 flex-shrink-0"></i>
<div>
<strong class="block text-xs text-slate-900 dark:text-white">Browser Pushberichten zijn Actief</strong>
<span class="text-[11px] text-teal-700 dark:text-teal-300/80">Uw browser heeft toestemming om bureaubladmeldingen weer te geven.</span>
</div>
</div>
<button type="button" onclick="focizTestNotification()" class="px-3 py-1.5 rounded-xl bg-teal-500/20 hover:bg-teal-500/30 text-teal-800 dark:text-teal-200 font-semibold text-xs transition flex items-center space-x-1.5 flex-shrink-0 cursor-pointer">
<i data-lucide="send" class="w-3.5 h-3.5"></i>
<span>Verzend Test Melding</span>
</button>
</div>
<div id="profileNotifPermRequest" class="p-4 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 text-indigo-800 dark:text-indigo-200 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex items-center space-x-2.5">
<i data-lucide="bell-ring" class="w-5 h-5 text-indigo-600 dark:text-indigo-400 flex-shrink-0"></i>
<div>
<strong class="block text-xs text-slate-900 dark:text-white">Toestemming van Browser Vereist</strong>
<span class="text-[11px] text-indigo-700 dark:text-indigo-300/80">Klik op de knop om pushmeldingen in te schakelen voor Fociz.</span>
</div>
</div>
<button type="button" onclick="focizRequestPermission()" class="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs transition shadow-xs flex-shrink-0 cursor-pointer">
Toestemming Verlenen
</button>
</div>
<div id="profileNotifPermDenied" class="hidden p-4 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-300 text-xs leading-relaxed">
<i data-lucide="alert-triangle" class="w-4 h-4 text-rose-600 dark:text-rose-400 inline mr-1 -mt-0.5"></i>
Meldingen zijn momenteel geblokkeerd in uw browser. Klik links in de adresbalk op het slotje/site-instellingen en zet "Meldingen" op "Toestaan".
</div>
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="save_notifications">
<div class="flex items-center justify-between p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
<div>
<span class="text-xs font-semibold text-slate-900 dark:text-white block">Meldingen Actief</span>
<span class="text-[11px] text-slate-500 dark:text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="notif_enabled" <?= !empty($notifSettings['enabled']) ? 'checked' : '' ?> class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Vooraankondiging
</label>
<select name="lead_time_minutes" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="0" <?= (int)$notifSettings['lead_time_minutes'] === 0 ? 'selected' : '' ?>>0 minuten (Precies op aanvang)</option>
<option value="2" <?= (int)$notifSettings['lead_time_minutes'] === 2 ? 'selected' : '' ?>>2 minuten van tevoren</option>
<option value="5" <?= (int)$notifSettings['lead_time_minutes'] === 5 ? 'selected' : '' ?>>5 minuten van tevoren (Aanbevolen)</option>
<option value="10" <?= (int)$notifSettings['lead_time_minutes'] === 10 ? 'selected' : '' ?>>10 minuten van tevoren</option>
<option value="15" <?= (int)$notifSettings['lead_time_minutes'] === 15 ? 'selected' : '' ?>>15 minuten van tevoren</option>
<option value="30" <?= (int)$notifSettings['lead_time_minutes'] === 30 ? 'selected' : '' ?>>30 minuten van tevoren</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Aantal herhalingen
</label>
<select name="repeat_count" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="1" <?= (int)$notifSettings['repeat_count'] === 1 ? 'selected' : '' ?>>1x (Alleen eerste melding)</option>
<option value="2" <?= (int)$notifSettings['repeat_count'] === 2 ? 'selected' : '' ?>>2x herhalen (Aanbevolen)</option>
<option value="3" <?= (int)$notifSettings['repeat_count'] === 3 ? 'selected' : '' ?>>3x herhalen</option>
<option value="5" <?= (int)$notifSettings['repeat_count'] === 5 ? 'selected' : '' ?>>5x herhalen</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Herhalingsinterval
</label>
<select name="repeat_interval_minutes" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="1" <?= (int)$notifSettings['repeat_interval_minutes'] === 1 ? 'selected' : '' ?>>Elke 1 minuut</option>
<option value="2" <?= (int)$notifSettings['repeat_interval_minutes'] === 2 ? 'selected' : '' ?>>Elke 2 minuten</option>
<option value="3" <?= (int)$notifSettings['repeat_interval_minutes'] === 3 ? 'selected' : '' ?>>Elke 3 minuten</option>
<option value="5" <?= (int)$notifSettings['repeat_interval_minutes'] === 5 ? 'selected' : '' ?>>Elke 5 minuten</option>
</select>
</div>
</div>
<div class="flex items-center justify-between p-4 rounded-xl bg-slate-50/70 dark:bg-slate-900/60 border border-slate-200/80 dark:border-slate-800">
<div class="flex items-center space-x-2.5">
<i data-lucide="volume-2" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<div>
<span class="text-xs font-semibold text-slate-900 dark:text-white block">Subtiel Geluidssignaal</span>
<span class="text-[11px] text-slate-500 dark:text-slate-400">Speel een zachte chime af bij binnenkomst van de herinnering</span>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="sound_enabled" <?= !empty($notifSettings['sound_enabled']) ? 'checked' : '' ?> class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs cursor-pointer">
Notificatievoorkeuren Opslaan
</button>
</div>
</form>
</div>
</div>
<!-- Sectie 4: Account & Beveiliging (Wachtwoord & 2FA) -->
<div class="space-y-6">
<!-- 2FA Beveiliging Card -->
<div class="p-5 rounded-2xl bg-teal-50/70 dark:bg-teal-950/20 border border-teal-200/80 dark:border-teal-800/40 flex flex-col sm:flex-row sm:items-center justify-between gap-4 shadow-xs">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-2xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-700 dark:text-teal-400 font-bold shadow-xs 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-teal-500/15 text-teal-800 dark:text-teal-300 font-semibold text-[10px] border border-teal-500/20">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-teal-700 dark:text-teal-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-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs space-y-4 transition">
<div class="flex items-center space-x-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<i data-lucide="user" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Persoonlijke Gegevens</h3>
</div>
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="update_profile">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
Weergavenaam
</label>
<input type="text" name="name" required value="<?= htmlspecialchars($userProfile['name']) ?>" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
E-mailadres
</label>
<input type="email" readonly disabled value="<?= htmlspecialchars($userProfile['email']) ?>" class="w-full bg-slate-100/70 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-800/60 rounded-xl px-3.5 py-2.5 text-xs text-slate-500 cursor-not-allowed">
<span class="text-[10px] text-slate-500 mt-1 block">E-mailadres is gekoppeld aan uw account en 2FA.</span>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-white text-xs font-semibold transition border border-slate-900 dark:border-slate-700 shadow-xs cursor-pointer">
Naam Opslaan
</button>
</div>
</form>
</div>
<!-- Wachtwoord Wijzigen Formulier -->
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs space-y-4 transition">
<div class="flex items-center space-x-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<i data-lucide="key" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Wachtwoord Wijzigen</h3>
</div>
<form method="POST" class="space-y-3">
<input type="hidden" name="action" value="change_password">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
Huidig Wachtwoord
</label>
<input type="password" name="current_password" required class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
Nieuw Wachtwoord (min. 8 tekens)
</label>
<input type="password" name="new_password" required minlength="8" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">
Bevestig Nieuw Wachtwoord
</label>
<input type="password" name="confirm_password" required minlength="8" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-white text-xs font-semibold transition border border-slate-900 dark:border-slate-700 shadow-xs cursor-pointer">
Wachtwoord Wijzigen
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// --- Thema Selector Functionaliteit ---
function selectThemePreference(theme) {
if (typeof focizSetTheme === 'function') {
focizSetTheme(theme, true);
}
updateProfileThemeUI(theme);
}
function updateProfileThemeUI(theme) {
const themes = ['dark', 'light', 'system'];
themes.forEach(t => {
const btn = document.getElementById('themeBtn-' + t);
const check = document.getElementById('themeCheck-' + t);
if (!btn || !check) return;
if (t === theme) {
btn.classList.add('ring-2', 'ring-indigo-500', 'border-indigo-500');
check.classList.remove('hidden');
check.classList.add('flex');
} else {
btn.classList.remove('ring-2', 'ring-indigo-500', 'border-indigo-500');
check.classList.add('hidden');
check.classList.remove('flex');
}
});
const badge = document.getElementById('themeStatusBadge');
if (badge) {
const label = theme.charAt(0).toUpperCase() + theme.slice(1);
badge.innerText = 'Actief: ' + label;
}
}
function toggleJiraToken() {
const input = document.getElementById('jiraApiTokenInput');
const icon = document.getElementById('eyeJiraIcon');
if (!input || !icon) return;
if (input.type === 'password') {
input.type = 'text';
icon.setAttribute('data-lucide', 'eye-off');
} else {
input.type = 'password';
icon.setAttribute('data-lucide', 'eye');
}
if (window.lucide) lucide.createIcons();
}
// Sync banner status met browser permissie & activeer actuele thema kaart
document.addEventListener('DOMContentLoaded', () => {
// Initialiseer geselecteerde thema-kaart
const activeTheme = localStorage.getItem('fociz_theme') || '<?= $userTheme ?>' || 'dark';
updateProfileThemeUI(activeTheme);
// Permissie status
if (!("Notification" in window)) return;
const grantedEl = document.getElementById('profileNotifPermGranted');
const reqEl = document.getElementById('profileNotifPermRequest');
const deniedEl = document.getElementById('profileNotifPermDenied');
if (Notification.permission === 'granted') {
if (grantedEl) grantedEl.classList.remove('hidden');
if (reqEl) reqEl.classList.add('hidden');
if (deniedEl) deniedEl.classList.add('hidden');
} else if (Notification.permission === 'denied') {
if (grantedEl) grantedEl.classList.add('hidden');
if (reqEl) reqEl.classList.add('hidden');
if (deniedEl) deniedEl.classList.remove('hidden');
} else {
if (grantedEl) grantedEl.classList.add('hidden');
if (reqEl) reqEl.classList.remove('hidden');
if (deniedEl) deniedEl.classList.add('hidden');
}
});
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* Fociz Roadmap & Tijdlijn
*/
require_once __DIR__ . '/includes/header.php';
$db = get_db_connection();
$selectedQuarter = $_GET['quarter'] ?? 'ALL';
$selectedStatus = $_GET['status'] ?? 'ALL';
$sql = "
SELECT r.*, u.name as creator_name, c.content as brainstorm_snippet
FROM roadmap_items r
JOIN users u ON r.created_by_id = u.id
LEFT JOIN brainstorm_cards c ON r.origin_brainstorm_card_id = c.id
WHERE 1=1
";
$params = [];
if ($selectedQuarter !== 'ALL') {
$sql .= " AND r.quarter = ?";
$params[] = $selectedQuarter;
}
if ($selectedStatus !== 'ALL') {
$sql .= " AND r.status = ?";
$params[] = $selectedStatus;
}
$sql .= " ORDER BY r.quarter ASC, r.priority DESC, r.due_date ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$roadmapItems = $stmt->fetchAll();
$quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
?>
<div class="space-y-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div>
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Planning & Strategie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white tracking-tight">
Roadmap & Tijdlijn
</h1>
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1">
Centraal overzicht van alle mijlpalen, features en doelen, direct voortgekomen uit brainstorms en gesynchroniseerd met Jira.
</p>
</div>
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition shadow-xs flex-shrink-0 cursor-pointer">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuw Item</span>
</button>
</div>
<!-- Filters: Quarters & Status -->
<div class="flex flex-wrap items-center justify-between gap-3 bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-4 shadow-xs transition">
<!-- Quarter Tabs -->
<div class="flex items-center space-x-1 text-xs">
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-medium <?= $selectedQuarter === 'ALL' ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800' ?>">
Alle Quarters
</a>
<?php foreach ($quarters as $q): ?>
<a href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-lg transition font-medium <?= $selectedQuarter === $q ? 'bg-indigo-600 text-white shadow-xs' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800' ?>">
<?= $q ?>
</a>
<?php endforeach; ?>
</div>
<!-- Status Filter -->
<div class="flex items-center space-x-2 text-xs">
<span class="text-slate-500 dark:text-slate-400 font-medium">Status:</span>
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="ALL" <?= $selectedStatus === 'ALL' ? 'selected' : '' ?>>Alle Statussen</option>
<option value="PLANNED" <?= $selectedStatus === 'PLANNED' ? 'selected' : '' ?>>Gepland</option>
<option value="IN_PROGRESS" <?= $selectedStatus === 'IN_PROGRESS' ? 'selected' : '' ?>>In Uitvoering</option>
<option value="REVIEW" <?= $selectedStatus === 'REVIEW' ? 'selected' : '' ?>>In Review</option>
<option value="COMPLETED" <?= $selectedStatus === 'COMPLETED' ? 'selected' : '' ?>>Voltooid</option>
</select>
</div>
</div>
<!-- Roadmap Items Lijst -->
<div class="space-y-4">
<?php if (empty($roadmapItems)): ?>
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 rounded-2xl p-12 text-center shadow-xs">
<i data-lucide="map-pin" class="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3"></i>
<h3 class="text-base font-semibold text-slate-900 dark:text-white">Geen items gevonden op de roadmap</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1 max-w-sm mx-auto">
Ga naar een <a href="/brainstorm.php" class="text-indigo-600 dark:text-indigo-400 underline font-medium">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
</p>
</div>
<?php else: ?>
<?php foreach ($roadmapItems as $item): ?>
<div class="bg-white dark:bg-[#111622] border border-slate-200/80 dark:border-slate-800 hover:border-indigo-300 dark:hover:border-indigo-700/60 rounded-2xl p-5 shadow-xs transition space-y-3" id="roadmap-item-<?= $item['id'] ?>">
<!-- Top header van item -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div class="flex items-center space-x-2.5 flex-wrap">
<!-- Quarter badge -->
<span class="px-2.5 py-0.5 rounded-md bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 font-mono text-xs font-semibold border border-indigo-200 dark:border-indigo-500/20">
<?= htmlspecialchars($item['quarter']) ?>
</span>
<!-- Type badge -->
<span class="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-[10px] font-semibold">
<?= htmlspecialchars($item['type']) ?>
</span>
<!-- Priority badge -->
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-rose-50 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-500/20' : ($item['priority'] === 'HIGH' ? 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700') ?>">
<?= htmlspecialchars($item['priority']) ?>
</span>
<!-- Jira Issue Key link -->
<?php if (!empty($item['jira_issue_key'])): ?>
<a href="/profile.php" class="flex items-center space-x-1 px-2 py-0.5 rounded-md bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 text-sky-700 dark:text-sky-400 text-[10px] font-mono hover:underline">
<i data-lucide="layers" class="w-3 h-3"></i>
<span><?= htmlspecialchars($item['jira_issue_key']) ?></span>
</a>
<?php endif; ?>
</div>
<!-- Status Selector -->
<div class="flex items-center space-x-2">
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1 text-xs text-slate-800 dark:text-slate-200 font-semibold focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="PLANNED" <?= $item['status'] === 'PLANNED' ? 'selected' : '' ?>>⏳ Gepland</option>
<option value="IN_PROGRESS" <?= $item['status'] === 'IN_PROGRESS' ? 'selected' : '' ?>>⚡ In Uitvoering</option>
<option value="REVIEW" <?= $item['status'] === 'REVIEW' ? 'selected' : '' ?>>🔍 In Review</option>
<option value="COMPLETED" <?= $item['status'] === 'COMPLETED' ? 'selected' : '' ?>>✅ Voltooid</option>
</select>
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:text-slate-500 dark:hover:text-rose-400 hover:bg-rose-500/10 transition cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- Titel & Beschrijving -->
<div>
<h3 class="text-base font-bold text-slate-900 dark:text-white leading-snug">
<?= htmlspecialchars($item['title']) ?>
</h3>
<?php if (!empty($item['description'])): ?>
<p class="text-slate-600 dark:text-slate-400 text-xs mt-1 whitespace-pre-wrap leading-relaxed">
<?= htmlspecialchars($item['description']) ?>
</p>
<?php endif; ?>
</div>
<!-- Origin Brainstorm snippet -->
<?php if (!empty($item['origin_brainstorm_card_id'])): ?>
<div class="bg-slate-50 dark:bg-slate-900/60 rounded-xl p-2.5 text-[11px] text-slate-500 dark:text-slate-400 flex items-center space-x-2 border border-slate-200/80 dark:border-slate-800">
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-500 flex-shrink-0"></i>
<span class="truncate">Ontstaan uit brainstormsessie</span>
</div>
<?php endif; ?>
<!-- Voortgangsbalk & Deadline -->
<div class="pt-2 border-t border-slate-100 dark:border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<!-- Progress Slider -->
<div class="flex items-center space-x-3 flex-1 max-w-md">
<span class="text-slate-500 dark:text-slate-400 text-[11px] font-medium w-16">Voortgang:</span>
<div class="flex-1 bg-slate-100 dark:bg-slate-900 h-2 rounded-full overflow-hidden border border-slate-200 dark:border-slate-800">
<div class="bg-gradient-to-r from-indigo-600 to-sky-500 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
</div>
<span class="font-mono text-slate-800 dark:text-slate-200 font-semibold w-10 text-right"><?= $item['progress'] ?>%</span>
<div class="flex space-x-1">
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer">25%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-[10px] text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white cursor-pointer">50%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-1.5 py-0.5 rounded bg-indigo-50 dark:bg-indigo-500/15 text-[10px] text-indigo-700 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-semibold cursor-pointer">100%</button>
</div>
</div>
<!-- Deadline & Creator -->
<div class="flex items-center space-x-3 text-slate-500 text-[11px]">
<?php if (!empty($item['due_date'])): ?>
<span class="flex items-center space-x-1 text-slate-700 dark:text-slate-300 font-medium">
<i data-lucide="calendar" class="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400"></i>
<span>Deadline: <?= date('d-m-Y', strtotime($item['due_date'])) ?></span>
</span>
<?php endif; ?>
<span>Toegevoegd door <?= htmlspecialchars($item['creator_name']) ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800 rounded-2xl p-6 shadow-xl max-w-lg w-full transition">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white flex items-center space-x-2">
<i data-lucide="map" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuw Roadmap Item Toevoegen</span>
</h3>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="text-slate-400 hover:text-slate-600 dark:hover:text-white cursor-pointer">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Beschrijving / Specificatie</label>
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Kwartaal</label>
<select id="manualQuarter" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3" selected>Q3</option>
<option value="Q4">Q4</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select id="manualType" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="FEATURE" selected>Feature</option>
<option value="EPIC">Epic</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Prioriteit</label>
<select id="manualPriority" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="CRITICAL">Kritiek</option>
<option value="HIGH" selected>Hoog</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="manualDueDate" class="w-full bg-slate-50 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitManualItem()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer shadow-xs">
Toevoegen aan Roadmap
</button>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl text-xs transition cursor-pointer">
Annuleren
</button>
</div>
</div>
</div>
<script>
async function updateRoadmapStatus(id, status) {
await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id, status })
});
}
async function updateRoadmapProgress(id, progress) {
await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id, progress })
});
location.reload();
}
async function deleteRoadmapItem(id) {
if (!confirm('Dit roadmap item verwijderen?')) return;
await fetch('/api/roadmap.php?action=delete_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id })
});
document.getElementById(`roadmap-item-${id}`)?.remove();
}
async function submitManualItem() {
const title = document.getElementById('manualTitle').value.trim();
const description = document.getElementById('manualDescription').value.trim();
const quarter = document.getElementById('manualQuarter').value;
const type = document.getElementById('manualType').value;
const priority = document.getElementById('manualPriority').value;
const jira_key = document.getElementById('manualJiraKey').value.trim();
const due_date = document.getElementById('manualDueDate').value;
if (!title) {
alert('Titel is verplicht');
return;
}
const res = await fetch('/api/roadmap.php?action=add_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ title, description, quarter, type, priority, jira_key, due_date })
});
const data = await res.json();
if (data.success) {
location.reload();
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+46
View File
@@ -0,0 +1,46 @@
/**
* Fociz Service Worker
* Beheert desktop push notificaties en click events voor agenda-afspraken.
*/
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
// Behandel klik op een notificatie
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url)
? event.notification.data.url
: '/planner.php';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// Als er al een Fociz tabblad open staat, breng die naar voren
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
if ('focus' in client) {
if (targetUrl.startsWith('http') && !targetUrl.includes(self.location.host)) {
// Externe meeting link (zoals Teams): open in nieuw tabblad
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
} else {
client.navigate(targetUrl);
return client.focus();
}
}
}
// Geen tabblad open: open een nieuw venster
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});
+269
View File
@@ -0,0 +1,269 @@
<?php
/**
* Fociz Team & Uitnodigingen Beheer (Enkel voor Admin)
*/
require_once __DIR__ . '/includes/auth.php';
$adminUser = require_admin();
$currentUser = $adminUser;
$db = get_db_connection();
$message = null;
$generatedLink = null;
$error = null;
// Uitnodiging aanmaken
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
$email = strtolower(trim($_POST['email'] ?? ''));
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in.";
} else {
// Controleer of e-mail al bestaat
$stmtCheck = $db->prepare("SELECT id FROM users WHERE email = ?");
$stmtCheck->execute([$email]);
if ($stmtCheck->fetch()) {
$error = "Er bestaat al een gebruiker met dit e-mailadres.";
} else {
$token = create_invite($adminUser['id'], $email, $role);
$baseUrl = rtrim(APP_URL, '/');
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
$message = "Uitnodigingslink succesvol aangemaakt!";
}
}
}
// Uitnodiging intrekken
if (isset($_GET['delete_invite'])) {
$inviteId = (int)$_GET['delete_invite'];
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
$stmtDel->execute([$inviteId]);
header('Location: /users.php');
exit;
}
// Gebruiker verwijderen
if (isset($_GET['delete_user'])) {
$delUserId = (int)$_GET['delete_user'];
if ($delUserId !== (int)$adminUser['id']) {
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
$stmtDelUser->execute([$delUserId]);
}
header('Location: /users.php');
exit;
}
// Haal alle gebruikers op
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll();
// Haal alle uitnodigingen op
$invites = $db->query("
SELECT i.*, u.name as inviter_name
FROM invites i
JOIN users u ON i.created_by_id = u.id
ORDER BY i.created_at DESC
")->fetchAll();
$pageTitle = 'Team & Uitnodigingen — Fociz';
require_once __DIR__ . '/includes/header.php';
?>
<div class="space-y-6 max-w-7xl mx-auto pb-12">
<!-- Header -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
<i data-lucide="shield" class="w-4 h-4"></i>
<span>Beheerderspaneel</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
Team & Uitnodigingen
</h1>
<p class="text-slate-500 dark:text-slate-400 text-xs sm:text-sm mt-1">
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
</p>
</div>
<?php if ($error): ?>
<div class="p-4 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-700 dark:text-red-400 text-xs font-semibold flex items-center space-x-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($message && $generatedLink): ?>
<div class="p-5 rounded-2xl bg-teal-50/80 dark:bg-teal-950/30 border border-teal-200 dark:border-teal-800/60 text-slate-900 dark:text-white space-y-2 shadow-xs transition">
<div class="flex items-center space-x-2 text-teal-700 dark:text-teal-400 font-bold text-xs">
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
<span><?= htmlspecialchars($message) ?></span>
</div>
<p class="text-xs text-slate-600 dark:text-slate-300">
Kopieer onderstaande beveiligde registratielink en stuur deze naar de collega:
</p>
<div class="flex items-center space-x-2 bg-white dark:bg-[#0c0f17] p-2.5 rounded-xl border border-teal-200 dark:border-slate-800">
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-teal-700 dark:text-teal-300 font-mono text-xs flex-1 outline-none">
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs rounded-xl transition flex items-center space-x-1 cursor-pointer">
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
<span id="copyBtnText">Kopiëren</span>
</button>
</div>
</div>
<?php endif; ?>
<!-- Uitnodiging Genereren Kaart -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs transition">
<h3 class="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center space-x-2">
<i data-lucide="user-plus" class="w-5 h-5 text-indigo-600 dark:text-indigo-400"></i>
<span>Nieuwe Collega Uitnodigen</span>
</h3>
<form method="POST" action="/users.php" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
<input type="hidden" name="action" value="create_invite">
<div class="md:col-span-6">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">E-mailadres van collega</label>
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-4 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-xs">
</div>
<div class="md:col-span-3">
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Rol</label>
<select name="role" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2.5 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="MEMBER">Collega (Standaard)</option>
<option value="ADMIN">Mede-beheerder</option>
</select>
</div>
<div class="md:col-span-3">
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-xs flex items-center justify-center space-x-2 cursor-pointer">
<i data-lucide="link" class="w-4 h-4"></i>
<span>Genereer Link</span>
</button>
</div>
</form>
</div>
<!-- Actieve Gebruikers Tabel -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs space-y-4 transition">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="users" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
<span>Geregistreerde Teamleden (<?= count($users) ?>)</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
<tr>
<th class="p-3">Naam & E-mail</th>
<th class="p-3">Rol</th>
<th class="p-3">2FA Beveiliging</th>
<th class="p-3">Lid sinds</th>
<th class="p-3 text-right">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
<?php foreach ($users as $u): ?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
<td class="p-3">
<div class="font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($u['name']) ?></div>
<div class="text-[11px] text-slate-500 dark:text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $u['role'] === 'ADMIN' ? 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/60' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300' ?>">
<?= $u['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</span>
</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-[10px] font-medium flex items-center space-x-1 w-max">
<i data-lucide="shield-check" class="w-3 h-3"></i>
<span>2FA Actief</span>
</span>
</td>
<td class="p-3 text-slate-500 dark:text-slate-400">
<?= date('d-m-Y', strtotime($u['created_at'])) ?>
</td>
<td class="p-3 text-right">
<?php if ($u['id'] !== $adminUser['id']): ?>
<a href="/users.php?delete_user=<?= $u['id'] ?>" onclick="return confirm('Gebruiker verwijderen?')" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
<?php else: ?>
<span class="text-[10px] text-slate-400 dark:text-slate-600 font-medium">Uzelf</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Uitstaande Uitnodigingen Tabel -->
<div class="bg-white dark:bg-[#111622] border border-slate-200 dark:border-slate-800/80 rounded-2xl p-6 sm:p-7 shadow-xs space-y-4 transition">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<h3 class="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="mail" class="w-4 h-4 text-amber-500"></i>
<span>Uitnodigingen Status (<?= count($invites) ?>)</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead class="bg-slate-50 dark:bg-[#0c0f17] text-slate-500 dark:text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-800">
<tr>
<th class="p-3">Uitgenodigd E-mailadres</th>
<th class="p-3">Rol</th>
<th class="p-3">Status</th>
<th class="p-3">Verloopt op</th>
<th class="p-3 text-right">Actie</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60">
<?php if (empty($invites)): ?>
<tr><td colspan="5" class="p-4 text-center text-slate-400 dark:text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
<?php else: ?>
<?php foreach ($invites as $inv):
$isUsed = !empty($inv['used_at']);
$isExpired = strtotime($inv['expires_at']) < time();
?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30 transition">
<td class="p-3 font-mono text-slate-900 dark:text-white"><?= htmlspecialchars($inv['email']) ?></td>
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
<td class="p-3">
<?php if ($isUsed): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-teal-50 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60">Geaccepteerd</span>
<?php elseif ($isExpired): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/30">Verlopen</span>
<?php else: ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">In afwachting</span>
<?php endif; ?>
</td>
<td class="p-3 text-slate-500 dark:text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></td>
<td class="p-3 text-right">
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" class="text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400 transition p-1 cursor-pointer">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
function copyInviteLink() {
const input = document.getElementById('inviteLinkInput');
input.select();
navigator.clipboard.writeText(input.value);
document.getElementById('copyBtnText').innerText = 'Gekopieerd!';
setTimeout(() => {
document.getElementById('copyBtnText').innerText = 'Kopiëren';
}, 2000);
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>