Merge branch 'dev' into main met behoud van live omgevingsconfiguratie en opschoning van overbodige bestanden

This commit is contained in:
2026-09-08 14:45:02 +02:00
22 changed files with 2051 additions and 340 deletions
+15 -1
View File
@@ -3,10 +3,24 @@ DB_HOST="127.0.0.1"
DB_PORT="3306" DB_PORT="3306"
DB_NAME="fociz" DB_NAME="fociz"
DB_USER="root" DB_USER="root"
DB_PASS="uw_wachtwoord" DB_PASS=""
# Omgeving: 'development' (knippert rood) of 'live' (knippert groen)
APP_ENV="development"
# Applicatie URL (voor uitnodigingslinks) # Applicatie URL (voor uitnodigingslinks)
APP_URL="https://uw-domein.nl" APP_URL="https://uw-domein.nl"
# Applicatie Geheime Sleutel (willekeurige tekenreeks) # Applicatie Geheime Sleutel (willekeurige tekenreeks)
APP_SECRET="fociz-maria-db-super-secret-key-998877" APP_SECRET="fociz-maria-db-super-secret-key-998877"
# Microsoft 365 / Outlook (Optionele globale defaults; kan ook via web UI worden ingesteld)
MICROSOFT_CLIENT_ID=""
MICROSOFT_CLIENT_SECRET=""
MICROSOFT_TENANT_ID="common"
# Atlassian Jira Cloud (Optionele globale defaults; kan ook via web UI worden ingesteld)
JIRA_DOMAIN=""
JIRA_EMAIL=""
JIRA_API_TOKEN=""
+3
View File
@@ -0,0 +1,3 @@
# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main
config/config.php merge=ours
includes/env_badge.php merge=ours
-16
View File
@@ -1,16 +0,0 @@
# Fociz Apache Configuratie
DirectoryIndex index.php index.html
# UTF-8 standaard
AddDefaultCharset UTF-8
# Blokkeer directe toegang tot SQL schema en configuratiebestanden
<FilesMatch "^(schema\.sql|\.env|\.git.*)$">
Order allow,deny
Deny from all
</FilesMatch>
# PHP foutmeldingen in productie (kan uitgeschakeld worden indien gewenst)
php_flag display_errors Off
php_value error_reporting E_ALL
+10
View File
@@ -0,0 +1,10 @@
FROM php:8.2-apache
# Installeer MariaDB/MySQL drivers
RUN docker-php-ext-install pdo pdo_mysql mysqli
# Activeer Apache mod_rewrite voor schone URL's
RUN a2enmod rewrite
# Zorg dat Apache op poort 80 luistert
EXPOSE 80
+2 -2
View File
@@ -85,9 +85,9 @@ APP_URL="https://uw-domein.nl"
``` ```
### 3. Automatische Tabellen Aanmaak ### 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 `schema.sql` vanzelf aangemaakt! 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 `schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).* *(Optioneel kunt u `config/schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).*
### 4. Eerste Beheerder Instellen ### 4. Eerste Beheerder Instellen
1. Open uw browser en ga naar `https://uw-domein.nl`. 1. Open uw browser en ga naar `https://uw-domein.nl`.
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* AJAX API voor Integraties (Outlook, Teams, Jira)
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/integrations/microsoft.php';
require_once __DIR__ . '/../includes/integrations/jira.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Jira Verbinding Testen & Optioneel Opslaan
if ($action === 'test_jira') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$domain = trim($input['jira_domain'] ?? '');
$email = trim($input['jira_email'] ?? '');
$apiToken = trim($input['jira_api_token'] ?? '');
$save = !empty($input['save']);
// Als er geen nieuw token is meegegeven, kijk of er een opgeslagen token is
if (empty($apiToken)) {
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
$stmt->execute([$user['id']]);
$saved = json_decode($stmt->fetchColumn() ?: '{}', true);
$apiToken = $saved['jira_api_token'] ?? '';
if (empty($domain)) $domain = $saved['jira_domain'] ?? '';
if (empty($email)) $email = $saved['jira_email'] ?? '';
}
$res = jira_test_connection($domain, $email, $apiToken);
if ($res['success'] && $save) {
$jiraUser = $res['user'];
$settings = json_encode([
'jira_domain' => jira_normalize_domain($domain),
'jira_email' => $email,
'jira_api_token' => $apiToken,
'account_name' => $jiraUser['displayName'] ?? '',
'account_id' => $jiraUser['accountId'] ?? '',
]);
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, 'JIRA', 1, 0, ?, NOW())
ON DUPLICATE KEY UPDATE
is_enabled = 1,
is_demo_mode = 0,
settings = VALUES(settings),
last_sync_at = NOW()
");
$stmt->execute([$user['id'], $settings]);
// Wis eventuele oude sessie cache
unset($_SESSION["jira_issues_user_{$user['id']}"]);
}
echo json_encode($res);
exit;
}
// 2. Jira Issues Ophalen (Toegewezen aan de gebruiker)
if ($action === 'get_jira_issues') {
$force = !empty($_GET['force']) || !empty($_POST['force']);
$res = jira_get_user_issues($user['id'], $force);
echo json_encode($res);
exit;
}
// 3. Outlook Agenda Afspraken Ophalen
if ($action === 'get_outlook_events') {
$date = $_GET['date'] ?? date('Y-m-d');
$events = microsoft_fetch_today_events($user['id'], $date);
echo json_encode([
'success' => true,
'date' => $date,
'events' => $events,
'count' => count($events),
]);
exit;
}
// 4. Integratie Ontkoppelen
if ($action === 'disconnect') {
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
$provider = strtoupper(trim($input['provider'] ?? ''));
if ($provider === 'OUTLOOK') {
microsoft_disconnect($user['id']);
echo json_encode(['success' => true, 'message' => 'Microsoft Outlook ontkoppeld']);
exit;
}
if ($provider === 'JIRA') {
jira_disconnect($user['id']);
echo json_encode(['success' => true, 'message' => 'Atlassian Jira ontkoppeld']);
exit;
}
echo json_encode(['error' => 'Onbekende provider']);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+7 -3
View File
@@ -53,11 +53,15 @@ try {
$stmtPlan->execute([$user['id'], $date]); $stmtPlan->execute([$user['id'], $date]);
$planId = $db->lastInsertId(); $planId = $db->lastInsertId();
$source = !empty($data['source']) ? substr(trim($data['source']), 0, 50) : 'FOCIZ';
$externalId = !empty($data['external_id']) ? substr(trim($data['external_id']), 0, 100) : null;
$externalUrl = !empty($data['external_url']) ? trim($data['external_url']) : null;
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, status) INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, status, source, external_id, external_url)
VALUES (?, ?, ?, ?, ?, 'TODO') VALUES (?, ?, ?, ?, ?, 'TODO', ?, ?, ?)
"); ");
$stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart]); $stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $source, $externalId, $externalUrl]);
$taskId = (int)$db->lastInsertId(); $taskId = (int)$db->lastInsertId();
echo json_encode([ echo json_encode([
+98
View File
@@ -0,0 +1,98 @@
<?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';
$user = require_auth();
$db = get_db_connection();
// 1. Foutafhandeling vanuit Microsoft
if (!empty($_GET['error'])) {
$errorDescription = $_GET['error_description'] ?? $_GET['error'];
header('Location: /integrations.php?error=' . urlencode("Microsoft melding: {$errorDescription}"));
exit;
}
// 2. CSRF State validatie
$receivedState = $_GET['state'] ?? '';
$expectedState = $_SESSION['ms_oauth_state'] ?? '';
unset($_SESSION['ms_oauth_state']);
if (empty($receivedState) || !hash_equals($expectedState, $receivedState)) {
header('Location: /integrations.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.'));
exit;
}
// 3. Autorisatiecode controleren
$code = $_GET['code'] ?? '';
if (empty($code)) {
header('Location: /integrations.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.'));
exit;
}
// 4. Instellingen ophalen
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$user['id']]);
$row = $stmt->fetch();
$savedSettings = json_decode($row['settings'] ?? '{}', true) ?: [];
$clientId = !empty($savedSettings['client_id']) ? $savedSettings['client_id'] : fociz_env('MICROSOFT_CLIENT_ID', '');
$clientSecret = !empty($savedSettings['client_secret']) ? $savedSettings['client_secret'] : fociz_env('MICROSOFT_CLIENT_SECRET', '');
$tenantId = !empty($savedSettings['tenant_id']) ? $savedSettings['tenant_id'] : fociz_env('MICROSOFT_TENANT_ID', 'common');
if (empty($clientId) || empty($clientSecret)) {
header('Location: /integrations.php?error=' . urlencode('Client ID of Client Secret ontbreekt in Fociz. Vul deze eerst in bij de Outlook instellingen.'));
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: /integrations.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
$newSettings = array_merge($savedSettings, [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'tenant_id' => $tenantId,
'expires_at' => $expiresAt,
'account_email' => $accountEmail,
'account_name' => $accountName,
]);
$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 Integraties Hub
header('Location: /integrations.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!"));
exit;
+43
View File
@@ -0,0 +1,43 @@
<?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 eventueel opgeslagen client_id en tenant_id op uit database
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$user['id']]);
$row = $stmt->fetch();
$savedSettings = json_decode($row['settings'] ?? '{}', true) ?: [];
$clientId = !empty($_GET['client_id'])
? trim($_GET['client_id'])
: (!empty($savedSettings['client_id']) ? $savedSettings['client_id'] : fociz_env('MICROSOFT_CLIENT_ID', ''));
$tenantId = !empty($_GET['tenant_id'])
? trim($_GET['tenant_id'])
: (!empty($savedSettings['tenant_id']) ? $savedSettings['tenant_id'] : fociz_env('MICROSOFT_TENANT_ID', 'common'));
if (empty($clientId)) {
header('Location: /integrations.php?error=missing_ms_client_id');
exit;
}
// Bepaal de dynamische of geconfigureerde redirect URI
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
// Genereer unieke CSRF state
$state = bin2hex(random_bytes(16));
$_SESSION['ms_oauth_state'] = $state;
$_SESSION['ms_oauth_user_id'] = $user['id'];
// Stuur gebruiker door naar Microsoft login
$authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state);
header("Location: {$authUrl}");
exit;
+2 -1
View File
@@ -15,6 +15,7 @@ if (has_admin_user()) {
$error = null; $error = null;
$success = false; $success = false;
$backupCodes = []; $backupCodes = [];
$email = '';
// Haal of genereer een tijdelijke sessie-sleutel voor de 2FA configuratie // Haal of genereer een tijdelijke sessie-sleutel voor de 2FA configuratie
if (empty($_SESSION['setup_totp_secret'])) { if (empty($_SESSION['setup_totp_secret'])) {
@@ -148,7 +149,7 @@ $pageTitle = "Fociz — Eerste Initialisatie";
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-xs font-medium text-slate-300 mb-1">Volledige naam</label> <label class="block text-xs font-medium text-slate-300 mb-1">Volledige naam</label>
<input type="text" name="name" required placeholder="Bijv. Niek Rabelink" 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"> <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>
<div> <div>
+9
View File
@@ -0,0 +1,9 @@
{
"require": {
"php": "^8.2",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-mysqli": "*"
}
}
+68 -15
View File
@@ -1,40 +1,93 @@
<?php <?php
/** /**
* Fociz Applicatie- en Databaseconfiguratie * Fociz Applicatie- en Databaseconfiguratie (MAIN / LIVE BRANCH)
* Laadt automatisch .env (of .env.example indien .env niet bestaat)
*/ */
// Automatisch eenvoudige .env ondersteuning indien aanwezig $envVars = [];
$envFile = __DIR__ . '/../.env';
// 1. Zoek naar .env of val terug op .env.example
$envCandidates = [
__DIR__ . '/../.env',
__DIR__ . '/../.env.example',
];
foreach ($envCandidates as $envFile) {
if (file_exists($envFile)) { if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) { foreach ($lines as $line) {
if (str_starts_with(trim($line), '#')) continue; $line = trim($line);
// Negeer lege regels en commentaarregels (# of //)
if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '//')) {
continue;
}
if (strpos($line, '=') !== false) { if (strpos($line, '=') !== false) {
list($name, $value) = explode('=', $line, 2); list($name, $value) = explode('=', $line, 2);
$name = trim($name); $name = trim($name);
$value = trim($value);
// Verwijder omlijstende aanhalingstekens (" of ')
$value = trim($value, " \t\n\r\0\x0B\"'"); $value = trim($value, " \t\n\r\0\x0B\"'");
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv(sprintf('%s=%s', $name, $value)); // Sla op in array en zet in globale PHP variabelen
$envVars[$name] = $value;
$_ENV[$name] = $value; $_ENV[$name] = $value;
$_SERVER[$name] = $value; $_SERVER[$name] = $value;
@putenv("{$name}={$value}");
} }
} }
} }
break; // Stop zodra we het eerste geldige bestand hebben ingeladen
}
} }
// MariaDB Verbindingsgegevens (pas deze aan naar uw server instellingen of gebruik .env) /**
define('DB_HOST', getenv('DB_HOST') ?: '127.0.0.1'); * Veilige helper om een omgevingsvariabele op te vragen
define('DB_PORT', getenv('DB_PORT') ?: '3306'); */
define('DB_NAME', getenv('DB_NAME') ?: 'fociz'); if (!function_exists('fociz_env')) {
define('DB_USER', getenv('DB_USER') ?: 'focizlive_user'); function fociz_env(string $key, $default = null) {
define('DB_PASS', getenv('DB_PASS') !== false ? getenv('DB_PASS') : 'F0c1z_Live_Secure_2026!'); global $envVars;
if (isset($envVars[$key]) && $envVars[$key] !== '') {
return $envVars[$key];
}
if (isset($_ENV[$key]) && $_ENV[$key] !== '') {
return $_ENV[$key];
}
if (isset($_SERVER[$key]) && $_SERVER[$key] !== '') {
return $_SERVER[$key];
}
$val = @getenv($key);
if ($val !== false && $val !== '') {
return $val;
}
return $default;
}
}
// MariaDB Verbindingsgegevens (Live defaults voor main branch; overschrijfbaar via server .env)
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
define('DB_PORT', fociz_env('DB_PORT', '3306'));
define('DB_NAME', fociz_env('DB_NAME', 'fociz'));
define('DB_USER', fociz_env('DB_USER', 'focizlive_user'));
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Live_Secure_2026!'));
define('DB_CHARSET', 'utf8mb4'); define('DB_CHARSET', 'utf8mb4');
// Applicatie instellingen // Applicatie instellingen (Live defaults)
define('APP_NAME', 'Fociz'); define('APP_NAME', 'Fociz');
define('APP_URL', getenv('APP_URL') ?: ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost'))); define('APP_URL', fociz_env('APP_URL', ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'fociz.nl'))));
define('APP_SECRET', getenv('APP_SECRET') ?: 'fociz-maria-db-super-secret-key-998877'); define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
define('APP_ENV', fociz_env('APP_ENV', 'live')); // 'live' voor de main branch
// Tijdzone // Tijdzone
date_default_timezone_set('Europe/Amsterdam'); date_default_timezone_set('Europe/Amsterdam');
// Foutrapportage: in live verbergen we fouten voor bezoekers
if (APP_ENV !== 'live') {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
}
+107 -33
View File
@@ -8,8 +8,35 @@ require_once __DIR__ . '/config.php';
function get_db_connection(): PDO { function get_db_connection(): PDO {
static $pdo = null; static $pdo = null;
if ($pdo === null) { if ($pdo !== null) {
$dsn = sprintf( 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', 'mysql:host=%s;port=%s;dbname=%s;charset=%s',
DB_HOST, DB_HOST,
DB_PORT, DB_PORT,
@@ -17,52 +44,99 @@ function get_db_connection(): PDO {
DB_CHARSET DB_CHARSET
); );
$options = [ // 2. CHECK: Bestaat de database al?
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // We proberen eerst direct verbinding te maken met de database.
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // Als de database al bestaat, lukt dit direct zonder database-aanmaakrechten te vereisen!
PDO::ATTR_EMULATE_PREPARES => false,
];
try { try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options); $pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options);
// Zorg ervoor dat tabellen bestaan
// De database bestaat al! Controleer enkel of de tabellen al aanwezig zijn
ensure_tables_exist($pdo); ensure_tables_exist($pdo);
return $pdo;
} catch (PDOException $e) { } catch (PDOException $e) {
// Als de database zelf nog niet bestaat, probeer deze aan te maken indien gebruiker rechten heeft // Foutcode 1049 betekent: Database 'DB_NAME' bestaat nog NIET op de MariaDB server
if ($e->getCode() == 1049) { if ($e->getCode() == 1049 || str_contains($e->getMessage(), 'Unknown database')) {
// Probeer de database eenmalig aan te maken indien de gebruiker CREATE rechten heeft
try { try {
$tempDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET); $serverDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
$tempPdo = new PDO($tempDsn, DB_USER, DB_PASS, $options); $serverPdo = new PDO($serverDsn, DB_USER, DB_PASS, $options);
$tempPdo->exec(sprintf('CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', DB_NAME)); $serverPdo->exec(sprintf(
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options); '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); ensure_tables_exist($pdo);
return $pdo; return $pdo;
} catch (PDOException $ex) {
die("Database verbindingsfout: Kan database '" . htmlspecialchars(DB_NAME) . "' niet aanmaken of vinden. Maak deze handmatig aan in MariaDB."); } 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;'>
die("MariaDB Verbindingsfout: " . htmlspecialchars($e->getMessage()) . "<br><small>Controleer uw instellingen in config/config.php of .env</small>"); <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>
");
} }
} }
return $pdo; // 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 tabellen uit schema.sql aanwezig zijn in MariaDB * 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 { function ensure_tables_exist(PDO $pdo): void {
try { try {
// Controleer of de hoofdtabel 'users' al bestaat
$result = $pdo->query("SHOW TABLES LIKE 'users'"); $result = $pdo->query("SHOW TABLES LIKE 'users'");
if ($result->rowCount() === 0) { if ($result && $result->rowCount() > 0) {
$schemaFile = __DIR__ . '/../schema.sql'; // Tabellen bestaan al! Geen actie nodig.
if (file_exists($schemaFile)) { return;
$sql = file_get_contents($schemaFile);
$pdo->exec($sql);
}
}
} catch (PDOException $e) {
// Tabellen bestaan waarschijnlijk al of er is een permissiebeperking
}
} }
// 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());
}
}
View File
+4 -1
View File
@@ -13,7 +13,6 @@ function start_secure_session(): void {
session_set_cookie_params([ session_set_cookie_params([
'lifetime' => 7 * 24 * 60 * 60, // 7 dagen 'lifetime' => 7 * 24 * 60 * 60, // 7 dagen
'path' => '/', 'path' => '/',
'domain' => '',
'secure' => $isSecure, 'secure' => $isSecure,
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax' 'samesite' => 'Lax'
@@ -26,9 +25,13 @@ function start_secure_session(): void {
* Controleert of er al een beheerder bestaat in MariaDB * Controleert of er al een beheerder bestaat in MariaDB
*/ */
function has_admin_user(): bool { function has_admin_user(): bool {
try {
$db = get_db_connection(); $db = get_db_connection();
$stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'"); $stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'");
return ((int)$stmt->fetchColumn()) > 0; return ((int)$stmt->fetchColumn()) > 0;
} catch (Throwable $e) {
return false;
}
} }
/** /**
+8 -13
View File
@@ -9,23 +9,19 @@
* - OP DE DEV OMGEVING: $environment = 'development'; (Rood met 'Development' + radar ping) * - OP DE DEV OMGEVING: $environment = 'development'; (Rood met 'Development' + radar ping)
* - OP DE MAIN OMGEVING: $environment = 'live'; (Groen met 'Live' + radar ping) * - OP DE MAIN OMGEVING: $environment = 'live'; (Groen met 'Live' + radar ping)
* *
* U kunt de waarde hieronder aanpassen, of instellen via APP_ENV in .env. * U kunt dit hieronder aanpassen, of instellen via APP_ENV in .env / .env.example!
*/ */
// Bepaal de omgeving: 'development' of 'live' (of 'main') require_once __DIR__ . '/../config/config.php';
$environment = 'live'; // <-- ZET DIT OP DE MAIN SERVER OP 'live'
// Optionele fallback als het in .env gedefinieerd is // Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
if (getenv('APP_ENV')) { $environment = defined('APP_ENV') ? APP_ENV : 'live';
$envVal = strtolower(trim(getenv('APP_ENV')));
if (in_array($envVal, ['live', 'production', 'prod', 'main'])) { if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
$environment = 'live'; $environment = fociz_env('APP_ENV');
} elseif (in_array($envVal, ['dev', 'development', 'staging', 'test'])) {
$environment = 'development';
}
} }
$isLive = in_array(strtolower($environment), ['live', 'production', 'prod', 'main']); $isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
?> ?>
<!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN --> <!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN -->
@@ -66,4 +62,3 @@ $isLive = in_array(strtolower($environment), ['live', 'production', 'prod', 'mai
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* Fociz Pure PHP HTTP Client Helper
* Ondersteunt cURL met automatische fallback naar stream wrappers (file_get_contents).
*/
if (!function_exists('fociz_http_request')) {
/**
* Voert een HTTP request uit en retourneert de statuscode, body en geparsede JSON.
*
* @param string $url
* @param array $options [
* 'method' => 'GET'|'POST'|'PUT'|'DELETE',
* 'headers' => ['Header-Name: Value'] of ['Header-Name' => 'Value'],
* 'body' => string|array,
* 'timeout' => int (seconden, default 12),
* 'verify_ssl' => bool (default true)
* ]
* @return array ['status' => int, 'body' => string, 'json' => ?array, 'error' => ?string]
*/
function fociz_http_request(string $url, array $options = []): array {
$method = strtoupper($options['method'] ?? 'GET');
$timeout = (int)($options['timeout'] ?? 12);
$verifySsl = $options['verify_ssl'] ?? true;
// Normaliseer headers naar 'Name: Value' regels
$rawHeaders = $options['headers'] ?? [];
$headerLines = [];
foreach ($rawHeaders as $k => $v) {
if (is_numeric($k)) {
$headerLines[] = $v;
} else {
$headerLines[] = "{$k}: {$v}";
}
}
// Bereid payload voor
$body = $options['body'] ?? null;
if (is_array($body)) {
$hasJsonHeader = false;
foreach ($headerLines as $h) {
if (stripos($h, 'application/json') !== false) {
$hasJsonHeader = true;
break;
}
}
if ($hasJsonHeader) {
$body = json_encode($body);
} else {
$body = http_build_query($body);
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
}
}
// Voeg standaard User-Agent toe als die er niet is
$hasUserAgent = false;
foreach ($headerLines as $h) {
if (stripos($h, 'User-Agent:') !== false) {
$hasUserAgent = true;
break;
}
}
if (!$hasUserAgent) {
$headerLines[] = 'User-Agent: Fociz-Integrations/1.0';
}
// 1. Probeer cURL indien beschikbaar
if (extension_loaded('curl')) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifySsl ? 2 : 0);
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$curlError = curl_error($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($responseBody === false && !empty($curlError)) {
return [
'status' => $statusCode ?: 0,
'body' => '',
'json' => null,
'error' => "cURL fout: {$curlError}"
];
}
$parsedJson = json_decode((string)$responseBody, true);
return [
'status' => $statusCode,
'body' => (string)$responseBody,
'json' => is_array($parsedJson) ? $parsedJson : null,
'error' => null
];
}
// 2. Fallback: PHP Streams (file_get_contents)
$contextOptions = [
'http' => [
'method' => $method,
'header' => implode("\r\n", $headerLines),
'timeout' => $timeout,
'ignore_errors' => true // Zorgt dat we ook bij 4xx / 5xx responses de body en statuscode kunnen lezen
],
'ssl' => [
'verify_peer' => $verifySsl,
'verify_peer_name' => $verifySsl
]
];
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$contextOptions['http']['content'] = $body;
}
$context = stream_context_create($contextOptions);
$responseBody = @file_get_contents($url, false, $context);
$statusCode = 0;
if (!empty($http_response_header)) {
// Zoek de laatste HTTP status regel (bij eventuele redirects)
foreach (array_reverse($http_response_header) as $hdr) {
if (preg_match('#HTTP/\d\.\d\s+(\d{3})#i', $hdr, $matches)) {
$statusCode = (int)$matches[1];
break;
}
}
}
if ($responseBody === false) {
$lastErr = error_get_last();
return [
'status' => $statusCode ?: 0,
'body' => '',
'json' => null,
'error' => 'Stream fout: ' . ($lastErr['message'] ?? 'Kan verbinding niet voltooien')
];
}
$parsedJson = json_decode((string)$responseBody, true);
return [
'status' => $statusCode,
'body' => (string)$responseBody,
'json' => is_array($parsedJson) ? $parsedJson : null,
'error' => null
];
}
}
+311
View File
@@ -0,0 +1,311 @@
<?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, '/');
}
/**
* 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 = $settings['jira_domain'] ?? fociz_env('JIRA_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]);
}
+260
View File
@@ -0,0 +1,260 @@
<?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 openid profile email User.Read Calendars.Read';
/**
* Genereert de Microsoft OAuth2 login URL
*/
function microsoft_get_auth_url(string $clientId, string $redirectUri, string $tenantId = 'common', string $state = ''): string {
$tenant = !empty($tenantId) ? trim($tenantId) : 'common';
$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 = 'common'): array {
$tenant = !empty($tenantId) ? trim($tenantId) : 'common';
$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): ?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, direct retourneren
if ($expiresAt > (time() + 240)) {
return $integration['access_token'];
}
// Token is verlopen of verloopt bijna; refresh met refresh_token
$refreshToken = $integration['refresh_token'] ?? '';
$clientId = $settings['client_id'] ?? fociz_env('MICROSOFT_CLIENT_ID', '');
$clientSecret = $settings['client_secret'] ?? fociz_env('MICROSOFT_CLIENT_SECRET', '');
$tenantId = $settings['tenant_id'] ?? fociz_env('MICROSOFT_TENANT_ID', 'common');
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
return null;
}
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode(!empty($tenantId) ? $tenantId : 'common'));
$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'])));
return null;
}
/**
* 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');
$token = microsoft_get_valid_token($userId);
if (!$token) {
return [];
}
// Venster van 00:00:00 tot 23:59:59 lokale tijd
$startIso = "{$targetDate}T00:00:00";
$endIso = "{$targetDate}T23:59:59";
$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' => '50',
]);
$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'])) {
return [];
}
$rawEvents = $response['json']['value'];
$events = [];
foreach ($rawEvents as $e) {
// Annuleringen overslaan
if (!empty($e['isCancelled'])) {
continue;
}
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
// Formatteer naar H:i (bijv. 09:30)
$startTime = '';
$endTime = '';
if (!empty($rawStart)) {
$startTime = date('H:i', strtotime($rawStart));
}
if (!empty($rawEnd)) {
$endTime = date('H:i', strtotime($rawEnd));
}
// Teams of meeting URL detecteren
$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)',
'start' => $startTime ?: '09:00',
'end' => $endTime ?: '10:00',
'start_raw' => $rawStart,
'end_raw' => $rawEnd,
'location' => $locationName ?: 'Geen locatie',
'meeting_url' => $meetingUrl,
'is_all_day' => !empty($e['isAllDay']),
'source' => 'OUTLOOK_LIVE',
];
}
return $events;
}
/**
* Ontkoppelt Microsoft integratie voor een gebruiker
*/
function microsoft_disconnect(int $userId): bool {
$db = get_db_connection();
$stmt = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
return $stmt->execute([$userId]);
}
+1 -1
View File
@@ -76,7 +76,7 @@ class TOTP {
/** /**
* Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter) * 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): boolean { public static function verifyCode(string $secret, string $userInputCode, int $window = 1): bool {
$userInputCode = trim(str_replace(' ', '', $userInputCode)); $userInputCode = trim(str_replace(' ', '', $userInputCode));
if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) { if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) {
return false; return false;
+522 -125
View File
@@ -1,34 +1,151 @@
<?php <?php
/** /**
* Fociz Integraties Hub (Outlook, Teams, Jira) * Fociz Integraties Hub (Microsoft Outlook, Microsoft Teams, Atlassian Jira)
*/ */
require_once __DIR__ . '/includes/header.php'; require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/includes/integrations/microsoft.php';
require_once __DIR__ . '/includes/integrations/jira.php';
$db = get_db_connection(); $db = get_db_connection();
$userId = $currentUser['id']; $userId = $currentUser['id'];
// Formulierverwerking voor integratie instellingen
$message = null; $message = null;
$messageType = 'success';
// Query parameters (bijv. na OAuth redirect)
if (!empty($_GET['success'])) {
$message = $_GET['success'];
$messageType = 'success';
} elseif (!empty($_GET['error'])) {
$message = $_GET['error'];
$messageType = 'error';
}
// POST Verwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$provider = $_POST['provider'] ?? ''; $provider = strtoupper(trim($_POST['provider'] ?? ''));
$action = $_POST['action'] ?? 'save';
// 1. Ontkoppelen
if ($action === 'disconnect') {
if ($provider === 'OUTLOOK') {
microsoft_disconnect($userId);
$message = "Microsoft Outlook is succesvol ontkoppeld.";
} elseif ($provider === 'JIRA') {
jira_disconnect($userId);
$message = "Atlassian Jira is succesvol ontkoppeld.";
}
}
// 2. Outlook Opslaan
elseif ($provider === 'OUTLOOK') {
$clientId = trim($_POST['client_id'] ?? '');
$clientSecret = trim($_POST['client_secret'] ?? '');
$tenantId = trim($_POST['tenant_id'] ?? 'common');
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0; $isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
$settings = json_encode([
'client_id' => $_POST['client_id'] ?? '', // Haal bestaande tokens op
'client_secret' => $_POST['client_secret'] ?? '', $stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
'tenant_id' => $_POST['tenant_id'] ?? '', $stmtExisting->execute([$userId]);
'jira_domain' => $_POST['jira_domain'] ?? '', $existing = $stmtExisting->fetch();
'jira_email' => $_POST['jira_email'] ?? '', $existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: [];
'jira_api_token' => $_POST['jira_api_token'] ?? '',
// Als geheim veld leeg is gelaten, behoud het bestaande geheim
if (empty($clientSecret) && !empty($existingSettings['client_secret'])) {
$clientSecret = $existingSettings['client_secret'];
}
$newSettings = array_merge($existingSettings, [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'tenant_id' => $tenantId,
]); ]);
if (in_array($provider, ['OUTLOOK', 'TEAMS', 'JIRA'])) {
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at) INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, ?, 1, ?, ?, NOW()) VALUES (?, 'OUTLOOK', 1, ?, ?, NOW())
ON DUPLICATE KEY UPDATE is_demo_mode = VALUES(is_demo_mode), settings = VALUES(settings), last_sync_at = NOW() ON DUPLICATE KEY UPDATE
is_demo_mode = VALUES(is_demo_mode),
settings = VALUES(settings),
last_sync_at = NOW()
"); ");
$stmt->execute([$userId, $provider, $isDemo, $settings]); $stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
$message = "Instellingen voor {$provider} succesvol opgeslagen."; $message = "Instellingen voor Microsoft Outlook zijn opgeslagen.";
// Als gebruiker direct op 'Koppel nu' klikte
if ($action === 'connect_oauth') {
header("Location: /auth/oauth_microsoft.php?client_id=" . urlencode($clientId) . "&tenant_id=" . urlencode($tenantId));
exit;
}
}
// 3. Teams Opslaan
elseif ($provider === 'TEAMS') {
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, last_sync_at)
VALUES (?, 'TEAMS', 1, ?, NOW())
ON DUPLICATE KEY UPDATE is_demo_mode = VALUES(is_demo_mode), last_sync_at = NOW()
");
$stmt->execute([$userId, $isDemo]);
$message = "Instellingen voor Microsoft Teams zijn bijgewerkt.";
}
// 4. Jira Opslaan & Testen
elseif ($provider === 'JIRA') {
$jiraDomain = jira_normalize_domain($_POST['jira_domain'] ?? '');
$jiraEmail = trim($_POST['jira_email'] ?? '');
$jiraToken = trim($_POST['jira_api_token'] ?? '');
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
// 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 (empty($jiraToken) && !empty($existingSettings['jira_api_token'])) {
$jiraToken = $existingSettings['jira_api_token'];
}
// Als niet in demo mode, test verbinding
$accountName = $existingSettings['account_name'] ?? '';
$accountId = $existingSettings['account_id'] ?? '';
if (!$isDemo && !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}";
}
} else {
$message = "Jira instellingen opgeslagen.";
}
$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, ?, ?, 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)]);
// Wis cache
unset($_SESSION["jira_issues_user_{$userId}"]);
} }
} }
@@ -42,263 +159,543 @@ foreach ($configsRaw as $c) {
$configs[$c['provider']]['settings_parsed'] = json_decode($c['settings'] ?? '{}', true) ?: []; $configs[$c['provider']]['settings_parsed'] = json_decode($c['settings'] ?? '{}', true) ?: [];
} }
// Jira demo issues // Bepaal statussen
$jiraIssues = [ $isOutlookConnected = !empty($configs['OUTLOOK']['access_token']) && empty($configs['OUTLOOK']['is_demo_mode']);
[ $outlookSettings = $configs['OUTLOOK']['settings_parsed'] ?? [];
'key' => 'FOC-101',
'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren', $isJiraConnected = !empty($configs['JIRA']) && empty($configs['JIRA']['is_demo_mode']) && !empty($configs['JIRA']['settings_parsed']['jira_api_token']);
'status' => 'In Progress', $jiraSettings = $configs['JIRA']['settings_parsed'] ?? [];
'priority' => 'High',
'epic' => 'Security & Access', // Haal actuele Jira issues op
], $forceRefresh = isset($_GET['refresh_jira']);
[ $jiraData = jira_get_user_issues($userId, $forceRefresh);
'key' => 'FOC-102', $jiraIssues = $jiraData['issues'] ?? [];
'summary' => 'Interactief whiteboard canvas voor brainstormsessies', $jiraIsDemo = !empty($jiraData['is_demo']);
'status' => 'To Do',
'priority' => 'High', // Callback redirect URI
'epic' => 'Brainstorming Module', $redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
],
[
'key' => 'FOC-103',
'summary' => 'Koppeling met Microsoft Outlook Calendar & Teams Graph API',
'status' => 'In Review',
'priority' => 'Medium',
'epic' => 'Integrations Hub',
],
[
'key' => 'FOC-104',
'summary' => 'Roadmap kwartaalweergave en voortgangsindicatoren',
'status' => 'To Do',
'priority' => 'Medium',
'epic' => 'Roadmap Module',
]
];
?> ?>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl"> <div class="bg-gradient-to-r from-slate-900 via-[#0d1527] to-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div class="flex items-center space-x-2 text-blue-400 text-xs font-semibold uppercase tracking-wider mb-1"> <div class="flex items-center space-x-2 text-blue-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="layers" class="w-4 h-4"></i> <i data-lucide="layers" class="w-4 h-4"></i>
<span>Connectiviteit & Synchronisatie</span> <span>Live Koppelingen & Data Synchronisatie</span>
</div> </div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight"> <h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Integraties Hub Integraties Hub
</h1> </h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1"> <p class="text-slate-400 text-xs sm:text-sm mt-1 max-w-2xl">
Beheer uw koppelingen met Microsoft Outlook (agenda & taken), Microsoft Teams (meetings) en Atlassian Jira (issues & roadmap). Koppel Microsoft 365 Outlook (voor realtime agenda & Teams meetings) en Atlassian Jira Cloud (om direct uw toegewezen tickets in te plannen).
</p> </p>
</div> </div>
<!-- Snelle status pills -->
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold <?= $isOutlookConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-slate-800/80 border-slate-700 text-slate-400' ?>">
<span class="w-2 h-2 rounded-full <?= $isOutlookConnected ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500' ?>"></span>
<span>Outlook: <?= $isOutlookConnected ? 'Verbonden' : 'Niet verbonden' ?></span>
</div>
<div class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl border text-xs font-semibold <?= $isJiraConnected ? 'bg-sky-500/10 border-sky-500/30 text-sky-400' : 'bg-slate-800/80 border-slate-700 text-slate-400' ?>">
<span class="w-2 h-2 rounded-full <?= $isJiraConnected ? 'bg-sky-400 animate-pulse' : 'bg-slate-500' ?>"></span>
<span>Jira: <?= $isJiraConnected ? 'Verbonden' : 'Niet verbonden' ?></span>
</div>
</div>
</div>
</div>
<!-- Flash message -->
<?php if ($message): ?> <?php if ($message): ?>
<div class="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-xs font-semibold flex items-center space-x-2"> <div class="p-4 rounded-xl text-xs font-semibold flex items-center justify-between border <?= $messageType === 'error' ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' ?>">
<i data-lucide="check-circle" class="w-4 h-4"></i> <div class="flex items-center space-x-2">
<i data-lucide="<?= $messageType === 'error' ? 'alert-triangle' : 'check-circle' ?>" class="w-4 h-4"></i>
<span><?= htmlspecialchars($message) ?></span> <span><?= htmlspecialchars($message) ?></span>
</div> </div>
<button onclick="this.parentElement.remove()" class="text-slate-500 hover:text-slate-300">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<?php endif; ?> <?php endif; ?>
<!-- Integratie Cards Grid --> <!-- 3 Koloms Grid voor Providers -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 1. OUTLOOK --> <!-- 1. MICROSOFT OUTLOOK -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between"> <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
<div> <div class="space-y-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-400 flex items-center justify-center font-bold"> <div class="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-400 flex items-center justify-center font-bold">
<i data-lucide="calendar" class="w-5 h-5"></i> <i data-lucide="calendar" class="w-5 h-5"></i>
</div> </div>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium flex items-center space-x-1"> <?php if ($isOutlookConnected): ?>
<i data-lucide="check-circle" class="w-3 h-3"></i> <span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center space-x-1">
<span>Actief</span> <span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Live Verbonden</span>
</span>
<?php elseif (!empty($configs['OUTLOOK']['is_demo_mode'])): ?>
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-semibold">
Sandbox Modus
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-xs font-semibold">
Niet Gekoppeld
</span>
<?php endif; ?>
</div>
<div>
<h3 class="text-base font-bold text-white mb-1">Microsoft Outlook (365)</h3>
<p class="text-xs text-slate-400 leading-relaxed">
Synchroniseert automatisch uw zakelijke agenda-afspraken en meetings naar de dagplanner tijdlijn.
</p>
</div>
<?php if ($isOutlookConnected): ?>
<div class="bg-slate-950/70 rounded-xl p-3.5 border border-emerald-500/20 text-xs space-y-2">
<div class="flex items-center justify-between text-slate-400">
<span>Account:</span>
<span class="text-emerald-400 font-semibold truncate max-w-[180px]" title="<?= htmlspecialchars($outlookSettings['account_email'] ?? '') ?>">
<?= htmlspecialchars($outlookSettings['account_email'] ?? 'Verbonden') ?>
</span> </span>
</div> </div>
<h3 class="text-base font-bold text-white mb-1">Microsoft Outlook</h3>
<p class="text-xs text-slate-400 mb-4">
Synchroniseert uw agenda-afspraken en e-mails automatisch naar uw dagplanning en time-blocks.
</p>
<div class="bg-slate-950/60 rounded-xl p-3 border border-slate-800/80 text-xs space-y-2 mb-4">
<div class="flex items-center justify-between text-slate-400"> <div class="flex items-center justify-between text-slate-400">
<span>Status:</span> <span>Naam:</span>
<span class="text-emerald-400 font-semibold">Gekoppeld</span> <span class="text-slate-200"><?= htmlspecialchars($outlookSettings['account_name'] ?? '-') ?></span>
</div> </div>
<div class="flex items-center justify-between text-slate-400"> <div class="flex items-center justify-between text-slate-400">
<span>Sync frequentie:</span> <span>Laatste Sync:</span>
<span class="text-slate-200">Elke 15 min</span> <span class="text-slate-300 font-mono text-[11px]"><?= htmlspecialchars($configs['OUTLOOK']['last_sync_at'] ?? 'Zojuist') ?></span>
</div> </div>
</div> </div>
<?php endif; ?>
<!-- Azure App Instructies Accordion -->
<details class="group text-xs bg-slate-950/50 rounded-xl border border-slate-800/80 p-3">
<summary class="cursor-pointer font-semibold text-blue-400 flex items-center justify-between">
<span class="flex items-center space-x-1.5">
<i data-lucide="help-circle" class="w-3.5 h-3.5"></i>
<span>Azure App Registratie Instructies</span>
</span>
<i data-lucide="chevron-down" class="w-3.5 h-3.5 group-open:rotate-180 transition"></i>
</summary>
<div class="mt-3 space-y-2 text-slate-400 text-[11px] leading-relaxed border-t border-slate-800/60 pt-2">
<p>1. Open <a href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade" target="_blank" class="text-blue-400 underline">Azure App Registraties</a> en maak een nieuwe registratie.</p>
<p>2. Kies <em>Accounts in elke organisatiemap (...) en persoonlijke Microsoft-accounts</em>.</p>
<p>3. Voeg een <strong>Web</strong> Omleidings-URI toe:</p>
<div class="flex items-center space-x-1 bg-slate-900 p-1.5 rounded border border-slate-800">
<input type="text" readonly id="msRedirectUriInput" value="<?= htmlspecialchars($redirectUri) ?>" class="bg-transparent text-[10px] text-emerald-400 font-mono flex-1 focus:outline-none">
<button onclick="copyToClipboard('msRedirectUriInput')" class="text-slate-400 hover:text-white px-1.5 py-0.5 rounded bg-slate-800 text-[10px]">Kopieer</button>
</div>
<p>4. Maak een geheim aan onder <em>Certificaten en geheimen</em>.</p>
<p>5. Voeg API-machtigingen toe: <code>Calendars.Read</code>, <code>User.Read</code>, <code>offline_access</code>.</p>
</div>
</details>
</div> </div>
<!-- Instellingen formulier --> <!-- Instellingen formulier -->
<form method="POST" action="/integrations.php" class="pt-3 border-t border-slate-800 space-y-3"> <form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-2.5 mt-4">
<input type="hidden" name="provider" value="OUTLOOK"> <input type="hidden" name="provider" value="OUTLOOK">
<label class="flex items-center space-x-2 text-xs text-slate-300"> <div>
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['OUTLOOK']) || !empty($configs['OUTLOOK']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-emerald-500"> <label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Application (Client) ID</label>
<input
type="text"
name="client_id"
value="<?= htmlspecialchars($outlookSettings['client_id'] ?? fociz_env('MICROSOFT_CLIENT_ID', '')) ?>"
placeholder="e.g. 11111111-2222-3333-4444-555555555555"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Client Secret</label>
<div class="relative">
<input
type="password"
id="msClientSecret"
name="client_secret"
value="<?= !empty($outlookSettings['client_secret']) ? '••••••••••••••••' : htmlspecialchars(fociz_env('MICROSOFT_CLIENT_SECRET', '')) ?>"
placeholder="Client Secret Value"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 pr-8 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
>
<button type="button" onclick="togglePasswordVisibility('msClientSecret')" class="absolute right-2 top-2 text-slate-500 hover:text-slate-300">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Directory (Tenant) ID</label>
<input
type="text"
name="tenant_id"
value="<?= htmlspecialchars($outlookSettings['tenant_id'] ?? fociz_env('MICROSOFT_TENANT_ID', 'common')) ?>"
placeholder="common of specifiek GUID"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 font-mono"
>
</div>
<label class="flex items-center space-x-2 text-xs text-slate-300 pt-1">
<input type="checkbox" name="is_demo_mode" value="1" <?= !empty($configs['OUTLOOK']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-blue-500">
<span>Gebruik interactieve Sandbox data</span> <span>Gebruik interactieve Sandbox data</span>
</label> </label>
<button type="submit" class="w-full py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold transition"> <div class="flex space-x-2 pt-2">
Instellingen Bijwerken <button type="submit" name="action" value="save" class="flex-1 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition">
Opslaan
</button> </button>
<button type="submit" name="action" value="connect_oauth" class="flex-1 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition flex items-center justify-center space-x-1 shadow-md shadow-blue-500/20">
<i data-lucide="key" class="w-3.5 h-3.5"></i>
<span><?= $isOutlookConnected ? 'Opnieuw Koppelen' : 'Koppel Outlook' ?></span>
</button>
</div>
<?php if ($isOutlookConnected): ?>
<button type="submit" name="action" value="disconnect" onclick="return confirm('Weet u zeker dat u Microsoft Outlook wilt ontkoppelen?')" class="w-full py-1.5 text-[11px] text-red-400 hover:text-red-300 transition text-center block">
Koppeling Verwijderen
</button>
<?php endif; ?>
</form> </form>
</div> </div>
<!-- 2. MICROSOFT TEAMS --> <!-- 2. MICROSOFT TEAMS -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between"> <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
<div> <div class="space-y-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/30 text-indigo-400 flex items-center justify-center font-bold"> <div class="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/30 text-indigo-400 flex items-center justify-center font-bold">
<i data-lucide="video" class="w-5 h-5"></i> <i data-lucide="video" class="w-5 h-5"></i>
</div> </div>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium flex items-center space-x-1"> <span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium flex items-center space-x-1">
<i data-lucide="check-circle" class="w-3 h-3"></i> <i data-lucide="check-circle" class="w-3 h-3"></i>
<span>Actief</span> <span>Inbegrepen bij Outlook</span>
</span> </span>
</div> </div>
<div>
<h3 class="text-base font-bold text-white mb-1">Microsoft Teams</h3> <h3 class="text-base font-bold text-white mb-1">Microsoft Teams</h3>
<p class="text-xs text-slate-400 mb-4"> <p class="text-xs text-slate-400 leading-relaxed">
Toont vandaag geplande Teams meetings met directe 'Deelnemen' links en kanaalupdates. Detecteert Teams vergaderingen automatisch vanuit uw Outlook agenda en toont directe 'Deelnemen' knoppen.
</p> </p>
</div>
<div class="bg-slate-950/60 rounded-xl p-3 border border-slate-800/80 text-xs space-y-2 mb-4"> <div class="bg-slate-950/60 rounded-xl p-3.5 border border-slate-800/80 text-xs space-y-2">
<div class="flex items-center justify-between text-slate-400"> <div class="flex items-center justify-between text-slate-400">
<span>Direct Deelnemen:</span> <span>Direct Deelnemen:</span>
<span class="text-emerald-400 font-semibold">Ondersteund</span> <span class="text-emerald-400 font-semibold">Ondersteund</span>
</div> </div>
<div class="flex items-center justify-between text-slate-400"> <div class="flex items-center justify-between text-slate-400">
<span>Meldingen:</span> <span>Meeting detectie:</span>
<span class="text-slate-200">Ingeschakeld</span> <span class="text-slate-200">Via Graph API</span>
</div> </div>
<div class="flex items-center justify-between text-slate-400">
<span>Status:</span>
<span class="text-indigo-400 font-semibold"><?= $isOutlookConnected ? 'Actief' : 'Koppel Outlook' ?></span>
</div> </div>
</div> </div>
<form method="POST" action="/integrations.php" class="pt-3 border-t border-slate-800 space-y-3"> <div class="p-3 bg-indigo-950/30 border border-indigo-900/50 rounded-xl text-xs text-indigo-200/80">
<p class="leading-relaxed">
Teams vergaderingen worden automatisch uitgelezen zodra Microsoft Outlook is gekoppeld. U hoeft voor Teams geen aparte koppeling te configureren!
</p>
</div>
</div>
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-3 mt-4">
<input type="hidden" name="provider" value="TEAMS"> <input type="hidden" name="provider" value="TEAMS">
<input type="hidden" name="action" value="save">
<label class="flex items-center space-x-2 text-xs text-slate-300"> <label class="flex items-center space-x-2 text-xs text-slate-300">
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['TEAMS']) || !empty($configs['TEAMS']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-emerald-500"> <input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['TEAMS']) || !empty($configs['TEAMS']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-indigo-500">
<span>Gebruik interactieve Sandbox data</span> <span>Gebruik interactieve Sandbox data</span>
</label> </label>
<button type="submit" class="w-full py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold transition"> <button type="submit" class="w-full py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold transition">
Instellingen Bijwerken Teams Instellingen Opslaan
</button> </button>
</form> </form>
</div> </div>
<!-- 3. ATLASSIAN JIRA --> <!-- 3. ATLASSIAN JIRA -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between"> <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between relative overflow-hidden backdrop-blur-sm">
<div> <div class="space-y-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/30 text-sky-400 flex items-center justify-center font-bold"> <div class="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/30 text-sky-400 flex items-center justify-center font-bold">
<i data-lucide="trello" class="w-5 h-5"></i> <i data-lucide="trello" class="w-5 h-5"></i>
</div> </div>
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium flex items-center space-x-1"> <?php if ($isJiraConnected): ?>
<i data-lucide="check-circle" class="w-3 h-3"></i> <span class="px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center space-x-1">
<span>Actief</span> <span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Live Verbonden</span>
</span>
<?php elseif (!empty($configs['JIRA']['is_demo_mode'])): ?>
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-semibold">
Sandbox Modus
</span>
<?php else: ?>
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-xs font-semibold">
Niet Gekoppeld
</span>
<?php endif; ?>
</div>
<div>
<h3 class="text-base font-bold text-white mb-1">Atlassian Jira Cloud</h3>
<p class="text-xs text-slate-400 leading-relaxed">
Koppelt uw Jira tickets rechtstreeks aan de Fociz dagplanning en roadmap.
</p>
</div>
<?php if ($isJiraConnected): ?>
<div class="bg-slate-950/70 rounded-xl p-3.5 border border-sky-500/20 text-xs space-y-2">
<div class="flex items-center justify-between text-slate-400">
<span>Ingelogd als:</span>
<span class="text-sky-400 font-semibold truncate max-w-[170px]" title="<?= htmlspecialchars($jiraSettings['jira_email'] ?? '') ?>">
<?= htmlspecialchars($jiraSettings['account_name'] ?: ($jiraSettings['jira_email'] ?? '')) ?>
</span> </span>
</div> </div>
<h3 class="text-base font-bold text-white mb-1">Atlassian Jira</h3>
<p class="text-xs text-slate-400 mb-4">
Koppelt Jira Epics en Issues direct aan uw roadmap items en takenlijst.
</p>
<div class="bg-slate-950/60 rounded-xl p-3 border border-slate-800/80 text-xs space-y-2 mb-4">
<div class="flex items-center justify-between text-slate-400"> <div class="flex items-center justify-between text-slate-400">
<span>Two-way Status Sync:</span> <span>Domein:</span>
<span class="text-emerald-400 font-semibold">Ondersteund</span> <span class="text-slate-200 font-mono text-[11px] truncate max-w-[170px]"><?= htmlspecialchars($jiraSettings['jira_domain'] ?? '-') ?></span>
</div> </div>
<div class="flex items-center justify-between text-slate-400"> <div class="flex items-center justify-between text-slate-400">
<span>Roadmap Linkage:</span> <span>Tickets Toegewezen:</span>
<span class="text-slate-200">Actief</span> <span class="text-emerald-400 font-bold"><?= count($jiraIssues) ?> open taken</span>
</div>
</div>
<?php endif; ?>
<div class="bg-slate-950/50 rounded-xl border border-slate-800/80 p-3 text-xs text-slate-400">
<div class="flex items-center justify-between">
<span class="text-slate-300 font-semibold">Atlassian API Token nodig?</span>
<a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" class="text-sky-400 hover:text-sky-300 font-semibold flex items-center space-x-1">
<span>Genereer Token</span>
<i data-lucide="external-link" class="w-3 h-3"></i>
</a>
</div> </div>
</div> </div>
</div> </div>
<form method="POST" action="/integrations.php" class="pt-3 border-t border-slate-800 space-y-2"> <!-- Jira Formulier -->
<form method="POST" action="/integrations.php" class="pt-4 border-t border-slate-800 space-y-2.5 mt-4">
<input type="hidden" name="provider" value="JIRA"> <input type="hidden" name="provider" value="JIRA">
<div> <div>
<label class="block text-[10px] text-slate-400 mb-0.5">Jira Cloud Domein</label> <label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Jira Cloud Domein</label>
<input type="text" name="jira_domain" placeholder="uwbedrijf.atlassian.net" value="<?= htmlspecialchars($configs['JIRA']['settings_parsed']['jira_domain'] ?? '') ?>" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1 text-xs text-white placeholder-slate-600 focus:outline-none"> <input
type="text"
name="jira_domain"
value="<?= htmlspecialchars($jiraSettings['jira_domain'] ?? fociz_env('JIRA_DOMAIN', '')) ?>"
placeholder="uwbedrijf.atlassian.net"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500 font-mono"
>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Atlassian E-mailadres</label>
<input
type="email"
name="jira_email"
value="<?= htmlspecialchars($jiraSettings['jira_email'] ?? fociz_env('JIRA_EMAIL', '')) ?>"
placeholder="collega@uwbedrijf.nl"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500"
>
</div>
<div>
<label class="block text-[10px] font-semibold text-slate-400 mb-0.5">Atlassian API Token</label>
<div class="relative">
<input
type="password"
id="jiraApiTokenInput"
name="jira_api_token"
value="<?= !empty($jiraSettings['jira_api_token']) ? '••••••••••••••••' : htmlspecialchars(fociz_env('JIRA_API_TOKEN', '')) ?>"
placeholder="Plak hier uw Atlassian API token"
class="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 pr-8 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-sky-500 font-mono"
>
<button type="button" onclick="togglePasswordVisibility('jiraApiTokenInput')" class="absolute right-2 top-2 text-slate-500 hover:text-slate-300">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
</div>
</div> </div>
<label class="flex items-center space-x-2 text-xs text-slate-300 pt-1"> <label class="flex items-center space-x-2 text-xs text-slate-300 pt-1">
<input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['JIRA']) || !empty($configs['JIRA']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-emerald-500"> <input type="checkbox" name="is_demo_mode" value="1" <?= empty($configs['JIRA']) || !empty($configs['JIRA']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-sky-500">
<span>Gebruik interactieve Sandbox data</span> <span>Gebruik interactieve Sandbox data</span>
</label> </label>
<button type="submit" class="w-full py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold transition"> <div class="flex space-x-2 pt-2">
Jira Opslaan <button type="submit" name="action" value="save" class="w-full py-2 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-xs font-bold transition flex items-center justify-center space-x-1.5 shadow-md shadow-sky-500/20">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
<span>Verbinding Testen & Opslaan</span>
</button> </button>
</div>
<?php if ($isJiraConnected): ?>
<button type="submit" name="action" value="disconnect" onclick="return confirm('Weet u zeker dat u Atlassian Jira wilt ontkoppelen?')" class="w-full py-1.5 text-[11px] text-red-400 hover:text-red-300 transition text-center block">
Koppeling Verwijderen
</button>
<?php endif; ?>
</form> </form>
</div> </div>
</div> </div>
<!-- Gesynchroniseerde Jira Issues & 1-Click Import naar Dagplanning --> <!-- Gesynchroniseerde Jira Issues & 1-Click Import naar Dagplanning -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4"> <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div class="flex items-center justify-between pb-3 border-b border-slate-800"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-800">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<i data-lucide="check-square" class="w-5 h-5 text-sky-400"></i> <i data-lucide="check-square" class="w-5 h-5 text-sky-400"></i>
<h3 class="text-sm font-bold text-white uppercase tracking-wider"> <h3 class="text-sm font-bold text-white uppercase tracking-wider">
Jira Issues Toegewezen aan U Jira Issues Toegewezen aan U
</h3> </h3>
</div> <?php if ($jiraIsDemo): ?>
<span class="text-xs text-slate-400 font-mono"><?= count($jiraIssues) ?> issues</span> <span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">Sandbox</span>
<?php else: ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">Live Jira</span>
<?php endif; ?>
</div> </div>
<div class="flex items-center space-x-3">
<span class="text-xs text-slate-400 font-mono"><?= count($jiraIssues) ?> issues</span>
<a href="/integrations.php?refresh_jira=1" class="flex items-center space-x-1 text-xs text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 px-2.5 py-1 rounded-lg border border-slate-700 transition">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
<span>Verversen</span>
</a>
</div>
</div>
<?php if (empty($jiraIssues)): ?>
<div class="text-center py-10 text-slate-500 text-xs">
Geen openstaande tickets gevonden die aan u zijn toegewezen in Jira.
</div>
<?php else: ?>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<?php foreach ($jiraIssues as $issue): ?> <?php foreach ($jiraIssues as $issue):
<div class="bg-slate-950/60 border border-slate-800 hover:border-slate-700 rounded-xl p-4 flex flex-col justify-between transition"> $priority = $issue['priority'] ?? 'Medium';
$priorityClass = match(strtolower($priority)) {
'highest', 'high', 'hoog' => 'text-red-400 bg-red-500/10 border-red-500/20',
'medium', 'gemiddeld' => 'text-amber-400 bg-amber-500/10 border-amber-500/20',
default => 'text-slate-400 bg-slate-800/60 border-slate-700',
};
?>
<div class="bg-slate-950/70 border border-slate-800/80 hover:border-slate-700 rounded-xl p-4 flex flex-col justify-between transition group">
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="font-mono text-xs font-bold text-sky-400 bg-sky-500/10 border border-sky-500/20 px-2 py-0.5 rounded"> <span class="font-mono text-xs font-bold text-sky-400 bg-sky-500/10 border border-sky-500/20 px-2 py-0.5 rounded">
<?= htmlspecialchars($issue['key']) ?> <?= htmlspecialchars($issue['key']) ?>
</span> </span>
<span class="text-[10px] text-slate-400 font-medium truncate max-w-[140px]">
<?= htmlspecialchars($issue['project'] ?? '') ?>
</span>
</div>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300"> <span class="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300">
<?= htmlspecialchars($issue['status']) ?> <?= htmlspecialchars($issue['status']) ?>
</span> </span>
</div> </div>
<h4 class="text-xs font-bold text-white leading-snug">
<h4 class="text-xs font-bold text-white leading-snug group-hover:text-sky-300 transition">
<?= htmlspecialchars($issue['summary']) ?> <?= htmlspecialchars($issue['summary']) ?>
</h4> </h4>
<span class="text-[10px] text-slate-500 mt-1 block">Epic: <?= htmlspecialchars($issue['epic']) ?></span>
<div class="flex items-center space-x-3 mt-2 text-[10px] text-slate-500">
<span>Type: <?= htmlspecialchars($issue['issue_type'] ?? 'Issue') ?></span>
<?php if (!empty($issue['due_date'])): ?>
<span>Vervaldatum: <?= htmlspecialchars($issue['due_date']) ?></span>
<?php endif; ?>
</div>
</div> </div>
<div class="mt-4 pt-3 border-t border-slate-800/80 flex items-center justify-between"> <div class="mt-4 pt-3 border-t border-slate-800/80 flex items-center justify-between">
<span class="text-[10px] font-bold text-amber-400"><?= htmlspecialchars($issue['priority']) ?> Priority</span> <span class="text-[10px] font-bold px-2 py-0.5 rounded border <?= $priorityClass ?>">
<button onclick="importJiraToTasks('<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>')" class="flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 px-2.5 py-1 rounded-lg transition"> <?= htmlspecialchars($priority) ?>
</span>
<div class="flex items-center space-x-2">
<?php if (!empty($issue['url']) && $issue['url'] !== '#'): ?>
<a href="<?= htmlspecialchars($issue['url']) ?>" target="_blank" class="p-1 rounded-lg text-slate-500 hover:text-sky-400 transition" title="Open in Jira">
<i data-lucide="external-link" class="w-3.5 h-3.5"></i>
</a>
<?php endif; ?>
<button
onclick="importJiraToTasks(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>', 'P1')"
class="flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 px-2.5 py-1 rounded-lg transition"
>
<i data-lucide="plus" class="w-3 h-3"></i> <i data-lucide="plus" class="w-3 h-3"></i>
<span>Plan als Dagtaak</span> <span>Plan als P1</span>
</button> </button>
<button
onclick="importJiraToTasks(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>', 'P2')"
class="flex items-center space-x-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2.5 py-1 rounded-lg transition"
>
<span>Plan P2</span>
</button>
</div>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
</div> </div>
</div> </div>
<script> <script>
async function importJiraToTasks(title, jiraKey) { function copyToClipboard(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
navigator.clipboard.writeText(el.value).then(() => {
alert('Omleidings-URI gekopieerd naar klembord!');
}).catch(() => {
el.select();
document.execCommand('copy');
alert('Omleidings-URI gekopieerd!');
});
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
}
async function importJiraToTasks(btn, title, jiraKey, url, priority = 'P1') {
const originalHtml = btn.innerHTML;
btn.innerText = 'Toevoegen...';
btn.disabled = true;
try {
const res = await fetch('/api/planner.php?action=add_task', { const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
title: `[${jiraKey}] ${title}`, title: `[${jiraKey}] ${title}`,
priority: 'P1', priority: priority,
source: 'JIRA',
external_id: jiraKey,
external_url: url,
date: '<?= date('Y-m-d') ?>' date: '<?= date('Y-m-d') ?>'
}) })
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
alert(`Jira issue ${jiraKey} is toegevoegd aan uw dagplanning!`); btn.innerHTML = '<i data-lucide="check" class="w-3 h-3"></i><span>Gepland!</span>';
btn.className = 'flex items-center space-x-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg';
if (typeof lucide !== 'undefined') lucide.createIcons();
} else {
btn.innerHTML = originalHtml;
btn.disabled = false;
alert(data.error || 'Kon taak niet toevoegen');
}
} catch(err) {
btn.innerHTML = originalHtml;
btn.disabled = false;
alert('Netwerkfout bij toevoegen van taak');
} }
} }
</script> </script>
<?php require_once __DIR__ . '/includes/footer.php'; ?> <?php require_once __DIR__ . '/includes/footer.php'; ?>
+201 -22
View File
@@ -1,15 +1,18 @@
<?php <?php
/** /**
* Fociz Dagplanning, Focus Modus & Time-blocking * Fociz Dagplanning, Focus Modus & Time-blocking
* Gekoppeld aan Microsoft Outlook 365 (Agenda) en Atlassian Jira (Tickets)
*/ */
require_once __DIR__ . '/includes/header.php'; require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/includes/integrations/microsoft.php';
require_once __DIR__ . '/includes/integrations/jira.php';
$db = get_db_connection(); $db = get_db_connection();
$userId = $currentUser['id']; $userId = $currentUser['id'];
$today = date('Y-m-d'); $today = date('Y-m-d');
// Haal dagplanning op of maak aan // 1. Haal dagplanning op of maak aan
$stmtPlan = $db->prepare("SELECT * FROM daily_plans WHERE user_id = ? AND plan_date = ?"); $stmtPlan = $db->prepare("SELECT * FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$userId, $today]); $stmtPlan->execute([$userId, $today]);
$dailyPlan = $stmtPlan->fetch(); $dailyPlan = $stmtPlan->fetch();
@@ -24,7 +27,7 @@ if (!$dailyPlan) {
$dailyPlan = ['id' => $planId, 'plan_date' => $today, 'focus_goal' => 'Mijn belangrijkste doel van vandaag behalen', 'notes' => '']; $dailyPlan = ['id' => $planId, 'plan_date' => $today, 'focus_goal' => 'Mijn belangrijkste doel van vandaag behalen', 'notes' => ''];
} }
// Haal taken op voor vandaag // 2. Haal taken op voor vandaag
$stmtTasks = $db->prepare(" $stmtTasks = $db->prepare("
SELECT * FROM tasks SELECT * FROM tasks
WHERE user_id = ? AND (daily_plan_id = ? OR daily_plan_id IS NULL) WHERE user_id = ? AND (daily_plan_id = ? OR daily_plan_id IS NULL)
@@ -33,7 +36,25 @@ $stmtTasks = $db->prepare("
$stmtTasks->execute([$userId, $dailyPlan['id']]); $stmtTasks->execute([$userId, $dailyPlan['id']]);
$tasks = $stmtTasks->fetchAll(); $tasks = $stmtTasks->fetchAll();
// Outlook demo / gesynchroniseerde events // 3. Haal integratiestatussen op
$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ?");
$stmtInt->execute([$userId]);
$userIntegrations = [];
foreach ($stmtInt->fetchAll() as $row) {
$userIntegrations[$row['provider']] = $row;
}
$isOutlookConnected = !empty($userIntegrations['OUTLOOK']['access_token']) && empty($userIntegrations['OUTLOOK']['is_demo_mode']);
$outlookEvents = [];
$isLiveOutlook = false;
if ($isOutlookConnected) {
$outlookEvents = microsoft_fetch_today_events($userId, $today);
$isLiveOutlook = !empty($outlookEvents) || true; // Live koppeling is actief
}
// Fallback naar sandbox data indien Outlook niet live gekoppeld is
if (!$isOutlookConnected || empty($outlookEvents)) {
$outlookEvents = [ $outlookEvents = [
[ [
'id' => 'evt-1', 'id' => 'evt-1',
@@ -42,6 +63,7 @@ $outlookEvents = [
'end' => '09:30', 'end' => '09:30',
'location' => 'Microsoft Teams', 'location' => 'Microsoft Teams',
'meeting_url' => 'https://teams.microsoft.com', 'meeting_url' => 'https://teams.microsoft.com',
'source' => 'SANDBOX',
], ],
[ [
'id' => 'evt-2', 'id' => 'evt-2',
@@ -50,6 +72,7 @@ $outlookEvents = [
'end' => '12:00', 'end' => '12:00',
'location' => 'Vergaderruimte A & Teams', 'location' => 'Vergaderruimte A & Teams',
'meeting_url' => 'https://teams.microsoft.com', 'meeting_url' => 'https://teams.microsoft.com',
'source' => 'SANDBOX',
], ],
[ [
'id' => 'evt-3', 'id' => 'evt-3',
@@ -58,10 +81,30 @@ $outlookEvents = [
'end' => '15:30', 'end' => '15:30',
'location' => 'Online Teams Call', 'location' => 'Online Teams Call',
'meeting_url' => 'https://teams.microsoft.com', 'meeting_url' => 'https://teams.microsoft.com',
'source' => 'SANDBOX',
] ]
]; ];
}
$hours = ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00", "17:00"]; // 4. Haal Jira issues op voor de snelle import widget
$jiraData = jira_get_user_issues($userId);
$jiraIssues = $jiraData['issues'] ?? [];
$isJiraLive = !empty($jiraData['success']) && empty($jiraData['is_demo']);
// 5. Tijdlijn uren bepalen
$startHour = 8;
$endHour = 18;
foreach ($outlookEvents as $e) {
if (!empty($e['start'])) {
$h = (int)explode(':', $e['start'])[0];
if ($h < $startHour && $h >= 6) $startHour = $h;
if ($h > $endHour && $h <= 22) $endHour = $h + 1;
}
}
$hours = [];
for ($h = $startHour; $h <= $endHour; $h++) {
$hours[] = sprintf('%02d:00', $h);
}
// Nederlandse datumformattering // Nederlandse datumformattering
$dutchDays = ['Zondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrijdag', 'Zaterdag']; $dutchDays = ['Zondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrijdag', 'Zaterdag'];
@@ -70,7 +113,7 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
?> ?>
<div class="space-y-6"> <div class="space-y-6">
<!-- Top Banner: Datum & Hoofddoel --> <!-- Top Banner: Datum, Hoofddoel & Integraties Status -->
<div class="bg-gradient-to-r from-slate-900 via-[#0e1626] to-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl"> <div class="bg-gradient-to-r from-slate-900 via-[#0e1626] to-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4"> <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div> <div>
@@ -83,11 +126,34 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
</h1> </h1>
</div> </div>
<div class="flex items-center space-x-2"> <!-- Integratie Status Badges -->
<a href="/integrations.php" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-blue-950/60 border border-blue-800/60 text-blue-300 hover:text-white hover:bg-blue-900/60 text-xs font-medium transition"> <div class="flex flex-wrap items-center gap-2">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i> <?php if ($isOutlookConnected): ?>
<span>Outlook & Teams Verbonden</span> <div class="flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-blue-950/60 border border-blue-800/60 text-blue-300 text-xs font-medium">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Outlook 365 Live</span>
<button onclick="location.reload()" title="Agenda Verversen" class="p-1 text-blue-400 hover:text-white rounded transition">
<i data-lucide="refresh-cw" class="w-3 h-3"></i>
</button>
</div>
<?php else: ?>
<a href="/integrations.php" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-slate-800/80 border border-slate-700 text-slate-300 hover:text-white hover:bg-slate-700 text-xs font-medium transition">
<i data-lucide="link-2" class="w-3.5 h-3.5 text-blue-400"></i>
<span>Koppel Outlook</span>
</a> </a>
<?php endif; ?>
<?php if ($isJiraLive): ?>
<a href="#jiraPanel" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-sky-950/60 border border-sky-800/60 text-sky-300 hover:text-white text-xs font-medium transition">
<span class="w-2 h-2 rounded-full bg-sky-400 animate-pulse"></span>
<span>Jira: <?= count($jiraIssues) ?> tickets</span>
</a>
<?php else: ?>
<a href="/integrations.php" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-slate-800/80 border border-slate-700 text-slate-300 hover:text-white hover:bg-slate-700 text-xs font-medium transition">
<i data-lucide="trello" class="w-3.5 h-3.5 text-sky-400"></i>
<span>Koppel Jira</span>
</a>
<?php endif; ?>
</div> </div>
</div> </div>
@@ -117,7 +183,7 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
</div> </div>
</div> </div>
<!-- Main Grid: Timer + Takenlijst (Links) & Timeblocking (Rechts) --> <!-- Main Grid: Timer + Takenlijst + Jira (Links) & Timeblocking (Rechts) -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Linker kolom: Focus Timer & Taken --> <!-- Linker kolom: Focus Timer & Taken -->
@@ -212,7 +278,7 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<div class="space-y-2" id="tasksContainer"> <div class="space-y-2" id="tasksContainer">
<?php if (empty($tasks)): ?> <?php if (empty($tasks)): ?>
<div class="text-center py-8 text-slate-500 text-xs"> <div class="text-center py-8 text-slate-500 text-xs">
Nog geen taken gepland voor vandaag. Klik op 'Nieuwe Taak' om te beginnen. Nog geen taken gepland voor vandaag. Klik op 'Nieuwe Taak' of importeer een Jira ticket hieronder.
</div> </div>
<?php else: ?> <?php else: ?>
<?php foreach ($tasks as $task): ?> <?php foreach ($tasks as $task): ?>
@@ -225,15 +291,31 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
</button> </button>
<div class="truncate"> <div class="truncate">
<span class="text-xs font-medium block truncate <?= $task['is_completed'] ? 'line-through text-slate-500' : 'text-slate-200' ?>"> <div class="flex items-center space-x-1.5 truncate">
<?php if ($task['source'] === 'JIRA'): ?>
<span class="px-1.5 py-0.2 rounded bg-sky-500/10 border border-sky-500/20 text-sky-400 font-mono text-[9px] font-bold">
JIRA
</span>
<?php endif; ?>
<span class="text-xs font-medium truncate <?= $task['is_completed'] ? 'line-through text-slate-500' : 'text-slate-200' ?>">
<?= htmlspecialchars($task['title']) ?> <?= htmlspecialchars($task['title']) ?>
</span> </span>
</div>
<div class="flex items-center space-x-2 mt-0.5">
<?php if (!empty($task['time_block_start'])): ?> <?php if (!empty($task['time_block_start'])): ?>
<span class="text-[10px] text-slate-400 font-mono flex items-center space-x-1 mt-0.5"> <span class="text-[10px] text-slate-400 font-mono flex items-center space-x-1">
<i data-lucide="clock" class="w-2.5 h-2.5"></i> <i data-lucide="clock" class="w-2.5 h-2.5"></i>
<span><?= htmlspecialchars($task['time_block_start']) ?></span> <span><?= htmlspecialchars($task['time_block_start']) ?></span>
</span> </span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($task['external_url'])): ?>
<a href="<?= htmlspecialchars($task['external_url']) ?>" target="_blank" class="text-[10px] text-sky-400 hover:underline flex items-center space-x-0.5">
<span>Open in Jira</span>
<i data-lucide="external-link" class="w-2.5 h-2.5"></i>
</a>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
@@ -258,6 +340,61 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
</div> </div>
</div> </div>
<!-- Jira Snelle Import Widget -->
<div id="jiraPanel" class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl backdrop-blur-sm">
<div class="flex items-center justify-between pb-3 border-b border-slate-800 mb-3">
<div class="flex items-center space-x-2">
<i data-lucide="trello" class="w-4 h-4 text-sky-400"></i>
<h3 class="text-sm font-bold text-white uppercase tracking-wider">
Mijn Jira Tickets
</h3>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold <?= $isJiraLive ? 'bg-sky-500/20 text-sky-400 border border-sky-500/30' : 'bg-slate-800 text-slate-400' ?>">
<?= count($jiraIssues) ?>
</span>
</div>
<a href="/integrations.php" class="text-xs text-slate-400 hover:text-sky-300 transition flex items-center space-x-1">
<span>Beheren</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
</a>
</div>
<?php if (empty($jiraIssues)): ?>
<div class="text-center py-6 text-slate-500 text-xs">
Geen openstaande tickets gevonden in Jira.
</div>
<?php else: ?>
<div class="space-y-2 max-h-64 overflow-y-auto pr-1">
<?php foreach (array_slice($jiraIssues, 0, 6) as $issue): ?>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-950/60 border border-slate-800/80 hover:border-slate-700 transition">
<div class="truncate mr-2">
<div class="flex items-center space-x-2">
<span class="font-mono text-[10px] font-bold text-sky-400 bg-sky-500/10 px-1.5 py-0.5 rounded">
<?= htmlspecialchars($issue['key']) ?>
</span>
<span class="text-xs font-semibold text-white truncate">
<?= htmlspecialchars($issue['summary']) ?>
</span>
</div>
<span class="text-[10px] text-slate-500 mt-0.5 block">
<?= htmlspecialchars($issue['status']) ?> &bull; <?= htmlspecialchars($issue['priority']) ?>
</span>
</div>
<button
onclick="quickImportJira(this, '<?= htmlspecialchars(addslashes($issue['summary'])) ?>', '<?= htmlspecialchars($issue['key']) ?>', '<?= htmlspecialchars(addslashes($issue['url'] ?? '')) ?>')"
class="flex items-center space-x-1 text-[11px] font-bold text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 px-2 py-1 rounded-lg transition flex-shrink-0"
title="Plan in als dagtaak"
>
<i data-lucide="plus" class="w-3 h-3"></i>
<span>Plan</span>
</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Dagnotities & Reflectie --> <!-- Dagnotities & Reflectie -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl backdrop-blur-sm"> <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl backdrop-blur-sm">
<div class="flex items-center space-x-2 mb-3"> <div class="flex items-center space-x-2 mb-3">
@@ -282,19 +419,25 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<i data-lucide="clock" class="w-5 h-5 text-emerald-400"></i> <i data-lucide="clock" class="w-5 h-5 text-emerald-400"></i>
<h3 class="text-sm font-bold text-white uppercase tracking-wider">Tijdlijn & Time-blocking</h3> <h3 class="text-sm font-bold text-white uppercase tracking-wider">Tijdlijn & Time-blocking</h3>
</div> </div>
<span class="text-xs text-slate-400">08:00 – 18:00</span> <div class="flex items-center space-x-2">
<?php if ($isOutlookConnected): ?>
<span class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] font-bold">Outlook 365</span>
<?php endif; ?>
<span class="text-xs text-slate-400 font-mono"><?= $hours[0] ?> – <?= end($hours) ?></span>
</div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<?php foreach ($hours as $hour): <?php foreach ($hours as $hour):
$hourInt = (int)explode(':', $hour)[0]; $hourInt = (int)explode(':', $hour)[0];
// Zoek Outlook meetings // Zoek Outlook meetings die binnen dit uur vallen
$slotEvents = array_filter($outlookEvents, function($e) use ($hourInt) { $slotEvents = array_filter($outlookEvents, function($e) use ($hourInt) {
if (empty($e['start'])) return false;
return (int)explode(':', $e['start'])[0] === $hourInt; return (int)explode(':', $e['start'])[0] === $hourInt;
}); });
// Zoek taken in dit blok // Zoek taken gepland in dit uur
$slotTasks = array_filter($tasks, function($t) use ($hourInt) { $slotTasks = array_filter($tasks, function($t) use ($hourInt) {
return !empty($t['time_block_start']) && (int)explode(':', $t['time_block_start'])[0] === $hourInt; return !empty($t['time_block_start']) && (int)explode(':', $t['time_block_start'])[0] === $hourInt;
}); });
@@ -307,17 +450,21 @@ $formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(i
<div class="flex-1 space-y-1.5"> <div class="flex-1 space-y-1.5">
<!-- Outlook meetings --> <!-- Outlook meetings -->
<?php foreach ($slotEvents as $evt): ?> <?php foreach ($slotEvents as $evt): ?>
<div class="flex items-center justify-between p-2 rounded-xl bg-blue-950/40 border border-blue-800/50 text-blue-200 text-xs shadow-sm"> <div class="flex items-center justify-between p-2 rounded-xl bg-blue-950/50 border border-blue-800/60 text-blue-100 text-xs shadow-sm">
<div class="flex items-center space-x-2 truncate mr-2"> <div class="flex items-center space-x-2 truncate mr-2">
<span class="px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 font-mono text-[10px] font-bold"> <span class="px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 font-mono text-[10px] font-bold">
<?= $evt['start'] ?> - <?= $evt['end'] ?> <?= htmlspecialchars($evt['start']) ?> - <?= htmlspecialchars($evt['end']) ?>
</span>
<span class="font-semibold truncate" title="<?= htmlspecialchars($evt['subject']) ?>">
<?= htmlspecialchars($evt['subject']) ?>
</span> </span>
<span class="font-semibold truncate"><?= htmlspecialchars($evt['subject']) ?></span>
</div> </div>
<a href="<?= htmlspecialchars($evt['meeting_url']) ?>" target="_blank" class="flex items-center space-x-1 text-[10px] text-blue-400 hover:text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded-lg flex-shrink-0"> <?php if (!empty($evt['meeting_url'])): ?>
<a href="<?= htmlspecialchars($evt['meeting_url']) ?>" target="_blank" class="flex items-center space-x-1 text-[10px] text-blue-300 hover:text-white bg-blue-600/30 hover:bg-blue-600/50 px-2 py-0.5 rounded-lg flex-shrink-0 transition">
<i data-lucide="video" class="w-3 h-3"></i> <i data-lucide="video" class="w-3 h-3"></i>
<span>Deelnemen</span> <span>Deelnemen</span>
</a> </a>
<?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@@ -374,7 +521,6 @@ function setTimerPreset(minutes) {
totalDuration = minutes * 60; totalDuration = minutes * 60;
updateTimerDisplay(); updateTimerDisplay();
// Toggle styling
document.getElementById('timerToggleText').innerText = 'Start Focus'; document.getElementById('timerToggleText').innerText = 'Start Focus';
document.getElementById('btnPreset25').className = minutes === 25 ? 'px-2.5 py-1 rounded-lg font-bold bg-emerald-500 text-slate-950' : 'px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200'; document.getElementById('btnPreset25').className = minutes === 25 ? 'px-2.5 py-1 rounded-lg font-bold bg-emerald-500 text-slate-950' : 'px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200';
document.getElementById('btnPreset50').className = minutes === 50 ? 'px-2.5 py-1 rounded-lg font-bold bg-emerald-500 text-slate-950' : 'px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200'; document.getElementById('btnPreset50').className = minutes === 50 ? 'px-2.5 py-1 rounded-lg font-bold bg-emerald-500 text-slate-950' : 'px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200';
@@ -470,6 +616,40 @@ async function submitNewTask() {
} }
} }
async function quickImportJira(btn, title, jiraKey, url) {
const originalHtml = btn.innerHTML;
btn.innerText = '...';
btn.disabled = true;
try {
const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
title: `[${jiraKey}] ${title}`,
priority: 'P1',
source: 'JIRA',
external_id: jiraKey,
external_url: url,
date: '<?= $today ?>'
})
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<i data-lucide="check" class="w-3 h-3"></i>';
location.reload();
} else {
btn.innerHTML = originalHtml;
btn.disabled = false;
alert(data.error || 'Kon taak niet toevoegen');
}
} catch(err) {
btn.innerHTML = originalHtml;
btn.disabled = false;
alert('Netwerkfout bij toevoegen');
}
}
async function toggleTask(taskId, isCompleted) { async function toggleTask(taskId, isCompleted) {
await fetch('/api/planner.php?action=toggle_task', { await fetch('/api/planner.php?action=toggle_task', {
method: 'POST', method: 'POST',
@@ -491,4 +671,3 @@ async function deleteTask(taskId) {
</script> </script>
<?php require_once __DIR__ . '/includes/footer.php'; ?> <?php require_once __DIR__ . '/includes/footer.php'; ?>