Schone start na index corruptie
This commit is contained in:
@@ -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
@@ -0,0 +1,12 @@
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# Systeem & Editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
config/.env.example
|
||||
config/config.php
|
||||
+10
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
<?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-slate-900/90 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-8 shadow-sm dark:shadow-2xl backdrop-blur-xl transition">
|
||||
<div class="absolute -top-24 -right-24 w-80 h-80 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="absolute -bottom-24 -left-24 w-80 h-80 bg-blue-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-emerald-500/10 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 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-sm">
|
||||
<i data-lucide="users" class="w-4 h-4 text-emerald-600 dark:text-emerald-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-emerald-600 hover:bg-emerald-500 text-white dark:text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-600/20">
|
||||
<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-emerald-500/10 border border-emerald-500/30 text-emerald-800 dark:text-emerald-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-emerald-600 dark:text-emerald-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-slate-900/70 border border-slate-200 dark:border-slate-800 rounded-3xl p-5 flex items-center space-x-4 shadow-sm transition">
|
||||
<div class="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-600 dark:text-emerald-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-slate-900/70 border border-slate-200 dark:border-slate-800 rounded-3xl p-5 flex items-center space-x-4 shadow-sm transition">
|
||||
<div class="w-12 h-12 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600 dark:text-blue-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-emerald-600 dark:text-emerald-400 block"><?= $appConfig['is_configured'] ? 'Azure App Actief' : 'Configuratie nodig' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900/70 border border-slate-200 dark:border-slate-800 rounded-3xl p-5 flex items-center space-x-4 shadow-sm transition">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 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-slate-900/70 border border-slate-200 dark:border-slate-800 rounded-3xl p-5 flex items-center space-x-4 shadow-sm transition">
|
||||
<div class="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/20 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 →</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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold shadow-sm">
|
||||
<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-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-[10px] font-bold">
|
||||
Geconfigureerd
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/15 border border-amber-500/30 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-blue-50 hover:bg-blue-100 dark:bg-blue-600/20 dark:hover:bg-blue-600/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-500/30 text-xs font-semibold transition" title="Verleen namens alle gebruikers beheerdersgoedkeuring in Azure">
|
||||
<i data-lucide="shield-check" class="w-4 h-4 text-blue-600 dark:text-blue-400"></i>
|
||||
<span>Admin Consent Verlenen</span>
|
||||
<i data-lucide="external-link" class="w-3.5 h-3.5 text-blue-600 dark:text-blue-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-slate-950/70 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-blue-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-slate-900 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-xs font-mono text-emerald-600 dark:text-emerald-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-slate-950 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-blue-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-slate-950 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-blue-500 font-mono">
|
||||
<span class="text-[10px] text-slate-500 dark:text-slate-400 mt-1 block">Vul uw domeinnaam in (<code class="text-emerald-600 dark:text-emerald-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-slate-950 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-blue-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-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition shadow-lg shadow-blue-500/20 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-blue-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-slate-950/60 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-blue-500/20 text-blue-600 dark:text-blue-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-blue-600 dark:text-blue-400 underline">portal.azure.com</a> → <em>Microsoft Entra ID</em> → <em>App registrations</em> → <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-blue-500/20 text-blue-600 dark:text-blue-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-emerald-600 dark:text-emerald-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-blue-500/20 text-blue-600 dark:text-blue-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> → <em>Add a permission</em> → <em>Microsoft Graph</em> → <strong>Delegated permissions</strong> → Vink aan: <code class="text-blue-600 dark:text-blue-400">User.Read</code> en <code class="text-blue-600 dark:text-blue-400">Calendars.ReadWrite</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2.5">
|
||||
<span class="w-5 h-5 rounded-full bg-blue-500/20 text-blue-600 dark:text-blue-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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-sm">
|
||||
<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-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-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-slate-950 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 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-lg shadow-indigo-500/20 cursor-pointer">
|
||||
Jira Domein Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 3. Organisatie Werkdag Standaarden (Release v1.4) -->
|
||||
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-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-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-600 dark:text-emerald-400 font-bold shadow-sm">
|
||||
<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-emerald-500/15 text-emerald-700 dark:text-emerald-400 text-xs font-bold border border-emerald-500/20">
|
||||
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-slate-950/60 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-slate-900 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-emerald-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-slate-900 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-emerald-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-slate-950/60 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-emerald-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-slate-900 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-emerald-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-slate-900 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-emerald-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-slate-950/60 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-red-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-slate-900 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-emerald-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-slate-900 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-emerald-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-slate-900 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-emerald-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standaard taakduur -->
|
||||
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-950/60 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-emerald-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-slate-900 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">
|
||||
<option value="15" <?= $workdaySettings['default_task_duration'] == 15 ? 'selected' : '' ?>>15 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-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 cursor-pointer">
|
||||
Werkdag Standaarden Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 4. AI & Brainstorm Co-Pilot (Release v1.5) -->
|
||||
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-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-2xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-600 dark:text-amber-400 font-bold shadow-sm">
|
||||
<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-emerald-500/15 text-emerald-700 dark:text-emerald-400 border border-emerald-500/20' : 'bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/20' ?> 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/30 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-amber-600 focus:ring-amber-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-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-amber-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-slate-950 border border-slate-300 dark:border-slate-700 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-amber-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="px-5 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-amber-500/20 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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 shadow-sm dark:shadow-xl 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-emerald-600 dark:text-emerald-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-emerald-600 dark:text-emerald-400 hover:underline font-semibold">
|
||||
Alle gebruikers →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<?php foreach ($recentUsers as $u): ?>
|
||||
<div class="p-3 rounded-2xl bg-slate-50 dark:bg-slate-950/60 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-emerald-600/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 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-blue-500/20 text-blue-700 dark:text-blue-300 border border-blue-500/30' : '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-500/20 text-indigo-700 dark:text-indigo-300 border border-indigo-500/30' : '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-sm">
|
||||
<i data-lucide="user-plus" class="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400"></i>
|
||||
<span>Nieuwe Collega Uitnodigen</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Systeem Gezondheid & Beveiliging -->
|
||||
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 shadow-sm dark:shadow-xl space-y-4 transition">
|
||||
<div class="flex items-center space-x-2 text-emerald-600 dark:text-emerald-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-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<span>Tweestapsverificatie (2FA)</span>
|
||||
<span class="text-emerald-600 dark:text-emerald-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-slate-950/60 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-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<span>Push Notificaties</span>
|
||||
<span class="text-emerald-600 dark:text-emerald-400 font-bold">Actief</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<span>Graph API Sync</span>
|
||||
<span class="text-blue-600 dark:text-blue-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
@@ -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()]);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
<?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;
|
||||
$durationMinutes = (int)($data['duration_minutes'] ?? 30);
|
||||
if ($durationMinutes <= 0) $durationMinutes = 30;
|
||||
$date = !empty($data['date']) ? trim($data['date']) : $today;
|
||||
$syncOutlook = !empty($data['sync_to_outlook']);
|
||||
|
||||
if (empty($title)) {
|
||||
echo json_encode(['error' => 'Titel is verplicht']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 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;
|
||||
$timeBlockEnd = 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,
|
||||
'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'] ?? null;
|
||||
} else {
|
||||
$outlookSyncError = $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook';
|
||||
}
|
||||
}
|
||||
|
||||
// Bereken timeBlockEnd indien nog niet gezet maar wel starttijd bekend
|
||||
if (!empty($timeBlockStart) && empty($timeBlockEnd)) {
|
||||
try {
|
||||
$dtEnd = new DateTime("{$date} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dtEnd->modify("+{$durationMinutes} minutes");
|
||||
$timeBlockEnd = $dtEnd->format('H:i');
|
||||
} catch (Throwable $e) {
|
||||
// negeer
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, 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,
|
||||
'is_completed' => 0,
|
||||
'status' => 'TODO',
|
||||
'source' => $source,
|
||||
'external_id' => $externalId,
|
||||
'external_url' => $externalUrl,
|
||||
],
|
||||
'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)),
|
||||
'outlook_error' => $outlookSyncError,
|
||||
]);
|
||||
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');
|
||||
$duration = (int)($task['duration_minutes'] ?: 30);
|
||||
|
||||
$msResult = microsoft_create_event($user['id'], [
|
||||
'subject' => $task['title'],
|
||||
'description' => "Gepland via Fociz Dagplanning (" . $task['priority'] . ")",
|
||||
'date' => $planDate,
|
||||
'start_time' => $startTime,
|
||||
'duration_minutes' => $duration,
|
||||
]);
|
||||
|
||||
if (empty($msResult['success']) || empty($msResult['event_id'])) {
|
||||
echo json_encode(['error' => $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Sla Outlook ID en bron op in de taak
|
||||
$stmtUp = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET source = 'OUTLOOK',
|
||||
external_id = ?,
|
||||
external_url = ?,
|
||||
time_block_start = COALESCE(time_block_start, ?),
|
||||
time_block_end = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
$stmtUp->execute([
|
||||
$msResult['event_id'],
|
||||
$msResult['web_link'] ?? 'https://outlook.office.com',
|
||||
$msResult['start_time'],
|
||||
$msResult['end_time'],
|
||||
$taskId,
|
||||
$user['id']
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'external_id' => $msResult['event_id'],
|
||||
'external_url' => $msResult['web_link'] ?? 'https://outlook.office.com',
|
||||
'start_time' => $msResult['start_time'],
|
||||
'end_time' => $msResult['end_time'],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4. Taak status toggelen (voltooid / open)
|
||||
if ($action === 'toggle_task') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$taskId = (int)($data['task_id'] ?? 0);
|
||||
$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;
|
||||
}
|
||||
|
||||
echo json_encode(['error' => 'Onbekende actie']);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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> →';
|
||||
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();
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<?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-[#090d16] text-white min-h-screen flex items-center justify-center p-4'>
|
||||
<div class='max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center'>
|
||||
<h2 class='text-xl font-bold text-red-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 bg-emerald-500 text-slate-950 font-bold rounded-xl text-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>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-lg bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<i data-lucide="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">Welkom bij Fociz</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Uitgenodigd door <strong class="text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-emerald-400 font-mono text-xs bg-emerald-500/10 px-2 py-0.5 rounded"><?= htmlspecialchars($invite['email']) ?></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 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-medium 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-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Verplichting -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider text-emerald-400">
|
||||
Verplichte Tweestapsverificatie (2FA)
|
||||
</h3>
|
||||
<p class="text-xs 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-xl">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono">
|
||||
Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium 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-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<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
@@ -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-slate-50 dark:bg-gradient-to-br dark:from-[#090d16] dark:via-[#0f172a] dark:to-[#090d16] 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-slate-900/90 border border-slate-200 dark:border-slate-800 rounded-3xl shadow-xl dark:shadow-2xl 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-emerald-500/10 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<i data-lucide="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold 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-slate-800/80 border border-slate-300 dark:border-slate-700 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-2 focus:ring-emerald-500/50 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-slate-800/80 border border-slate-300 dark:border-slate-700 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-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 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-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-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-slate-800/80 border border-slate-300 dark:border-slate-700 rounded-xl py-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 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-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<?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>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/env_badge.php'; ?>
|
||||
|
||||
<div class="w-full max-w-xl bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
|
||||
<i data-lucide="shield-check" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-white">Fociz Initialisatie</h1>
|
||||
<p class="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-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 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-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto border border-emerald-500/40">
|
||||
<i data-lucide="check-circle-2" class="w-10 h-10"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white mb-1">Beheerder Geïnstalleerd!</h2>
|
||||
<p class="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-950/90 border border-slate-800 rounded-xl p-4 text-left">
|
||||
<div class="text-xs font-semibold text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-300">
|
||||
<?php foreach ($backupCodes as $code): ?>
|
||||
<span class="bg-slate-900 px-2.5 py-1.5 rounded border border-slate-800 text-center"><?= htmlspecialchars($code) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 space-x-2">
|
||||
<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-medium 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-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium 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-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
|
||||
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
|
||||
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA QR Code & Koppeling -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center justify-center space-x-1.5 text-emerald-400">
|
||||
<i data-lucide="key" class="w-4 h-4"></i>
|
||||
<span>Tweestapsverificatie (2FA) Koppelen</span>
|
||||
</h3>
|
||||
<p class="text-xs 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-xl">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
|
||||
<span>Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium 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-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
|
||||
<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
@@ -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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl transition">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400 text-xs font-bold 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-black 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-amber-500 hover:bg-amber-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-amber-500/20 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-2xl bg-red-500/10 border border-red-500/30 text-red-700 dark:text-red-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-slate-900/60 border border-slate-200 dark:border-slate-800/80 rounded-3xl p-12 text-center shadow-sm">
|
||||
<i data-lucide="lightbulb" class="w-12 h-12 text-amber-500/40 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold 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-amber-500 hover:bg-amber-600 text-slate-950 font-bold rounded-xl text-xs transition cursor-pointer">
|
||||
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 dark:bg-slate-900/80 dark:hover:bg-slate-850 border border-slate-200 hover:border-amber-500/50 dark:border-slate-800 dark:hover:border-amber-500/40 rounded-3xl p-5 shadow-sm hover:shadow-md dark:shadow-lg 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-full bg-amber-500/15 border border-amber-500/30 text-amber-700 dark:text-amber-400 font-bold text-[11px]">
|
||||
<?= htmlspecialchars($sess['theme']) ?>
|
||||
</span>
|
||||
<span class="flex items-center space-x-1 text-slate-500 dark:text-slate-400 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-amber-600 dark:group-hover:text-amber-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-amber-600 dark:text-amber-400 group-hover:translate-x-1 transition font-semibold">
|
||||
<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-black/60 dark:bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-2xl 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-base font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-amber-500"></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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50">
|
||||
</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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button type="submit" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer">
|
||||
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'; ?>
|
||||
@@ -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 dark:border-amber-500/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 dark:border-rose-500/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' => 'emerald',
|
||||
'border' => 'border-emerald-200 dark:border-emerald-500/40',
|
||||
'bg' => 'bg-emerald-50/60 dark:bg-emerald-950/20',
|
||||
'badge' => 'bg-emerald-500/15 text-emerald-800 dark:text-emerald-300'
|
||||
],
|
||||
'Quick Wins' => [
|
||||
'title' => 'Quick Wins (Direct te doen)',
|
||||
'icon' => 'zap',
|
||||
'color' => 'blue',
|
||||
'border' => 'border-blue-200 dark:border-blue-500/40',
|
||||
'bg' => 'bg-blue-50/60 dark:bg-blue-950/20',
|
||||
'badge' => 'bg-blue-500/15 text-blue-800 dark:text-blue-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-slate-900/90 border border-slate-200 dark:border-slate-800 rounded-3xl p-5 shadow-sm dark:shadow-xl transition">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/brainstorm.php" class="p-2.5 rounded-2xl 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 py-0.5 rounded bg-amber-500/15 text-amber-700 dark:text-amber-400 font-bold border border-amber-500/20">
|
||||
<?= htmlspecialchars($session['theme']) ?>
|
||||
</span>
|
||||
<span class="text-slate-500 dark:text-slate-500">Gestart door <?= htmlspecialchars($session['creator_name']) ?></span>
|
||||
</div>
|
||||
<h1 class="text-xl sm:text-2xl font-black 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.5 py-2 rounded-xl bg-violet-50 hover:bg-violet-100 dark:bg-violet-950/60 dark:hover:bg-violet-900/60 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-800/80 text-xs font-bold transition cursor-pointer shadow-sm" 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.5 py-2 rounded-xl bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/60 dark:hover:bg-indigo-900/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/80 text-xs font-bold transition cursor-pointer shadow-sm" 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-sm">
|
||||
<i data-lucide="plus" class="w-4 h-4 text-amber-500"></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-gradient-to-r from-emerald-500 to-teal-400 hover:from-emerald-600 hover:to-teal-500 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 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-slate-900/60 border border-slate-200 dark:border-slate-800/80 rounded-3xl p-4 flex flex-col min-h-[500px] shadow-sm 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-bold text-slate-900 dark:text-white uppercase tracking-wider"><?= $catConfig['title'] ?></h3>
|
||||
</div>
|
||||
<span class="text-xs font-mono font-bold 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-2xl p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> backdrop-blur-sm shadow-sm 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-emerald-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-full bg-emerald-500/20 text-emerald-800 dark:text-emerald-300 font-bold text-[10px] flex items-center space-x-1 border border-emerald-500/30">
|
||||
<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 hover:bg-slate-100 dark:bg-slate-800/80 dark:hover:bg-slate-700 text-amber-600 dark:text-amber-300 border border-slate-200 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-red-600 dark:text-slate-500 dark:hover:text-red-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-300 dark:border-slate-800 hover:border-slate-400 dark:hover:border-slate-700 text-slate-500 hover:text-slate-800 dark:hover:text-slate-300 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-black/60 dark:bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 shadow-2xl 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-base font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="sticky-note" class="w-4 h-4 text-amber-500"></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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-amber-500 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button onclick="submitNewCard()" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer">
|
||||
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-black/60 dark:bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-900 border border-emerald-500/40 rounded-3xl p-6 shadow-2xl 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-base font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="map" class="w-4 h-4 text-emerald-600 dark:text-emerald-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-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-500/20 rounded-2xl p-3 text-xs text-emerald-800 dark:text-emerald-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-2xl 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-bold transition flex items-center space-x-1 cursor-pointer flex-shrink-0">
|
||||
<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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button onclick="submitConvertToRoadmap()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 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'; ?>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"ext-mysqli": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: #090d16; 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: #090d16; 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: #090d16; 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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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']];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (GROEN) -->
|
||||
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-emerald-500/70 shadow-lg shadow-emerald-500/25 backdrop-blur-md group">
|
||||
<!-- Pulsing Green 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-emerald-400 opacity-80"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500 shadow-sm shadow-emerald-500"></span>
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] font-black tracking-wider text-emerald-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.2 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-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>
|
||||
@@ -0,0 +1,26 @@
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-slate-200 dark:border-slate-800/80 bg-white/50 dark:bg-transparent backdrop-blur-sm py-6 text-center text-xs text-slate-500 transition">
|
||||
<div class="max-w-7xl mx-auto px-4 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-700 dark:text-slate-400">Fociz</span>
|
||||
<span>•</span>
|
||||
<span class="text-slate-600 dark:text-slate-500">Focus & Structuur Systeem</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-500 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>
|
||||
@@ -0,0 +1,364 @@
|
||||
<?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: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
html.dark body {
|
||||
background-color: #090d16;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
html:not(.dark) body {
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
::-webkit-scrollbar { width: 7px; height: 7px; }
|
||||
html.dark ::-webkit-scrollbar-track { background: #0b1120; }
|
||||
html.dark ::-webkit-scrollbar-thumb { background: #223049; border-radius: 4px; }
|
||||
html.dark ::-webkit-scrollbar-thumb:hover { background: #334668; }
|
||||
html:not(.dark) ::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
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-emerald-500 selection:text-white transition-colors duration-150">
|
||||
|
||||
<?php
|
||||
// Include flashy omgevingsindicator (Dev = Rood, Live = Groen)
|
||||
require_once __DIR__ . '/env_badge.php';
|
||||
?>
|
||||
|
||||
<?php if ($currentUser): ?>
|
||||
<header class="sticky top-0 z-40 bg-white/85 dark:bg-[#0c121e]/90 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/80 shadow-sm dark:shadow-none 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-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 flex items-center justify-center text-slate-950 font-black text-lg shadow-md shadow-emerald-500/20 group-hover:scale-105 transition">
|
||||
F
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xl font-bold tracking-tight text-slate-900 dark:text-white leading-none">Fociz</span>
|
||||
<span class="text-[10px] text-emerald-600 dark:text-emerald-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-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-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-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-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-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-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-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-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-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-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-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-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-emerald-600 dark:text-emerald-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.5 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60" title="Mijn Profiel & Instellingen">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 p-0.5 flex-shrink-0 shadow-sm">
|
||||
<div class="w-full h-full rounded-[10px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-xs text-emerald-600 dark:text-emerald-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left hidden sm:block">
|
||||
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition leading-tight">
|
||||
<?= htmlspecialchars($currentUser['name']) ?>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 dark:text-slate-400 leading-tight">
|
||||
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition hidden sm:block"></i>
|
||||
</a>
|
||||
|
||||
<!-- Uitloggen -->
|
||||
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-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-emerald-600 dark:text-emerald-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-emerald-600 dark:text-emerald-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-emerald-600 dark:text-emerald-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-emerald-600 dark:text-emerald-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-emerald-600 dark:text-emerald-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
|
||||
<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-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30 text-emerald-800 dark:text-emerald-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-emerald-600 dark:text-emerald-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-emerald-100 dark:bg-emerald-500/20 hover:bg-emerald-200 dark:hover:bg-emerald-500/30 text-emerald-800 dark:text-emerald-300 font-semibold text-[10px] transition">
|
||||
Test Melding
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notifPermRequest" class="p-3 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-900 dark:text-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="bell" class="w-4 h-4 text-blue-600 dark:text-blue-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-blue-600 hover:bg-blue-500 text-white font-bold text-[10px] transition shadow">
|
||||
Inschakelen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 text-red-800 dark:text-red-300 text-[11px] leading-relaxed">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-red-600 dark:text-red-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-emerald-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-emerald-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-emerald-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-emerald-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-emerald-600 dark:text-emerald-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-emerald-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="notifSaveStatus" class="hidden text-emerald-600 dark:text-emerald-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-emerald-600 hover:bg-emerald-500 text-white font-bold transition shadow-md shadow-emerald-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">
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
<?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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
+1081
File diff suppressed because it is too large
Load Diff
+901
@@ -0,0 +1,901 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
// 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-slate-900/90 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-8 shadow-sm dark:shadow-2xl backdrop-blur-xl transition">
|
||||
<div class="absolute -top-20 -right-20 w-72 h-72 bg-emerald-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-emerald-600 to-teal-400 p-0.5 shadow-xl shadow-emerald-500/20 flex-shrink-0">
|
||||
<div class="w-full h-full rounded-2xl bg-white dark:bg-slate-950 flex items-center justify-center font-black text-2xl sm:text-3xl text-emerald-600 dark:text-emerald-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-black 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-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-xs font-bold 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-blue-500/15 border border-blue-500/30 text-blue-700 dark:text-blue-400 text-xs font-bold 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-emerald-500/10 border border-emerald-500/20 text-emerald-700 dark:text-emerald-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-sm">
|
||||
<i data-lucide="shield-alert" class="w-4 h-4 text-emerald-600 dark:text-emerald-400"></i>
|
||||
<span>Systeembeheer Openen</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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-emerald-500/10 border border-emerald-500/30 text-emerald-800 dark:text-emerald-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-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-600 dark:text-purple-400 font-bold shadow-sm">
|
||||
<i data-lucide="palette" 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">Weergave & Thema</h2>
|
||||
<span id="themeStatusBadge" class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-[10px] font-bold">
|
||||
Actief: <?= ucfirst($userTheme) ?>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">Kies uw gewenste uiterlijk: rustig donker, kraakhelder licht of synchroniseer automatisch 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-2xl border-2 transition duration-150 flex flex-col justify-between group hover:shadow-md cursor-pointer bg-slate-900 border-slate-700 text-white">
|
||||
<div class="w-full h-24 rounded-xl bg-slate-950 p-2.5 border border-slate-800 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-red-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-emerald-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-emerald-500/40 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-emerald-500/20 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-bold text-white flex items-center space-x-1.5">
|
||||
<i data-lucide="moon" class="w-4 h-4 text-purple-400"></i>
|
||||
<span>Donkere Modus</span>
|
||||
</span>
|
||||
<span id="themeCheck-dark" class="theme-check hidden w-5 h-5 rounded-full bg-emerald-500 text-slate-950 items-center justify-center">
|
||||
<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 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-2xl border-2 transition duration-150 flex flex-col justify-between group hover:shadow-md cursor-pointer bg-white border-slate-200 text-slate-900">
|
||||
<div class="w-full h-24 rounded-xl bg-slate-100 p-2.5 border border-slate-200 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-red-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-emerald-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-emerald-600/40 rounded"></div>
|
||||
<div class="h-2 w-1/2 bg-slate-300 rounded"></div>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<div class="h-3 w-8 bg-slate-200 rounded"></div>
|
||||
<div class="h-3 w-10 bg-emerald-100 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-bold 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-emerald-500 text-slate-950 items-center justify-center">
|
||||
<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 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-2xl border-2 transition duration-150 flex flex-col justify-between group hover:shadow-md cursor-pointer bg-slate-50 dark:bg-slate-900 border-slate-300 dark:border-slate-700">
|
||||
<div class="w-full h-24 rounded-xl border border-slate-300 dark:border-slate-800 mb-3 flex overflow-hidden shadow-inner">
|
||||
<!-- Linker helft licht -->
|
||||
<div class="w-1/2 bg-slate-100 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-emerald-600/50 rounded"></div>
|
||||
<div class="h-2 w-1/2 bg-slate-300 rounded"></div>
|
||||
</div>
|
||||
<!-- Rechter helft donker -->
|
||||
<div class="w-1/2 bg-slate-950 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-emerald-400/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-bold text-slate-900 dark:text-white flex items-center space-x-1.5">
|
||||
<i data-lucide="laptop" class="w-4 h-4 text-blue-500"></i>
|
||||
<span>Systeem Volgen</span>
|
||||
</span>
|
||||
<span id="themeCheck-system" class="theme-check hidden w-5 h-5 rounded-full bg-emerald-500 text-slate-950 items-center justify-center">
|
||||
<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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold shadow-sm">
|
||||
<i data-lucide="calendar" 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">Mijn Microsoft 365 Agenda</h2>
|
||||
<?php if ($isOutlookConnected): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-[10px] font-bold flex items-center space-x-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-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-bold">
|
||||
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-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400 border border-red-500/20 text-xs font-medium transition">
|
||||
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-2xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 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-emerald-600 dark:text-emerald-400 block truncate"><?= htmlspecialchars($outlookSettings['account_email'] ?? $userProfile['email']) ?></span>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 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-blue-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>
|
||||
|
||||
<div class="mt-4 p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-800 dark:text-emerald-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-emerald-600 dark:text-emerald-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-emerald-500/20 hover:bg-emerald-500/30 text-emerald-800 dark:text-emerald-200 font-semibold text-xs transition flex-shrink-0">
|
||||
Naar Dagplanning →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="p-6 rounded-2xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 text-center space-y-4">
|
||||
<div class="w-14 h-14 rounded-2xl bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-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-blue-600 hover:bg-blue-500 text-white font-bold text-xs transition shadow-xl shadow-blue-500/25 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-500/10 border 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 →</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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-sm">
|
||||
<i data-lucide="check-square" 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">Mijn Atlassian Jira Koppeling</h2>
|
||||
<?php if ($isJiraConnected): ?>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-[10px] font-bold">
|
||||
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-bold">
|
||||
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-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400 border border-red-500/20 text-xs font-medium transition">
|
||||
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-950 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 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-950 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">
|
||||
</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-950 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">
|
||||
<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">
|
||||
<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-emerald-600 dark:text-emerald-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-bold transition shadow-lg shadow-indigo-500/20">
|
||||
Jira Opslaan & Testen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectie 3: Mijn Meldingen & Agenda Push Notificaties -->
|
||||
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-600 dark:text-emerald-400 font-bold shadow-sm">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="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-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-800 dark:text-emerald-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-emerald-600 dark:text-emerald-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-emerald-700 dark:text-emerald-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-emerald-500/20 hover:bg-emerald-500/30 text-emerald-800 dark:text-emerald-300 font-bold text-xs transition flex items-center space-x-1.5 flex-shrink-0">
|
||||
<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-2xl bg-blue-500/10 border border-blue-500/30 text-blue-800 dark:text-blue-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-blue-600 dark:text-blue-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-blue-700 dark:text-blue-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-blue-600 hover:bg-blue-500 text-white font-bold text-xs transition shadow flex-shrink-0">
|
||||
Toestemming Verlenen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="profileNotifPermDenied" class="hidden p-4 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-800 dark:text-red-300 text-xs leading-relaxed">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-red-600 dark:text-red-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-2xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<span class="text-xs font-bold 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-300 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-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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-2xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<i data-lucide="volume-2" class="w-4 h-4 text-emerald-600 dark:text-emerald-400"></i>
|
||||
<div>
|
||||
<span class="text-xs font-bold 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-300 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-emerald-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white dark:text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-600/20">
|
||||
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-3xl bg-emerald-50/80 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/25 flex flex-col sm:flex-row sm:items-center justify-between gap-4 shadow-sm">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-600 dark:text-emerald-400 font-bold shadow-sm flex-shrink-0">
|
||||
<i data-lucide="shield-check" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Tweestapsverificatie (2FA) is Actief</h3>
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-500 text-slate-950 font-bold text-[10px]">Beveiligd</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400 mt-0.5">
|
||||
Uw Fociz account is optimaal beveiligd met een Authenticator app (TOTP) voor maximale privacy- en dataveiligheid.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs font-mono font-medium text-emerald-700 dark:text-emerald-400 flex items-center space-x-1.5 flex-shrink-0 self-end sm:self-center">
|
||||
<i data-lucide="lock" class="w-4 h-4"></i>
|
||||
<span>Verplicht & Actief</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Profielnaam formulier -->
|
||||
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-4 transition">
|
||||
<div class="flex items-center space-x-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<i data-lucide="user" class="w-5 h-5 text-emerald-600 dark:text-emerald-400"></i>
|
||||
<h3 class="text-base 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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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 dark:bg-slate-950/50 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-sm">
|
||||
Naam Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Wachtwoord Wijzigen Formulier -->
|
||||
<div class="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-4 transition">
|
||||
<div class="flex items-center space-x-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<i data-lucide="key" class="w-5 h-5 text-emerald-600 dark:text-emerald-400"></i>
|
||||
<h3 class="text-base 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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-900 dark:text-white focus:outline-none focus:border-emerald-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-sm">
|
||||
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-emerald-500', 'border-emerald-500');
|
||||
check.classList.remove('hidden');
|
||||
check.classList.add('flex');
|
||||
} else {
|
||||
btn.classList.remove('ring-2', 'ring-emerald-500', 'border-emerald-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'; ?>
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
<?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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl transition">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-emerald-600 dark:text-emerald-400 text-xs font-bold 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-black 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-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 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-slate-900/40 border border-slate-200 dark:border-slate-800/80 rounded-2xl p-4 shadow-sm 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-xl transition font-medium <?= $selectedQuarter === 'ALL' ? 'bg-emerald-500 text-slate-950 font-bold shadow-sm' : '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-xl transition font-medium <?= $selectedQuarter === $q ? 'bg-emerald-500 text-slate-950 font-bold shadow-sm' : '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-800 border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<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-slate-900/60 border border-slate-200 dark:border-slate-800/80 rounded-3xl p-12 text-center shadow-sm">
|
||||
<i data-lucide="map-pin" class="w-12 h-12 text-emerald-500/40 mx-auto mb-3"></i>
|
||||
<h3 class="text-base font-bold 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-amber-600 dark:text-amber-400 underline font-semibold">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-slate-900/80 border border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 rounded-3xl p-5 shadow-sm hover:shadow-md dark:shadow-lg 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-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-mono text-xs font-bold border border-emerald-500/30">
|
||||
<?= htmlspecialchars($item['quarter']) ?>
|
||||
</span>
|
||||
|
||||
<!-- Type badge -->
|
||||
<span class="px-2 py-0.5 rounded-full 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-red-500/15 text-red-700 dark:text-red-400 border border-red-500/30' : ($item['priority'] === 'HIGH' ? 'bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30' : 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400') ?>">
|
||||
<?= 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-full bg-blue-50 dark:bg-blue-950/60 border border-blue-200 dark:border-blue-800/60 text-blue-700 dark:text-blue-300 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-950 border border-slate-300 dark:border-slate-700 rounded-xl px-2.5 py-1 text-xs text-slate-800 dark:text-slate-200 font-semibold focus:outline-none">
|
||||
<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-red-600 dark:text-slate-600 dark:hover:text-red-400 hover:bg-red-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-950/50 rounded-xl p-2.5 text-[11px] text-slate-500 dark:text-slate-400 flex items-center space-x-2 border border-slate-200 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-950 h-2 rounded-full overflow-hidden border border-slate-200 dark:border-slate-800">
|
||||
<div class="bg-gradient-to-r from-emerald-500 to-teal-400 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
|
||||
</div>
|
||||
<span class="font-mono text-slate-800 dark:text-slate-200 font-bold 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-emerald-500/20 text-[10px] text-emerald-700 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-300 font-bold 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-emerald-600 dark:text-emerald-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-black/60 dark:bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 shadow-2xl 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-base font-bold text-slate-900 dark:text-white flex items-center space-x-2">
|
||||
<i data-lucide="map" class="w-4 h-4 text-emerald-600 dark:text-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3.5 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-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-950 border border-slate-300 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none 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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
<option value="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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs font-mono focus:outline-none">
|
||||
</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-950 border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-1.5 text-slate-900 dark:text-white text-xs focus:outline-none">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 pt-2">
|
||||
<button onclick="submitManualItem()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition cursor-pointer">
|
||||
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>
|
||||
</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'; ?>
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl transition">
|
||||
<div class="flex items-center space-x-2 text-emerald-600 dark:text-emerald-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-3xl bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-300 dark:border-emerald-500/40 text-slate-900 dark:text-white space-y-2 shadow-sm dark:shadow-xl transition">
|
||||
<div class="flex items-center space-x-2 text-emerald-700 dark:text-emerald-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-slate-950 p-2.5 rounded-2xl border border-emerald-200 dark:border-slate-800">
|
||||
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-emerald-700 dark:text-emerald-300 font-mono text-xs flex-1 outline-none">
|
||||
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl 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-emerald-600 dark:text-emerald-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-slate-950 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-emerald-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-slate-950 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">
|
||||
<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-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-4 transition">
|
||||
<div class="flex items-center 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-emerald-600 dark:text-emerald-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-slate-950/60 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-emerald-500/15 text-emerald-700 dark:text-emerald-400 border border-emerald-500/30' : '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-emerald-500/10 text-emerald-700 dark:text-emerald-400 border border-emerald-500/20 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-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-3xl p-6 sm:p-7 shadow-sm dark:shadow-xl space-y-4 transition">
|
||||
<div class="flex items-center 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-slate-950/60 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-emerald-500/15 text-emerald-700 dark:text-emerald-400 border border-emerald-500/30">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'; ?>
|
||||
Reference in New Issue
Block a user