Add roadmap management feature with database schema and user management

- Implemented roadmap.php for displaying and managing roadmap items with filters for quarter and status.
- Created schema.sql to define the database structure for users, invites, daily plans, tasks, brainstorm sessions, brainstorm cards, roadmap items, and integrations.
- Developed users.php for admin panel to manage team members and invitations, including functionality to create and revoke invites.
This commit is contained in:
2026-09-08 12:47:11 +02:00
commit 9da056b480
25 changed files with 3737 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# MariaDB Verbinding
DB_HOST="127.0.0.1"
DB_PORT="3306"
DB_NAME="fociz"
DB_USER="root"
DB_PASS="uw_wachtwoord"
# Applicatie URL (voor uitnodigingslinks)
APP_URL="https://uw-domein.nl"
# Applicatie Geheime Sleutel (willekeurige tekenreeks)
APP_SECRET="fociz-maria-db-super-secret-key-998877"
+10
View File
@@ -0,0 +1,10 @@
# Environment
.env
.env*.local
# Systeem & Editor
.DS_Store
Thumbs.db
*.log
.vscode/
.idea/
+16
View File
@@ -0,0 +1,16 @@
# 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
+86
View File
@@ -0,0 +1,86 @@
# Fociz — Focus, Dagplanning, Brainstorm & Roadmap Systeem
Fociz is een compleet, modern webplatform gebouwd in **PHP 8 & MariaDB** voor dagplanning, werkstructurering, interactieve brainstormsessies die met één klik omgezet kunnen worden in een roadmap, en koppelingen met Outlook, Teams en Jira.
---
## 🔒 Beveiliging & Toegangsbeheer
- **Strikt afgesloten**: Geen openbare registratie. Enkel toegankelijk na inloggen met tweetrapsauthenticatie (2FA).
- **First-run Admin Setup (`/auth/setup.php`)**:
- Bij het allereerste gebruik detecteert Fociz automatisch dat er nog geen beheerder bestaat en start de setup wizard.
- De beheerder voert naam, e-mail en wachtwoord in en scant de 2FA QR-code met bijv. **Google Authenticator**, **Microsoft Authenticator** of **1Password**.
- Na verificatie van de 6-cijferige code wordt de beheerder opgeslagen en wordt `/auth/setup.php` definitief vergrendeld.
- **Invite-only Registratie (`/auth/invite.php?token=...`)**:
- Nieuwe collega's kunnen enkel toetreden met een unieke uitnodigingslink aangemaakt door de beheerder (`/users.php`).
- Dwingt direct het instellen van een wachtwoord en 2FA-registratie af.
- **Inloggen (`/auth/login.php`)**:
- Stap 1: E-mailadres & wachtwoord (`password_hash` / `password_verify`).
- Stap 2: 6-cijferige TOTP verificatiecode (of herstelcode).
---
## 🚀 Kernmodules
1. **Dagplanning & Focus (`/planner.php`)**:
- **Dagdoel**: Leg uw nummer 1 focuspunt van de dag vast met één klik.
- **Focus Modus**: Ingebouwde Pomodoro/Stopwatch timer (25 min Focus, 50 min Deep Work, 5 min Pauze) met visuele voortgang en taakselectie.
- **Dagtaken & Prioriteiten**: Taken indelen op prioriteit (P1 Brandend, P2 Belangrijk, P3 Regulier), snel toevoegen en afvinken.
- **Tijdlijn & Time-blocking**: Uuroverzicht (08:00 - 18:00) met time-blocking voor taken en gesynchroniseerde Outlook meetings.
- **Dagnotities**: Snel kladblok voor reflecties en memo's.
2. **Brainstorm Canvas (`/brainstorm.php` & `/brainstorm_canvas.php`)**:
- Interactief whiteboard met sticky notes ingedeeld in 4 categorieën:
- 💡 **Ideeën & Concepten**
- ⚠️ **Knelpunten & Uitdagingen**
- 🚀 **Oplossingen & Features**
- ⚡ **Quick Wins**
- Stemmen (upvoting) op ideeën.
- **Zet om naar Roadmap**: Selecteer brainstormkaarten en zet ze direct om naar planbare roadmap-items met kwartaal, deadline en prioriteit.
3. **Roadmap & Tijdlijn (`/roadmap.php`)**:
- Kwartaaloverzicht (Q1, Q2, Q3, Q4) en statussen (Gepland, In Uitvoering, In Review, Voltooid).
- Voortgangsbalken (0 - 100%) en deadlines.
- Gekoppeld aan de oorspronkelijke brainstormkaarten en optioneel Jira Issue Keys (bijv. `FOC-101`).
4. **Integraties Hub (`/integrations.php`)**:
- **Microsoft Outlook**: Synchroniseert agenda-afspraken direct naar de dagplanning.
- **Microsoft Teams**: Directe deelnamelinks naar online meetings en kanaalnotificaties.
- **Atlassian Jira**: Issues synchroniseren en direct met één klik inplannen als dagtaak.
- **Sandbox / Demo Modus**: Schakel met één vinkje tussen live productie en interactieve testdata.
5. **Team & Invites Beheer (`/users.php` - Admin only)**:
- Genereer beveiligde uitnodigingslinks voor collega's met kopieerknop.
- Overzicht van actieve gebruikers, rollen en 2FA-status.
---
## 🛠️ Installatie op uw Server
### 1. Bestanden uploaden
Push of upload alle bestanden naar de gewenste webdirectory op uw server (bijv. `/var/www/fociz` of `public_html`).
### 2. MariaDB Database configureren
Pas de databasegegevens aan in `.env` (of direct in `config/config.php`):
```env
DB_HOST="127.0.0.1"
DB_PORT="3306"
DB_NAME="fociz"
DB_USER="uw_mariadb_gebruiker"
DB_PASS="uw_mariadb_wachtwoord"
APP_URL="https://uw-domein.nl"
```
### 3. Automatische Tabellen Aanmaak
U hoeft **niet** handmatig SQL-commando's uit te voeren: zodra u de website voor het eerst opent in uw browser, detecteert Fociz automatisch MariaDB en worden alle tabellen en relaties uit `schema.sql` vanzelf aangemaakt!
*(Optioneel kunt u `schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).*
### 4. Eerste Beheerder Instellen
1. Open uw browser en ga naar `https://uw-domein.nl`.
2. U wordt automatisch doorverwezen naar `/auth/setup.php`.
3. Vul uw naam, e-mailadres en gewenst wachtwoord in.
4. Scan de QR-code met Google Authenticator of Microsoft Authenticator.
5. Vul de 6-cijferige code in om te bevestigen.
6. **Klaar!** U kunt nu direct inloggen en collega's uitnodigen.
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* AJAX API voor Brainstorm Sessies, Kaarten & Omzetten naar Roadmap
*/
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Brainstormkaart toevoegen
if ($action === 'add_card') {
$data = json_decode(file_get_contents('php://input'), true);
$sessionId = (int)($data['session_id'] ?? 0);
$content = trim($data['content'] ?? '');
$category = $data['category'] ?? 'Ideeën';
$color = $data['color'] ?? 'amber';
if (empty($content)) {
echo json_encode(['error' => 'Inhoud is verplicht']);
exit;
}
$stmt = $db->prepare("
INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes)
VALUES (?, ?, ?, ?, ?, 0)
");
$stmt->execute([$sessionId, $content, $category, $color, $user['name']]);
$cardId = (int)$db->lastInsertId();
echo json_encode([
'success' => true,
'card' => [
'id' => $cardId,
'content' => $content,
'category' => $category,
'color' => $color,
'author_name' => $user['name'],
'votes' => 0,
'converted_to_roadmap_item_id' => null
]
]);
exit;
}
// 2. Stemmen / Upvote op kaart
if ($action === 'vote_card') {
$data = json_decode(file_get_contents('php://input'), true);
$cardId = (int)($data['card_id'] ?? 0);
$stmt = $db->prepare("UPDATE brainstorm_cards SET votes = votes + 1 WHERE id = ?");
$stmt->execute([$cardId]);
$stmtVotes = $db->prepare("SELECT votes FROM brainstorm_cards WHERE id = ?");
$stmtVotes->execute([$cardId]);
$votes = (int)$stmtVotes->fetchColumn();
echo json_encode(['success' => true, 'votes' => $votes]);
exit;
}
// 3. Kaart verwijderen
if ($action === 'delete_card') {
$data = json_decode(file_get_contents('php://input'), true);
$cardId = (int)($data['card_id'] ?? 0);
$stmt = $db->prepare("DELETE FROM brainstorm_cards WHERE id = ?");
$stmt->execute([$cardId]);
echo json_encode(['success' => true]);
exit;
}
// 4. Omzetten naar Roadmap!
if ($action === 'convert_to_roadmap') {
$data = json_decode(file_get_contents('php://input'), true);
$cardIds = $data['card_ids'] ?? [];
$quarter = $data['quarter'] ?? 'Q3';
$type = $data['type'] ?? 'FEATURE';
$priority = $data['priority'] ?? 'MEDIUM';
$dueDate = !empty($data['due_date']) ? $data['due_date'] : null;
if (empty($cardIds) || !is_array($cardIds)) {
echo json_encode(['error' => 'Selecteer ten minste één kaart om om te zetten.']);
exit;
}
$db->beginTransaction();
$convertedCount = 0;
foreach ($cardIds as $cardId) {
$cardId = (int)$cardId;
$stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?");
$stmtCard->execute([$cardId]);
$card = $stmtCard->fetch();
if ($card) {
// Maak titel (eerste zin of max 80 tekens)
$firstLine = explode("\n", trim($card['content']))[0];
$title = mb_substr($firstLine, 0, 80);
$stmtRoadmap = $db->prepare("
INSERT INTO roadmap_items (title, description, type, status, quarter, priority, progress, due_date, origin_brainstorm_card_id, created_by_id)
VALUES (?, ?, ?, 'PLANNED', ?, ?, 0, ?, ?, ?)
");
$stmtRoadmap->execute([
$title,
$card['content'],
$type,
$quarter,
$priority,
$dueDate,
$cardId,
$user['id']
]);
$roadmapId = (int)$db->lastInsertId();
// Koppel terug aan brainstorm kaart
$stmtUpdateCard = $db->prepare("UPDATE brainstorm_cards SET converted_to_roadmap_item_id = ? WHERE id = ?");
$stmtUpdateCard->execute([$roadmapId, $cardId]);
$convertedCount++;
}
}
$db->commit();
echo json_encode([
'success' => true,
'message' => "{$convertedCount} brainstorm items succesvol omgezet naar de Roadmap!",
'converted_count' => $convertedCount
]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
if ($db->inTransaction()) $db->rollBack();
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* AJAX API voor Dagplanning & Taken
*/
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
$today = date('Y-m-d');
try {
// 1. Dagdoel of notities opslaan
if ($action === 'save_goal') {
$data = json_decode(file_get_contents('php://input'), true);
$date = $data['date'] ?? $today;
$focusGoal = $data['focus_goal'] ?? '';
$notes = $data['notes'] ?? '';
$stmt = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date, focus_goal, notes)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE focus_goal = VALUES(focus_goal), notes = VALUES(notes)
");
$stmt->execute([$user['id'], $date, $focusGoal, $notes]);
echo json_encode(['success' => true]);
exit;
}
// 2. Taak toevoegen
if ($action === 'add_task') {
$data = json_decode(file_get_contents('php://input'), true);
$title = trim($data['title'] ?? '');
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
$timeBlockStart = $data['time_block_start'] ?? null;
$date = $data['date'] ?? $today;
if (empty($title)) {
echo json_encode(['error' => 'Titel is verplicht']);
exit;
}
// Haal of maak daily plan op
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$user['id'], $date]);
$planId = $db->lastInsertId();
$stmt = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, status)
VALUES (?, ?, ?, ?, ?, 'TODO')
");
$stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart]);
$taskId = (int)$db->lastInsertId();
echo json_encode([
'success' => true,
'task' => [
'id' => $taskId,
'title' => $title,
'priority' => $priority,
'time_block_start' => $timeBlockStart,
'is_completed' => 0,
'status' => 'TODO'
]
]);
exit;
}
// 3. Taak status toggelen (voltooid / open)
if ($action === 'toggle_task') {
$data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0);
$isCompleted = !empty($data['is_completed']) ? 1 : 0;
$status = $isCompleted ? 'DONE' : 'TODO';
$stmt = $db->prepare("
UPDATE tasks SET is_completed = ?, status = ?
WHERE id = ? AND user_id = ?
");
$stmt->execute([$isCompleted, $status, $taskId, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
// 4. Taak verwijderen
if ($action === 'delete_task') {
$data = json_decode(file_get_contents('php://input'), true);
$taskId = (int)($data['task_id'] ?? 0);
$stmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$stmt->execute([$taskId, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* AJAX API voor Roadmap Beheer
*/
require_once __DIR__ . '/../includes/auth.php';
$user = require_auth();
$db = get_db_connection();
header('Content-Type: application/json');
$action = $_GET['action'] ?? ($_POST['action'] ?? '');
try {
// 1. Roadmap item toevoegen
if ($action === 'add_item') {
$data = json_decode(file_get_contents('php://input'), true);
$title = trim($data['title'] ?? '');
$description = trim($data['description'] ?? '');
$quarter = $data['quarter'] ?? 'Q3';
$type = $data['type'] ?? 'FEATURE';
$priority = $data['priority'] ?? 'MEDIUM';
$dueDate = !empty($data['due_date']) ? $data['due_date'] : null;
$jiraKey = !empty($data['jira_key']) ? trim($data['jira_key']) : null;
if (empty($title)) {
echo json_encode(['error' => 'Titel is verplicht']);
exit;
}
$stmt = $db->prepare("
INSERT INTO roadmap_items (title, description, quarter, type, priority, status, progress, due_date, jira_issue_key, created_by_id)
VALUES (?, ?, ?, ?, ?, 'PLANNED', 0, ?, ?, ?)
");
$stmt->execute([$title, $description, $quarter, $type, $priority, $dueDate, $jiraKey, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
// 2. Status of voortgang bijwerken
if ($action === 'update_item') {
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$status = $data['status'] ?? null;
$progress = isset($data['progress']) ? (int)$data['progress'] : null;
$jiraKey = isset($data['jira_key']) ? trim($data['jira_key']) : null;
$fields = [];
$params = [];
if ($status !== null) {
$fields[] = "status = ?";
$params[] = $status;
}
if ($progress !== null) {
$fields[] = "progress = ?";
$params[] = max(0, min(100, $progress));
}
if ($jiraKey !== null) {
$fields[] = "jira_issue_key = ?";
$params[] = $jiraKey;
}
if (!empty($fields)) {
$params[] = $id;
$sql = "UPDATE roadmap_items SET " . implode(", ", $fields) . " WHERE id = ?";
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
echo json_encode(['success' => true]);
exit;
}
// 3. Item verwijderen
if ($action === 'delete_item') {
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$stmt = $db->prepare("DELETE FROM roadmap_items WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
exit;
}
echo json_encode(['error' => 'Onbekende actie']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
+185
View File
@@ -0,0 +1,185 @@
<?php
/**
* Fociz Uitnodiging Accepteren & Account Registratie met 2FA
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
$token = trim($_GET['token'] ?? ($_POST['token'] ?? ''));
$invite = validate_invite($token);
if (!$invite) {
die("
<!DOCTYPE html>
<html lang='nl' class='dark'>
<head><script src='https://cdn.tailwindcss.com'></script></head>
<body class='bg-[#090d16] text-white min-h-screen flex items-center justify-center p-4'>
<div class='max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center'>
<h2 class='text-xl font-bold text-red-400 mb-2'>Ongeldige Uitnodiging</h2>
<p class='text-sm text-slate-400 mb-6'>Deze uitnodigingslink is ongeldig, verlopen of al eerder gebruikt.</p>
<a href='/auth/login.php' class='px-4 py-2 bg-emerald-500 text-slate-950 font-bold rounded-xl text-xs'>Naar Inloggen</a>
</div>
</body>
</html>
");
}
$error = null;
if (empty($_SESSION['invite_totp_secret'])) {
$_SESSION['invite_totp_secret'] = TOTP::generateSecret(16);
}
$secret = $_SESSION['invite_totp_secret'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$totpCode = trim($_POST['totp_code'] ?? '');
if (empty($name) || empty($password) || empty($totpCode)) {
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
} elseif (strlen($password) < 8) {
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn.";
} elseif ($password !== $confirmPassword) {
$error = "Wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
$error = "Ongeldige 2FA verificatiecode. Controleer de tijd op uw smartphone.";
} else {
try {
$db = get_db_connection();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$backupCodes = TOTP::generateBackupCodes(8);
$db->beginTransaction();
// 1. Maak gebruiker aan
$stmt = $db->prepare("
INSERT INTO users (name, email, password_hash, role, two_factor_secret, two_factor_enabled, backup_codes)
VALUES (?, ?, ?, ?, ?, 1, ?)
");
$stmt->execute([
$name,
$invite['email'],
$passwordHash,
$invite['role'],
$secret,
json_encode($backupCodes)
]);
$newUserId = (int)$db->lastInsertId();
// 2. Markeer invite als gebruikt
$stmtInvite = $db->prepare("UPDATE invites SET used_at = NOW() WHERE id = ?");
$stmtInvite->execute([$invite['id']]);
$db->commit();
// Start sessie
$_SESSION['user_id'] = $newUserId;
$_SESSION['user_name'] = $name;
$_SESSION['user_email'] = $invite['email'];
$_SESSION['user_role'] = $invite['role'];
$_SESSION['is_2fa_verified'] = true;
unset($_SESSION['invite_totp_secret']);
header('Location: /planner.php');
exit;
} catch (Exception $e) {
if ($db->inTransaction()) $db->rollBack();
$error = "Fout bij registreren: " . $e->getMessage();
}
}
}
$otpAuthUrl = TOTP::getOtpAuthUrl($invite['email'], $secret, 'Fociz');
$qrCodeUrl = TOTP::getQrCodeUrl($otpAuthUrl, 180);
$pageTitle = "Fociz — Uitnodiging Accepteren";
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
<div class="w-full max-w-lg bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-2xl font-bold text-white">Welkom bij Fociz</h1>
<p class="text-slate-400 text-sm mt-1">
Uitgenodigd door <strong class="text-slate-200"><?= htmlspecialchars($invite['inviter_name']) ?></strong> voor <span class="text-emerald-400 font-mono text-xs bg-emerald-500/10 px-2 py-0.5 rounded"><?= htmlspecialchars($invite['email']) ?></span>
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<form method="POST" action="/auth/invite.php" class="space-y-4">
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Uw naam</label>
<input type="text" name="name" required placeholder="Bijv. Sarah de Vries" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
</div>
<!-- 2FA Verplichting -->
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
<h3 class="text-sm font-bold text-white uppercase tracking-wider text-emerald-400">
Verplichte Tweestapsverificatie (2FA)
</h3>
<p class="text-xs text-slate-400">
Scan deze QR-code in uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-40 h-40 mx-auto">
</div>
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono">
Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1.5">
Voer de 6-cijferige code uit uw app in:
</label>
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
</div>
</div>
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<span>Account Activeren & Inloggen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</form>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* Fociz Veilige Login met Tweestapsverificatie (2FA)
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Als er nog geen admin is, direct naar setup
if (!has_admin_user()) {
header('Location: /auth/setup.php');
exit;
}
// Als al volledig ingelogd en 2FA geverifieerd, ga naar planner
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
header('Location: /planner.php');
exit;
}
$error = null;
$step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
// Terug naar stap 1
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']);
header('Location: /auth/login.php');
exit;
}
// POST Verwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db = get_db_connection();
// STAP 1: Wachtwoord verificatie
if ($step === 1) {
$email = strtolower(trim($_POST['email'] ?? ''));
$password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) {
$error = "Vul e-mailadres en wachtwoord in.";
} else {
$stmt = $db->prepare("SELECT id, name, email, password_hash, two_factor_secret, two_factor_enabled FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name'];
$step = 2;
} else {
$error = "Onjuist e-mailadres of wachtwoord.";
}
}
}
// STAP 2: 2FA TOTP Code verificatie
elseif ($step === 2) {
$userId = $_SESSION['pending_2fa_user_id'] ?? null;
$totpCode = trim(str_replace(' ', '', $_POST['totp_code'] ?? ''));
if (!$userId || empty($totpCode)) {
$error = "Vul de 6-cijferige 2FA verificatiecode in.";
} else {
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_secret, backup_codes FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
$isValid = false;
// 1. Controleer TOTP code
if (!empty($user['two_factor_secret']) && TOTP::verifyCode($user['two_factor_secret'], $totpCode)) {
$isValid = true;
}
// 2. Controleer optioneel backup codes
if (!$isValid && !empty($user['backup_codes'])) {
$backupCodes = json_decode($user['backup_codes'], true) ?: [];
$codeUpper = strtoupper($totpCode);
$key = array_search($codeUpper, $backupCodes);
if ($key !== false) {
$isValid = true;
// Verbruik de backup code
unset($backupCodes[$key]);
$stmtUpdate = $db->prepare("UPDATE users SET backup_codes = ? WHERE id = ?");
$stmtUpdate->execute([json_encode(array_values($backupCodes)), $userId]);
}
}
if ($isValid) {
// Volledige login succesvol
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_role'] = $user['role'];
$_SESSION['is_2fa_verified'] = true;
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
header('Location: /planner.php');
exit;
} else {
$error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw.";
}
}
}
}
$pageTitle = "Fociz — Inloggen";
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
<div class="w-full max-w-md bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-3xl font-extrabold tracking-tight text-white">Fociz</h1>
<p class="text-slate-400 text-sm mt-1">
Veilig inloggen met tweestapsverificatie (2FA)
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($step === 1): ?>
<!-- STAP 1: E-mail & Wachtwoord -->
<form method="POST" action="/auth/login.php" class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres</label>
<div class="relative">
<input type="email" name="email" required autofocus placeholder="uw.naam@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-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>
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord</label>
<div class="relative">
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
</div>
<button type="submit" class="w-full mt-4 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<span>Verder naar 2FA</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
</form>
<?php else: ?>
<!-- STAP 2: 2FA TOTP Code -->
<form method="POST" action="/auth/login.php" class="space-y-5">
<div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-emerald-500/10 text-emerald-400 mb-2">
<i data-lucide="key" class="w-6 h-6"></i>
</div>
<p className="text-sm text-slate-300 font-medium">
Voer uw 6-cijferige verificatiecode in
</p>
<p class="text-xs text-slate-400 mt-1">
Gekoppeld aan <strong class="text-slate-200"><?= htmlspecialchars($_SESSION['pending_2fa_user_email'] ?? '') ?></strong>
</p>
</div>
<div>
<input type="text" name="totp_code" required autofocus maxlength="9" placeholder="000 000" class="w-full text-center tracking-[0.4em] font-mono text-2xl bg-slate-800/80 border border-slate-700 rounded-xl py-3 text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
<span class="text-[11px] text-slate-500 text-center block mt-1">
Tip: U kunt ook een 8-cijferige herstelcode invoeren.
</span>
</div>
<div class="flex space-x-3">
<a href="/auth/login.php?action=restart" class="w-1/3 text-center bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium py-2.5 px-3 rounded-xl transition text-sm flex items-center justify-center">
Terug
</a>
<button type="submit" class="w-2/3 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<span>Inloggen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</div>
</form>
<?php endif; ?>
<div class="mt-8 pt-6 border-t border-slate-800/80 text-center text-xs text-slate-500">
Toegang tot Fociz is strikt op uitnodiging. Vraag uw beheerder om een uitnodigingslink.
</div>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* Fociz Uitloggen
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Vernietig alle sessie variabelen
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params["path"],
$params["domain"],
$params["secure"],
$params["httponly"]
);
}
session_destroy();
header('Location: /auth/login.php');
exit;
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* Fociz First-Run Admin Initialisatie Wizard
*/
require_once __DIR__ . '/../includes/auth.php';
start_secure_session();
// Als er al een beheerder bestaat, sluit deze pagina direct af
if (has_admin_user()) {
header('Location: /auth/login.php');
exit;
}
$error = null;
$success = false;
$backupCodes = [];
// Haal of genereer een tijdelijke sessie-sleutel voor de 2FA configuratie
if (empty($_SESSION['setup_totp_secret'])) {
$_SESSION['setup_totp_secret'] = TOTP::generateSecret(16);
}
$secret = $_SESSION['setup_totp_secret'];
// Formulierverwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$email = strtolower(trim($_POST['email'] ?? ''));
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$totpCode = trim($_POST['totp_code'] ?? '');
if (empty($name) || empty($email) || empty($password) || empty($totpCode)) {
$error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht.";
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in.";
} elseif (strlen($password) < 8) {
$error = "Het wachtwoord moet minimaal 8 tekens bevatten.";
} elseif ($password !== $confirmPassword) {
$error = "De ingevoerde wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) {
$error = "Ongeldige 2FA verificatiecode. Zorg dat de tijd op uw telefoon gelijk loopt en probeer opnieuw.";
} else {
// Alles klopt: maak de allereerste Admin gebruiker aan in MariaDB!
try {
$db = get_db_connection();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$backupCodes = TOTP::generateBackupCodes(8);
$stmt = $db->prepare("
INSERT INTO users (name, email, password_hash, role, two_factor_secret, two_factor_enabled, backup_codes)
VALUES (?, ?, ?, 'ADMIN', ?, 1, ?)
");
$stmt->execute([
$name,
$email,
$passwordHash,
$secret,
json_encode($backupCodes)
]);
$adminId = (int)$db->lastInsertId();
// Automatische sessie starten
$_SESSION['user_id'] = $adminId;
$_SESSION['user_name'] = $name;
$_SESSION['user_email'] = $email;
$_SESSION['user_role'] = 'ADMIN';
$_SESSION['is_2fa_verified'] = true;
unset($_SESSION['setup_totp_secret']);
$success = true;
} catch (Exception $e) {
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
}
}
}
$otpAuthUrl = TOTP::getOtpAuthUrl(!empty($email) ? $email : 'admin@fociz.local', $secret, 'Fociz');
$qrCodeUrl = TOTP::getQrCodeUrl($otpAuthUrl, 200);
$pageTitle = "Fociz — Eerste Initialisatie";
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>body { background-color: #090d16; color: #f1f5f9; }</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#090d16] via-[#0f172a] to-[#090d16]">
<div class="w-full max-w-xl bg-slate-900/90 border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl p-8">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 mb-3 shadow-lg shadow-emerald-500/10">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-3xl font-bold tracking-tight text-white">Fociz Initialisatie</h1>
<p class="text-slate-400 text-sm mt-1">
Eenmalige configuratie van het hoofdbeheerdersaccount met verplichte 2FA
</p>
</div>
<?php if ($error): ?>
<div class="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start space-x-3 text-red-400 text-sm">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($success): ?>
<!-- Stap 3: Voltooid met backup codes -->
<div class="text-center space-y-6">
<div class="w-16 h-16 bg-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto border border-emerald-500/40">
<i data-lucide="check-circle-2" class="w-10 h-10"></i>
</div>
<div>
<h2 class="text-2xl font-bold text-white mb-1">Beheerder Geïnstalleerd!</h2>
<p class="text-slate-400 text-sm">
Uw account is aangemaakt en direct beveiligd met 2FA. Bewaar deze eenmalige herstelcodes veilig:
</p>
</div>
<div class="bg-slate-950/90 border border-slate-800 rounded-xl p-4 text-left">
<div class="text-xs font-semibold text-slate-400 mb-2">HERSTELCODES (NOODGEBRUIK):</div>
<div class="grid grid-cols-2 gap-2 font-mono text-xs text-slate-300">
<?php foreach ($backupCodes as $code): ?>
<span class="bg-slate-900 px-2.5 py-1.5 rounded border border-slate-800 text-center"><?= htmlspecialchars($code) ?></span>
<?php endforeach; ?>
</div>
</div>
<a href="/planner.php" class="inline-flex items-center justify-center w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 space-x-2">
<span>Start met Fociz Dagplanning</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
<?php else: ?>
<form method="POST" action="/auth/setup.php" class="space-y-5">
<!-- Account Info -->
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Volledige naam</label>
<input type="text" name="name" required placeholder="Bijv. 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">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Beheerder E-mailadres</label>
<input type="email" name="email" required placeholder="admin@bedrijf.nl" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Wachtwoord (min. 8 tekens)</label>
<input type="password" name="password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Bevestig wachtwoord</label>
<input type="password" name="confirm_password" required placeholder="••••••••" class="w-full bg-slate-800/80 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 text-sm transition">
</div>
</div>
</div>
<!-- 2FA QR Code & Koppeling -->
<div class="mt-6 pt-6 border-t border-slate-800 text-center space-y-4">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center justify-center space-x-1.5 text-emerald-400">
<i data-lucide="key" class="w-4 h-4"></i>
<span>Tweestapsverificatie (2FA) Koppelen</span>
</h3>
<p class="text-xs text-slate-400 max-w-md mx-auto">
Scan onderstaande QR-code met uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password):
</p>
<div class="inline-block p-3 bg-white rounded-2xl shadow-xl">
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="2FA QR Code" class="w-44 h-44 mx-auto">
</div>
<div class="bg-slate-800/60 border border-slate-700/80 rounded-xl p-2.5 text-xs text-slate-400 max-w-sm mx-auto font-mono flex items-center justify-between">
<span>Sleutel: <strong class="text-slate-200"><?= htmlspecialchars($secret) ?></strong></span>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1.5">
Voer de 6-cijferige verificatiecode uit uw app in:
</label>
<input type="text" name="totp_code" required maxlength="6" placeholder="123456" class="w-48 mx-auto block text-center tracking-[0.5em] font-mono text-xl bg-slate-800 border border-slate-700 rounded-xl py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition">
</div>
</div>
<button type="submit" class="w-full mt-6 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-4 rounded-xl transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<span>Account & 2FA Bevestigen</span>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
</button>
</form>
<?php endif; ?>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* Fociz Brainstorm Sessies Overzicht
*/
require_once __DIR__ . '/includes/header.php';
$db = get_db_connection();
// Verwerk nieuwe sessie aanmaken
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_session') {
$title = trim($_POST['title'] ?? '');
$theme = trim($_POST['theme'] ?? 'Product & Strategie');
$description = trim($_POST['description'] ?? '');
if (!empty($title)) {
$stmt = $db->prepare("
INSERT INTO brainstorm_sessions (title, theme, description, created_by_id)
VALUES (?, ?, ?, ?)
");
$stmt->execute([$title, $theme, $description, $currentUser['id']]);
$newId = $db->lastInsertId();
header("Location: /brainstorm_canvas.php?id={$newId}");
exit;
} else {
$error = "Titel van de brainstormsessie is verplicht.";
}
}
// Haal alle sessies op
$stmtSessions = $db->query("
SELECT s.*, u.name as creator_name,
(SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count
FROM brainstorm_sessions s
JOIN users u ON s.created_by_id = u.id
ORDER BY s.created_at DESC
");
$sessions = $stmtSessions->fetchAll();
?>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<div>
<div class="flex items-center space-x-2 text-amber-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Creatie & Innovatie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Brainstorm Sessies
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
Brainstorm met collega's, verzamel ideeën en knelpunten, en transformeer ze direct tot een werkbare roadmap.
</p>
</div>
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-amber-500/20 flex-shrink-0">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuwe Sessie Starten</span>
</button>
</div>
<?php if ($error): ?>
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<!-- Grid met sessies -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<?php if (empty($sessions)): ?>
<div class="col-span-full bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
<i data-lucide="lightbulb" class="w-12 h-12 text-amber-500/40 mx-auto mb-3"></i>
<h3 class="text-base font-bold text-white">Nog geen brainstormsessies gestart</h3>
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
Start uw eerste brainstormsessie om ideeën te verzamelen en ze met één klik om te vormen naar planningsitems.
</p>
<button onclick="document.getElementById('newSessionModal').classList.remove('hidden')" class="mt-4 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold rounded-xl text-xs transition">
Sessie Starten
</button>
</div>
<?php else: ?>
<?php foreach ($sessions as $sess): ?>
<a href="/brainstorm_canvas.php?id=<?= $sess['id'] ?>" class="group bg-slate-900/80 hover:bg-slate-850 border border-slate-800 hover:border-amber-500/40 rounded-2xl p-5 shadow-lg transition flex flex-col justify-between">
<div>
<div class="flex items-center justify-between mb-3">
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 font-semibold text-[11px]">
<?= htmlspecialchars($sess['theme']) ?>
</span>
<span class="flex items-center space-x-1 text-slate-500 text-xs">
<i data-lucide="message-square" class="w-3.5 h-3.5"></i>
<span><?= $sess['cards_count'] ?> kaarten</span>
</span>
</div>
<h3 class="text-base font-bold text-white group-hover:text-amber-400 transition leading-snug">
<?= htmlspecialchars($sess['title']) ?>
</h3>
<?php if (!empty($sess['description'])): ?>
<p class="text-slate-400 text-xs mt-2 line-clamp-2">
<?= htmlspecialchars($sess['description']) ?>
</p>
<?php endif; ?>
</div>
<div class="mt-5 pt-4 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-500">
<span>Door <?= htmlspecialchars($sess['creator_name']) ?></span>
<div class="flex items-center space-x-1 text-amber-400 group-hover:translate-x-1 transition font-medium">
<span>Open canvas</span>
<i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</div>
</div>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Modal: Nieuwe sessie -->
<div id="newSessionModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
<h3 class="text-base font-bold text-white flex items-center space-x-2">
<i data-lucide="sparkles" class="w-4 h-4 text-amber-400"></i>
<span>Nieuwe Brainstormsessie</span>
</h3>
<button onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form method="POST" action="/brainstorm.php" class="space-y-4">
<input type="hidden" name="action" value="create_session">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Onderwerp / Titel</label>
<input type="text" name="title" required placeholder="Bijv. Q4 Automatisering & Klantreis" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Thema / Categorie</label>
<select name="theme" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="Product & Strategie">Product & Strategie</option>
<option value="Procesverbetering">Procesverbetering</option>
<option value="Klant & Service">Klant & Service</option>
<option value="Technologie & IT">Technologie & IT</option>
<option value="Marketing & Groei">Marketing & Groei</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Doel / Beschrijving</label>
<input type="text" name="description" placeholder="Doel van deze sessie..." class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none">
</div>
</div>
<div class="flex space-x-2 pt-2">
<button type="submit" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
Sessie Aanmaken & Canvas Openen
</button>
<button type="button" onclick="document.getElementById('newSessionModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
Annuleren
</button>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+371
View File
@@ -0,0 +1,371 @@
<?php
/**
* Fociz Interactief Brainstorm Canvas & Roadmap Converter
*/
require_once __DIR__ . '/includes/header.php';
$db = get_db_connection();
$sessionId = (int)($_GET['id'] ?? 0);
$stmtSession = $db->prepare("
SELECT s.*, u.name as creator_name
FROM brainstorm_sessions s
JOIN users u ON s.created_by_id = u.id
WHERE s.id = ?
");
$stmtSession->execute([$sessionId]);
$session = $stmtSession->fetch();
if (!$session) {
echo "<div class='p-8 text-center text-red-400'>Brainstormsessie niet gevonden. <a href='/brainstorm.php' class='underline'>Terug</a></div>";
require_once __DIR__ . '/includes/footer.php';
exit;
}
// Haal alle kaarten op van deze sessie
$stmtCards = $db->prepare("
SELECT * FROM brainstorm_cards
WHERE session_id = ?
ORDER BY votes DESC, created_at ASC
");
$stmtCards->execute([$sessionId]);
$cards = $stmtCards->fetchAll();
$categories = [
'Ideeën' => ['title' => 'Ideeën & Concepten', 'icon' => 'lightbulb', 'color' => 'amber', 'border' => 'border-amber-500/40', 'bg' => 'bg-amber-950/20', 'badge' => 'bg-amber-500/20 text-amber-300'],
'Knelpunten' => ['title' => 'Knelpunten & Uitdagingen', 'icon' => 'alert-triangle', 'color' => 'rose', 'border' => 'border-rose-500/40', 'bg' => 'bg-rose-950/20', 'badge' => 'bg-rose-500/20 text-rose-300'],
'Oplossingen' => ['title' => 'Oplossingen & Features', 'icon' => 'rocket', 'color' => 'emerald', 'border' => 'border-emerald-500/40', 'bg' => 'bg-emerald-950/20', 'badge' => 'bg-emerald-500/20 text-emerald-300'],
'Quick Wins' => ['title' => 'Quick Wins (Direct te doen)', 'icon' => 'zap', 'color' => 'blue', 'border' => 'border-blue-500/40', 'bg' => 'bg-blue-950/20', 'badge' => 'bg-blue-500/20 text-blue-300'],
];
?>
<div class="space-y-6">
<!-- Top Bar -->
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-900/90 border border-slate-800 rounded-2xl p-5 shadow-xl">
<div class="flex items-center space-x-4">
<a href="/brainstorm.php" class="p-2 rounded-xl bg-slate-800 text-slate-400 hover:text-white transition">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
</a>
<div>
<div class="flex items-center space-x-2 text-xs">
<span class="px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 font-semibold border border-amber-500/20">
<?= htmlspecialchars($session['theme']) ?>
</span>
<span class="text-slate-500">Gestart door <?= htmlspecialchars($session['creator_name']) ?></span>
</div>
<h1 class="text-xl sm:text-2xl font-black text-white tracking-tight mt-0.5">
<?= htmlspecialchars($session['title']) ?>
</h1>
</div>
</div>
<div class="flex items-center space-x-3">
<button onclick="openAddCardModal()" class="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold border border-slate-700 transition">
<i data-lucide="plus" class="w-4 h-4 text-amber-400"></i>
<span>Nieuwe Kaart</span>
</button>
<!-- De Kernfeature: Zet om naar Roadmap! -->
<button onclick="openConvertModal()" class="flex items-center space-x-2 px-4 py-2 rounded-xl bg-gradient-to-r from-emerald-500 to-teal-400 hover:from-emerald-600 hover:to-teal-500 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Zet om naar Roadmap</span>
</button>
</div>
</div>
<!-- Whiteboard Categorie Kolommen -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5">
<?php foreach ($categories as $catKey => $catConfig):
$catCards = array_filter($cards, fn($c) => $c['category'] === $catKey);
?>
<div class="bg-slate-900/60 border border-slate-800/80 rounded-2xl p-4 flex flex-col min-h-[500px]">
<!-- Column Header -->
<div class="flex items-center justify-between pb-3 mb-3 border-b border-slate-800">
<div class="flex items-center space-x-2">
<i data-lucide="<?= $catConfig['icon'] ?>" class="w-4 h-4 text-<?= $catConfig['color'] ?>-400"></i>
<h3 class="text-xs font-bold text-white uppercase tracking-wider"><?= $catConfig['title'] ?></h3>
</div>
<span class="text-xs font-mono font-semibold px-2 py-0.5 rounded-full bg-slate-800 text-slate-400">
<?= count($catCards) ?>
</span>
</div>
<!-- Cards Container -->
<div class="flex-1 space-y-3">
<?php if (empty($catCards)): ?>
<div class="py-8 text-center text-slate-600 text-xs">
Nog geen kaarten in deze categorie.
</div>
<?php else: ?>
<?php foreach ($catCards as $card): ?>
<div class="group relative rounded-xl p-4 border <?= $catConfig['border'] ?> <?= $catConfig['bg'] ?> backdrop-blur-sm shadow-md transition hover:-translate-y-0.5" id="card-<?= $card['id'] ?>">
<!-- Top: Checkbox voor selectie + Roadmap badge -->
<div class="flex items-center justify-between mb-2">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" value="<?= $card['id'] ?>" class="card-checkbox w-4 h-4 rounded bg-slate-900 border-slate-700 text-emerald-500 focus:ring-0">
<span class="text-[10px] text-slate-400">Selecteer</span>
</label>
<?php if (!empty($card['converted_to_roadmap_item_id'])): ?>
<span class="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 font-semibold text-[10px] flex items-center space-x-1 border border-emerald-500/30">
<i data-lucide="check" class="w-3 h-3"></i>
<span>Op Roadmap</span>
</span>
<?php endif; ?>
</div>
<!-- Inhoud -->
<p class="text-xs text-slate-200 leading-relaxed whitespace-pre-wrap">
<?= htmlspecialchars($card['content']) ?>
</p>
<!-- Footer van kaart -->
<div class="mt-3 pt-2.5 border-t border-slate-800/60 flex items-center justify-between text-[10px] text-slate-400">
<span><?= htmlspecialchars($card['author_name'] ?? 'Collega') ?></span>
<div class="flex items-center space-x-2">
<!-- Upvote button -->
<button onclick="voteCard(<?= $card['id'] ?>)" class="flex items-center space-x-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-amber-300 transition">
<i data-lucide="thumbs-up" class="w-3 h-3"></i>
<span id="votes-<?= $card['id'] ?>" class="font-bold"><?= $card['votes'] ?></span>
</button>
<!-- Delete button -->
<button onclick="deleteCard(<?= $card['id'] ?>)" class="p-1 rounded text-slate-500 hover:text-red-400 transition opacity-0 group-hover:opacity-100">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Quick add button at column bottom -->
<button onclick="openAddCardModal('<?= $catKey ?>')" class="mt-3 w-full py-2 rounded-xl border border-dashed border-slate-800 hover:border-slate-700 text-slate-500 hover:text-slate-300 text-xs transition flex items-center justify-center space-x-1">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span>Kaart toevoegen</span>
</button>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Modal: Nieuwe Kaart Toevoegen -->
<div id="addCardModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-md w-full">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
<h3 class="text-base font-bold text-white flex items-center space-x-2">
<i data-lucide="sticky-note" class="w-4 h-4 text-amber-400"></i>
<span>Brainstormkaart Toevoegen</span>
</h3>
<button onclick="closeAddCardModal()" class="text-slate-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Categorie</label>
<select id="modalCardCategory" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="Ideeën">💡 Ideeën & Concepten</option>
<option value="Knelpunten">⚠️ Knelpunten & Uitdagingen</option>
<option value="Oplossingen">🚀 Oplossingen & Features</option>
<option value="Quick Wins">⚡ Quick Wins</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Idee / Beschrijving</label>
<textarea id="modalCardContent" rows="4" placeholder="Schrijf hier uw inzicht, idee of knelpunt..." class="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-amber-500 resize-none"></textarea>
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitNewCard()" class="flex-1 bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
Plaats op Canvas
</button>
<button onclick="closeAddCardModal()" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
Annuleren
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Zet om naar Roadmap -->
<div id="convertModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-emerald-500/40 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
<h3 class="text-base font-bold text-white flex items-center space-x-2">
<i data-lucide="map" class="w-4 h-4 text-emerald-400"></i>
<span>Zet Brainstorm om naar Roadmap</span>
</h3>
<button onclick="closeConvertModal()" class="text-slate-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="space-y-4">
<div class="bg-emerald-950/30 border border-emerald-500/20 rounded-xl p-3 text-xs text-emerald-300">
<span id="selectedCountText" class="font-bold">0</span> kaarten geselecteerd om om te zetten naar planbare roadmap items.
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Doel Kwartaal</label>
<select id="roadmapQuarter" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="Q1">Q1 (Jan - Mrt)</option>
<option value="Q2">Q2 (Apr - Jun)</option>
<option value="Q3" selected>Q3 (Jul - Sep)</option>
<option value="Q4">Q4 (Okt - Dec)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Item Type</label>
<select id="roadmapType" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="FEATURE" selected>Feature / Functionaliteit</option>
<option value="EPIC">Epic (Groot project)</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Oplos-Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Prioriteit</label>
<select id="roadmapPriority" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="HIGH" selected>Hoog (Belangrijke milestone)</option>
<option value="CRITICAL">Kritiek (Must-have)</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag / Backlog</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Streefdatum (Deadline)</label>
<input type="date" id="roadmapDueDate" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none">
</div>
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitConvertToRoadmap()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<i data-lucide="check-circle" class="w-4 h-4"></i>
<span>Definitief Toevoegen aan Roadmap</span>
</button>
<button onclick="closeConvertModal()" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
Annuleren
</button>
</div>
</div>
</div>
</div>
<script>
const sessionId = <?= $sessionId ?>;
function openAddCardModal(defaultCategory = null) {
if (defaultCategory) {
document.getElementById('modalCardCategory').value = defaultCategory;
}
document.getElementById('addCardModal').classList.remove('hidden');
document.getElementById('modalCardContent').focus();
}
function closeAddCardModal() {
document.getElementById('addCardModal').classList.add('hidden');
document.getElementById('modalCardContent').value = '';
}
async function submitNewCard() {
const category = document.getElementById('modalCardCategory').value;
const content = document.getElementById('modalCardContent').value.trim();
if (!content) return;
const res = await fetch('/api/brainstorm.php?action=add_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ session_id: sessionId, category, content })
});
const data = await res.json();
if (data.success) {
location.reload();
}
}
async function voteCard(cardId) {
const res = await fetch('/api/brainstorm.php?action=vote_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cardId })
});
const data = await res.json();
if (data.success) {
document.getElementById(`votes-${cardId}`).innerText = data.votes;
}
}
async function deleteCard(cardId) {
if (!confirm('Kaart verwijderen?')) return;
await fetch('/api/brainstorm.php?action=delete_card', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ card_id: cardId })
});
document.getElementById(`card-${cardId}`)?.remove();
}
// --- Roadmap Conversie Logica ---
function getSelectedCardIds() {
const checkboxes = document.querySelectorAll('.card-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function openConvertModal() {
const selectedIds = getSelectedCardIds();
if (selectedIds.length === 0) {
alert('Vink eerst minimaal één kaart aan om om te zetten naar de roadmap.');
return;
}
document.getElementById('selectedCountText').innerText = selectedIds.length;
document.getElementById('convertModal').classList.remove('hidden');
}
function closeConvertModal() {
document.getElementById('convertModal').classList.add('hidden');
}
async function submitConvertToRoadmap() {
const cardIds = getSelectedCardIds();
const quarter = document.getElementById('roadmapQuarter').value;
const type = document.getElementById('roadmapType').value;
const priority = document.getElementById('roadmapPriority').value;
const dueDate = document.getElementById('roadmapDueDate').value;
const res = await fetch('/api/brainstorm.php?action=convert_to_roadmap', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
card_ids: cardIds,
quarter,
type,
priority,
due_date: dueDate
})
});
const data = await res.json();
if (data.success) {
alert(data.message);
window.location.href = '/roadmap.php';
} else {
alert(data.error || 'Er is een fout opgetreden.');
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* Fociz Applicatie- en Databaseconfiguratie
*/
// Automatisch eenvoudige .env ondersteuning indien aanwezig
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), '#')) continue;
if (strpos($line, '=') !== false) {
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$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));
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
}
// MariaDB Verbindingsgegevens (pas deze aan naar uw server instellingen of gebruik .env)
define('DB_HOST', getenv('DB_HOST') ?: '127.0.0.1');
define('DB_PORT', getenv('DB_PORT') ?: '3306');
define('DB_NAME', getenv('DB_NAME') ?: 'fociz');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASS') !== false ? getenv('DB_PASS') : '');
define('DB_CHARSET', 'utf8mb4');
// Applicatie instellingen
define('APP_NAME', 'Fociz');
define('APP_URL', getenv('APP_URL') ?: ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost')));
define('APP_SECRET', getenv('APP_SECRET') ?: 'fociz-maria-db-super-secret-key-998877');
// Tijdzone
date_default_timezone_set('Europe/Amsterdam');
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* MariaDB PDO Databaseverbinding & Automatische Migratie
*/
require_once __DIR__ . '/config.php';
function get_db_connection(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
DB_HOST,
DB_PORT,
DB_NAME,
DB_CHARSET
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
// Zorg ervoor dat tabellen bestaan
ensure_tables_exist($pdo);
} catch (PDOException $e) {
// Als de database zelf nog niet bestaat, probeer deze aan te maken indien gebruiker rechten heeft
if ($e->getCode() == 1049) {
try {
$tempDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
$tempPdo = new PDO($tempDsn, DB_USER, DB_PASS, $options);
$tempPdo->exec(sprintf('CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', DB_NAME));
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
ensure_tables_exist($pdo);
return $pdo;
} catch (PDOException $ex) {
die("Database verbindingsfout: Kan database '" . htmlspecialchars(DB_NAME) . "' niet aanmaken of vinden. Maak deze handmatig aan in MariaDB.");
}
}
die("MariaDB Verbindingsfout: " . htmlspecialchars($e->getMessage()) . "<br><small>Controleer uw instellingen in config/config.php of .env</small>");
}
}
return $pdo;
}
/**
* Zorgt ervoor dat alle tabellen uit schema.sql aanwezig zijn in MariaDB
*/
function ensure_tables_exist(PDO $pdo): void {
try {
$result = $pdo->query("SHOW TABLES LIKE 'users'");
if ($result->rowCount() === 0) {
$schemaFile = __DIR__ . '/../schema.sql';
if (file_exists($schemaFile)) {
$sql = file_get_contents($schemaFile);
$pdo->exec($sql);
}
}
} catch (PDOException $e) {
// Tabellen bestaan waarschijnlijk al of er is een permissiebeperking
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Authenticatie & Autorisatie Guard voor Fociz
*/
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/totp.php';
function start_secure_session(): void {
if (session_status() === PHP_SESSION_NONE) {
// Veilige sessiecookie parameters
$isSecure = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
session_set_cookie_params([
'lifetime' => 7 * 24 * 60 * 60, // 7 dagen
'path' => '/',
'domain' => '',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
}
}
/**
* Controleert of er al een beheerder bestaat in MariaDB
*/
function has_admin_user(): bool {
$db = get_db_connection();
$stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'");
return ((int)$stmt->fetchColumn()) > 0;
}
/**
* Haalt de huidige ingelogde gebruiker op uit de database
*/
function get_logged_in_user(): ?array {
start_secure_session();
if (empty($_SESSION['user_id']) || empty($_SESSION['is_2fa_verified'])) {
return null;
}
$db = get_db_connection();
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Beschermt pagina's: dwingt login + 2FA af
*/
function require_auth(): array {
start_secure_session();
// 1. Controleer of eerste setup nodig is
if (!has_admin_user()) {
header('Location: /auth/setup.php');
exit;
}
// 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is
$user = get_logged_in_user();
if (!$user) {
header('Location: /auth/login.php');
exit;
}
return $user;
}
/**
* Dwingt beheerder (ADMIN) rol af
*/
function require_admin(): array {
$user = require_auth();
if ($user['role'] !== 'ADMIN') {
header('Location: /planner.php');
exit;
}
return $user;
}
/**
* Maakt een nieuwe uitnodiging aan voor een collega
*/
function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string {
$db = get_db_connection();
$token = bin2hex(random_bytes(24));
$expiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
$stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]);
return $token;
}
/**
* Valideert een invite token
*/
function validate_invite(string $token): ?array {
$db = get_db_connection();
$stmt = $db->prepare("
SELECT i.*, u.name as inviter_name, u.email as inviter_email
FROM invites i
JOIN users u ON i.created_by_id = u.id
WHERE i.token = ?
");
$stmt->execute([$token]);
$invite = $stmt->fetch();
if (!$invite) return null;
if ($invite['used_at'] !== null) return null;
if (strtotime($invite['expires_at']) < time()) return null;
return $invite;
}
/**
* CSRF token helpers
*/
function get_csrf_token(): string {
start_secure_session();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(?string $token): bool {
start_secure_session();
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
+24
View File
@@ -0,0 +1,24 @@
</main>
<footer class="border-t border-slate-800/80 py-6 text-center text-xs text-slate-500">
<div class="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center space-x-2">
<span class="font-bold text-slate-400">Fociz</span>
<span>&bull;</span>
<span>Focus & Structuur Systeem</span>
</div>
<div>
<span>Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
</div>
</div>
</footer>
<script>
// Lucide Icons initialiseren
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
</script>
</body>
</html>
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Header & Navigatiebalk voor Fociz
*/
require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? get_logged_in_user();
$currentPage = basename($_SERVER['PHP_SELF']);
?>
<!DOCTYPE html>
<html lang="nl" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Dagplanning & Roadmap') ?></title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f0fdf4',
500: '#10b981',
600: '#059669',
700: '#047857',
}
}
}
}
}
</script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body {
background-color: #090d16;
color: #f1f5f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
</style>
</head>
<body class="min-h-screen flex flex-col antialiased selection:bg-emerald-500 selection:text-white">
<?php if ($currentUser): ?>
<header class="sticky top-0 z-40 bg-[#0c121e]/90 backdrop-blur-md border-b border-slate-800/80">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Brand Logo -->
<div class="flex items-center space-x-6">
<a href="/planner.php" class="flex items-center space-x-2.5 group">
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-400 flex items-center justify-center text-slate-950 font-black text-lg shadow-md shadow-emerald-500/20 group-hover:scale-105 transition">
F
</div>
<div class="flex flex-col">
<span class="text-xl font-bold tracking-tight text-white leading-none">Fociz</span>
<span class="text-[10px] text-emerald-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
</div>
</a>
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'planner.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Dagplanning</span>
</a>
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="lightbulb" class="w-4 h-4"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'roadmap.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Roadmap</span>
</a>
<a href="/integrations.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'integrations.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="layers" class="w-4 h-4"></i>
<span>Integraties</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex items-center space-x-2 px-3.5 py-2 rounded-lg text-sm font-medium transition <?= $currentPage === 'users.php' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' ?>">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Team & Invites</span>
</a>
<?php endif; ?>
</nav>
</div>
<!-- Right side: User & 2FA Status -->
<div class="flex items-center space-x-3">
<div class="hidden sm:flex items-center space-x-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs font-medium">
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i>
<span>2FA Actief</span>
</div>
<div class="text-right hidden sm:block">
<div class="text-xs font-semibold text-slate-200"><?= htmlspecialchars($currentUser['name']) ?></div>
<div class="text-[10px] text-slate-500">
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?> • <?= htmlspecialchars($currentUser['email']) ?>
</div>
</div>
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition">
<i data-lucide="log-out" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
</a>
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="lightbulb" class="w-4 h-4 mb-0.5"></i>
<span>Brainstorm</span>
</a>
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
<span>Roadmap</span>
</a>
<a href="/integrations.php" class="flex flex-col items-center py-1 <?= $currentPage === 'integrations.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="layers" class="w-4 h-4 mb-0.5"></i>
<span>Koppelingen</span>
</a>
<?php if ($currentUser['role'] === 'ADMIN'): ?>
<a href="/users.php" class="flex flex-col items-center py-1 <?= $currentPage === 'users.php' ? 'text-emerald-400 font-bold' : 'text-slate-400' ?>">
<i data-lucide="users" class="w-4 h-4 mb-0.5"></i>
<span>Team</span>
</a>
<?php endif; ?>
</div>
</div>
</header>
<?php endif; ?>
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Pure PHP TOTP (RFC 6238) & 2FA Implementatie
* Volledig compatibel met Google Authenticator, Microsoft Authenticator & 1Password
*/
class TOTP {
private static string $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Genereert een willekeurige 16-teken Base32 geheime sleutel
*/
public static function generateSecret(int $length = 16): string {
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= self::$base32Chars[ord($bytes[$i]) % 32];
}
return $secret;
}
/**
* Decodeert een Base32 string naar binaire data
*/
private static function base32Decode(string $b32): string {
$b32 = strtoupper(trim($b32));
$buffer = 0;
$bufferBits = 0;
$binary = '';
for ($i = 0; $i < strlen($b32); $i++) {
$char = $b32[$i];
$val = strpos(self::$base32Chars, $char);
if ($val === false) continue; // Negeer spaties/ongeldige tekens
$buffer = ($buffer << 5) | $val;
$bufferBits += 5;
if ($bufferBits >= 8) {
$bufferBits -= 8;
$binary .= chr(($buffer >> $bufferBits) & 0xFF);
}
}
return $binary;
}
/**
* Berekent de 6-cijferige TOTP code voor een bepaald tijdsinterval
*/
public static function getCode(string $secret, ?int $timestamp = null): string {
if ($timestamp === null) {
$timestamp = time();
}
$timeCounter = floor($timestamp / 30);
$binaryKey = self::base32Decode($secret);
// Converteer tijdsinterval naar 8-byte big-endian binaire string
$binaryTime = pack('N*', 0) . pack('N*', $timeCounter);
$hash = hash_hmac('sha1', $binaryTime, $binaryKey, true);
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
$binaryCode = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
(ord($hash[$offset + 3]) & 0xFF)
);
$otp = $binaryCode % 1000000;
return str_pad((string)$otp, 6, '0', STR_PAD_LEFT);
}
/**
* Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter)
*/
public static function verifyCode(string $secret, string $userInputCode, int $window = 1): boolean {
$userInputCode = trim(str_replace(' ', '', $userInputCode));
if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) {
return false;
}
$currentTime = time();
for ($i = -$window; $i <= $window; $i++) {
$calcCode = self::getCode($secret, $currentTime + ($i * 30));
if (hash_equals($calcCode, $userInputCode)) {
return true;
}
}
return false;
}
/**
* Maakt de otpauth:// URL aan voor authenticator apps
*/
public static function getOtpAuthUrl(string $email, string $secret, string $issuer = 'Fociz'): string {
return sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
rawurlencode($issuer),
rawurlencode($email),
$secret,
rawurlencode($issuer)
);
}
/**
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
*/
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
return sprintf(
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
urlencode($otpAuthUrl),
$size
);
}
/**
* Genereert een lijst met eenmalige herstelcodes
*/
public static function generateBackupCodes(int $count = 8): array {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$bytes = bin2hex(random_bytes(4));
$codes[] = strtoupper(substr($bytes, 0, 4) . '-' . substr($bytes, 4, 4));
}
return $codes;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Fociz Entrypoint
*/
require_once __DIR__ . '/includes/auth.php';
start_secure_session();
if (!has_admin_user()) {
header('Location: /auth/setup.php');
exit;
}
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
header('Location: /planner.php');
exit;
}
header('Location: /auth/login.php');
exit;
+304
View File
@@ -0,0 +1,304 @@
<?php
/**
* Fociz Integraties Hub (Outlook, Teams, Jira)
*/
require_once __DIR__ . '/includes/header.php';
$db = get_db_connection();
$userId = $currentUser['id'];
// Formulierverwerking voor integratie instellingen
$message = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$provider = $_POST['provider'] ?? '';
$isDemo = isset($_POST['is_demo_mode']) ? 1 : 0;
$settings = json_encode([
'client_id' => $_POST['client_id'] ?? '',
'client_secret' => $_POST['client_secret'] ?? '',
'tenant_id' => $_POST['tenant_id'] ?? '',
'jira_domain' => $_POST['jira_domain'] ?? '',
'jira_email' => $_POST['jira_email'] ?? '',
'jira_api_token' => $_POST['jira_api_token'] ?? '',
]);
if (in_array($provider, ['OUTLOOK', 'TEAMS', 'JIRA'])) {
$stmt = $db->prepare("
INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at)
VALUES (?, ?, 1, ?, ?, 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]);
$message = "Instellingen voor {$provider} succesvol opgeslagen.";
}
}
// Haal huidige configuraties op
$stmtConfigs = $db->prepare("SELECT * FROM integrations WHERE user_id = ?");
$stmtConfigs->execute([$userId]);
$configsRaw = $stmtConfigs->fetchAll();
$configs = [];
foreach ($configsRaw as $c) {
$configs[$c['provider']] = $c;
$configs[$c['provider']]['settings_parsed'] = json_decode($c['settings'] ?? '{}', true) ?: [];
}
// Jira demo issues
$jiraIssues = [
[
'key' => 'FOC-101',
'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren',
'status' => 'In Progress',
'priority' => 'High',
'epic' => 'Security & Access',
],
[
'key' => 'FOC-102',
'summary' => 'Interactief whiteboard canvas voor brainstormsessies',
'status' => 'To Do',
'priority' => 'High',
'epic' => 'Brainstorming Module',
],
[
'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">
<!-- Header -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<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>
<span>Connectiviteit & Synchronisatie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Integraties Hub
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
Beheer uw koppelingen met Microsoft Outlook (agenda & taken), Microsoft Teams (meetings) en Atlassian Jira (issues & roadmap).
</p>
</div>
<?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">
<i data-lucide="check-circle" class="w-4 h-4"></i>
<span><?= htmlspecialchars($message) ?></span>
</div>
<?php endif; ?>
<!-- Integratie Cards Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 1. OUTLOOK -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl flex flex-col justify-between">
<div>
<div class="flex items-center justify-between mb-4">
<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>
</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">
<i data-lucide="check-circle" class="w-3 h-3"></i>
<span>Actief</span>
</span>
</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">
<span>Status:</span>
<span class="text-emerald-400 font-semibold">Gekoppeld</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Sync frequentie:</span>
<span class="text-slate-200">Elke 15 min</span>
</div>
</div>
</div>
<!-- Instellingen formulier -->
<form method="POST" action="/integrations.php" class="pt-3 border-t border-slate-800 space-y-3">
<input type="hidden" name="provider" value="OUTLOOK">
<label class="flex items-center space-x-2 text-xs text-slate-300">
<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">
<span>Gebruik interactieve Sandbox data</span>
</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">
Instellingen Bijwerken
</button>
</form>
</div>
<!-- 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>
<div class="flex items-center justify-between mb-4">
<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>
</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">
<i data-lucide="check-circle" class="w-3 h-3"></i>
<span>Actief</span>
</span>
</div>
<h3 class="text-base font-bold text-white mb-1">Microsoft Teams</h3>
<p class="text-xs text-slate-400 mb-4">
Toont vandaag geplande Teams meetings met directe 'Deelnemen' links en kanaalupdates.
</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">
<span>Direct Deelnemen:</span>
<span class="text-emerald-400 font-semibold">Ondersteund</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Meldingen:</span>
<span class="text-slate-200">Ingeschakeld</span>
</div>
</div>
</div>
<form method="POST" action="/integrations.php" class="pt-3 border-t border-slate-800 space-y-3">
<input type="hidden" name="provider" value="TEAMS">
<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">
<span>Gebruik interactieve Sandbox data</span>
</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">
Instellingen Bijwerken
</button>
</form>
</div>
<!-- 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>
<div class="flex items-center justify-between mb-4">
<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>
</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">
<i data-lucide="check-circle" class="w-3 h-3"></i>
<span>Actief</span>
</span>
</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">
<span>Two-way Status Sync:</span>
<span class="text-emerald-400 font-semibold">Ondersteund</span>
</div>
<div class="flex items-center justify-between text-slate-400">
<span>Roadmap Linkage:</span>
<span class="text-slate-200">Actief</span>
</div>
</div>
</div>
<form method="POST" action="/integrations.php" class="pt-3 border-t border-slate-800 space-y-2">
<input type="hidden" name="provider" value="JIRA">
<div>
<label class="block text-[10px] 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">
</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['JIRA']) || !empty($configs['JIRA']['is_demo_mode']) ? 'checked' : '' ?> class="rounded bg-slate-900 border-slate-700 text-emerald-500">
<span>Gebruik interactieve Sandbox data</span>
</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">
Jira Opslaan
</button>
</form>
</div>
</div>
<!-- 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="flex items-center justify-between pb-3 border-b border-slate-800">
<div class="flex items-center space-x-2">
<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">
Jira Issues Toegewezen aan U
</h3>
</div>
<span class="text-xs text-slate-400 font-mono"><?= count($jiraIssues) ?> issues</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<?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">
<div>
<div class="flex items-center justify-between mb-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">
<?= htmlspecialchars($issue['key']) ?>
</span>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300">
<?= htmlspecialchars($issue['status']) ?>
</span>
</div>
<h4 class="text-xs font-bold text-white leading-snug">
<?= htmlspecialchars($issue['summary']) ?>
</h4>
<span class="text-[10px] text-slate-500 mt-1 block">Epic: <?= htmlspecialchars($issue['epic']) ?></span>
</div>
<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>
<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">
<i data-lucide="plus" class="w-3 h-3"></i>
<span>Plan als Dagtaak</span>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
async function importJiraToTasks(title, jiraKey) {
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',
date: '<?= date('Y-m-d') ?>'
})
});
const data = await res.json();
if (data.success) {
alert(`Jira issue ${jiraKey} is toegevoegd aan uw dagplanning!`);
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+494
View File
@@ -0,0 +1,494 @@
<?php
/**
* Fociz Dagplanning, Focus Modus & Time-blocking
*/
require_once __DIR__ . '/includes/header.php';
$db = get_db_connection();
$userId = $currentUser['id'];
$today = date('Y-m-d');
// Haal dagplanning op of maak aan
$stmtPlan = $db->prepare("SELECT * FROM daily_plans WHERE user_id = ? AND plan_date = ?");
$stmtPlan->execute([$userId, $today]);
$dailyPlan = $stmtPlan->fetch();
if (!$dailyPlan) {
$stmtCreatePlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date, focus_goal)
VALUES (?, ?, 'Mijn belangrijkste doel van vandaag behalen')
");
$stmtCreatePlan->execute([$userId, $today]);
$planId = (int)$db->lastInsertId();
$dailyPlan = ['id' => $planId, 'plan_date' => $today, 'focus_goal' => 'Mijn belangrijkste doel van vandaag behalen', 'notes' => ''];
}
// Haal taken op voor vandaag
$stmtTasks = $db->prepare("
SELECT * FROM tasks
WHERE user_id = ? AND (daily_plan_id = ? OR daily_plan_id IS NULL)
ORDER BY is_completed ASC, priority ASC, time_block_start ASC, created_at DESC
");
$stmtTasks->execute([$userId, $dailyPlan['id']]);
$tasks = $stmtTasks->fetchAll();
// Outlook demo / gesynchroniseerde events
$outlookEvents = [
[
'id' => 'evt-1',
'subject' => 'Daily Team Standup & Focus Sync',
'start' => '09:00',
'end' => '09:30',
'location' => 'Microsoft Teams',
'meeting_url' => 'https://teams.microsoft.com',
],
[
'id' => 'evt-2',
'subject' => 'Sprint & Roadmap Review met Stakeholders',
'start' => '11:00',
'end' => '12:00',
'location' => 'Vergaderruimte A & Teams',
'meeting_url' => 'https://teams.microsoft.com',
],
[
'id' => 'evt-3',
'subject' => 'Architectuur & MariaDB Database Evaluatie',
'start' => '14:30',
'end' => '15:30',
'location' => 'Online Teams Call',
'meeting_url' => 'https://teams.microsoft.com',
]
];
$hours = ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00", "17:00"];
// Nederlandse datumformattering
$dutchDays = ['Zondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrijdag', 'Zaterdag'];
$dutchMonths = ['', 'januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'];
$formattedDate = $dutchDays[date('w')] . ' ' . date('j') . ' ' . $dutchMonths[(int)date('n')] . ' ' . date('Y');
?>
<div class="space-y-6">
<!-- Top Banner: Datum & Hoofddoel -->
<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>
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span><?= htmlspecialchars($formattedDate) ?></span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Dagplanning & Structuur
</h1>
</div>
<div class="flex items-center space-x-2">
<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">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
<span>Outlook & Teams Verbonden</span>
</a>
</div>
</div>
<!-- Dagdoel invoerveld -->
<div class="mt-5 pt-4 border-t border-slate-800/80">
<label class="block text-xs font-bold text-emerald-400 uppercase tracking-wider mb-1.5 flex items-center space-x-1.5">
<i data-lucide="target" class="w-4 h-4"></i>
<span>Mijn Belangrijkste Dagdoel (Nummer 1 Focus)</span>
</label>
<div class="flex items-center space-x-2">
<input
type="text"
id="focusGoalInput"
value="<?= htmlspecialchars($dailyPlan['focus_goal'] ?? '') ?>"
placeholder="Wat moet er vandaag absoluut gebeuren voor succes?"
class="flex-1 bg-slate-950/70 border border-slate-700/80 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 font-medium transition"
>
<button
onclick="saveGoal()"
id="saveGoalBtn"
class="px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold text-xs rounded-xl transition flex items-center space-x-1 shadow-md shadow-emerald-500/20"
>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
<span>Opslaan</span>
</button>
</div>
</div>
</div>
<!-- Main Grid: Timer + Takenlijst (Links) & Timeblocking (Rechts) -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Linker kolom: Focus Timer & Taken -->
<div class="lg:col-span-7 space-y-6">
<!-- Focus Modus Timer -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl relative overflow-hidden backdrop-blur-sm">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<i data-lucide="flame" class="w-4 h-4"></i>
</div>
<div>
<h3 class="text-sm font-bold text-white uppercase tracking-wider">Focus Modus</h3>
<p class="text-[11px] text-slate-400">Pomodoro & diepe concentratie</p>
</div>
</div>
<div class="flex bg-slate-950/80 p-1 rounded-xl border border-slate-800 text-xs">
<button onclick="setTimerPreset(25)" id="btnPreset25" class="px-2.5 py-1 rounded-lg font-bold bg-emerald-500 text-slate-950 transition">25m Focus</button>
<button onclick="setTimerPreset(50)" id="btnPreset50" class="px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200 transition">50m Deep</button>
<button onclick="setTimerPreset(5)" id="btnPreset5" class="px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200 transition">5m Pauze</button>
</div>
</div>
<div class="text-center my-6">
<div id="timerDisplay" class="text-5xl sm:text-6xl font-black font-mono tracking-tight text-white mb-2">25:00</div>
<div class="min-h-[22px] text-xs text-slate-400 truncate max-w-sm mx-auto flex items-center justify-center space-x-1">
<span class="text-emerald-400 font-medium">Actieve focus:</span>
<span id="activeTaskDisplay" class="text-slate-200 font-semibold">Kies een taak hieronder</span>
</div>
<div class="w-full bg-slate-950/80 h-2 rounded-full overflow-hidden mt-4 border border-slate-800/80">
<div id="timerProgress" class="h-full bg-gradient-to-r from-emerald-500 to-teal-400 transition-all duration-300 rounded-full" style="width: 0%"></div>
</div>
</div>
<div class="flex items-center justify-center space-x-3">
<button id="timerToggleBtn" onclick="toggleTimer()" class="flex items-center space-x-2 px-6 py-2.5 rounded-xl font-bold text-sm bg-emerald-500 hover:bg-emerald-600 text-slate-950 transition shadow-lg shadow-emerald-500/20">
<i data-lucide="play" class="w-4 h-4 fill-current"></i>
<span id="timerToggleText">Start Focus</span>
</button>
<button onclick="resetTimer()" class="p-2.5 rounded-xl bg-slate-800/80 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition border border-slate-700/60" title="Reset">
<i data-lucide="rotate-ccw" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- Takenlijst -->
<div 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 mb-4 pb-3 border-b border-slate-800">
<div>
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
<span>Dagtaken & Prioriteiten</span>
<span class="text-xs font-normal text-slate-400">(<?= count($tasks) ?>)</span>
</h3>
</div>
<button onclick="toggleAddTaskForm()" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl bg-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-md shadow-emerald-500/20">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuwe Taak</span>
</button>
</div>
<!-- Nieuwe taak formulier (verborgen by default) -->
<div id="addTaskForm" class="hidden mb-4 p-4 rounded-xl bg-slate-950/90 border border-emerald-500/30 space-y-3">
<div>
<label class="block text-[11px] font-medium text-slate-400 mb-1">Taaktitel</label>
<input type="text" id="newTaskTitle" placeholder="Wat moet er vandaag gedaan worden?" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-medium text-slate-400 mb-1">Prioriteit</label>
<select id="newTaskPriority" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none">
<option value="P1">P1 - Brandend / Hoogste Focus</option>
<option value="P2" selected>P2 - Belangrijk (Standaard)</option>
<option value="P3">P3 - Regulier / Lage druk</option>
</select>
</div>
<div>
<label class="block text-[11px] font-medium text-slate-400 mb-1">Tijdblok (Starttijd)</label>
<input type="text" id="newTaskTime" placeholder="Bijv. 09:30" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs font-mono focus:outline-none">
</div>
</div>
<div class="flex space-x-2 pt-1">
<button onclick="submitNewTask()" class="px-4 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold rounded-lg text-xs transition">Opslaan</button>
<button onclick="toggleAddTaskForm()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 rounded-lg text-xs transition">Annuleren</button>
</div>
</div>
<!-- Taken weergave -->
<div class="space-y-2" id="tasksContainer">
<?php if (empty($tasks)): ?>
<div class="text-center py-8 text-slate-500 text-xs">
Nog geen taken gepland voor vandaag. Klik op 'Nieuwe Taak' om te beginnen.
</div>
<?php else: ?>
<?php foreach ($tasks as $task): ?>
<div class="group flex items-center justify-between p-3 rounded-xl border <?= $task['is_completed'] ? 'bg-slate-950/30 border-slate-900 opacity-60' : 'bg-slate-950/60 border-slate-800 hover:border-slate-700' ?> transition" id="task-row-<?= $task['id'] ?>">
<div class="flex items-center space-x-3 truncate mr-2">
<button onclick="toggleTask(<?= $task['id'] ?>, <?= $task['is_completed'] ? 0 : 1 ?>)" class="w-5 h-5 rounded-lg border flex items-center justify-center transition <?= $task['is_completed'] ? 'bg-emerald-500 border-emerald-500 text-slate-950 font-bold' : 'border-slate-700 hover:border-emerald-400 bg-slate-900' ?>">
<?php if ($task['is_completed']): ?>
<i data-lucide="check" class="w-3.5 h-3.5 stroke-[3]"></i>
<?php endif; ?>
</button>
<div class="truncate">
<span class="text-xs font-medium block truncate <?= $task['is_completed'] ? 'line-through text-slate-500' : 'text-slate-200' ?>">
<?= htmlspecialchars($task['title']) ?>
</span>
<?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">
<i data-lucide="clock" class="w-2.5 h-2.5"></i>
<span><?= htmlspecialchars($task['time_block_start']) ?></span>
</span>
<?php endif; ?>
</div>
</div>
<div class="flex items-center space-x-2 flex-shrink-0">
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $task['priority'] === 'P1' ? 'bg-red-500/20 text-red-400 border border-red-500/30' : ($task['priority'] === 'P2' ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30' : 'bg-slate-800 text-slate-400') ?>">
<?= htmlspecialchars($task['priority']) ?>
</span>
<?php if (!$task['is_completed']): ?>
<button onclick="selectFocusTask('<?= htmlspecialchars(addslashes($task['title'])) ?>')" title="Koppel aan Focus Timer" class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-400 hover:bg-emerald-500/10 transition">
<i data-lucide="flame" class="w-3.5 h-3.5"></i>
</button>
<?php endif; ?>
<button onclick="deleteTask(<?= $task['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Dagnotities & Reflectie -->
<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">
<i data-lucide="book-open" class="w-4 h-4 text-emerald-400"></i>
<h3 class="text-sm font-bold text-white uppercase tracking-wider">Dagnotities & Kladblok</h3>
</div>
<textarea
id="dailyNotesInput"
rows="3"
placeholder="Belangrijke aantekeningen, reflecties of snelle memo's voor vandaag..."
onblur="saveGoal()"
class="w-full bg-slate-950/70 border border-slate-800 rounded-xl p-3 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none transition"
><?= htmlspecialchars($dailyPlan['notes'] ?? '') ?></textarea>
</div>
</div>
<!-- Rechter kolom: Timeblocking & Outlook Tijdlijn -->
<div class="lg:col-span-5 space-y-6">
<div 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 mb-4 pb-3 border-b border-slate-800">
<div class="flex items-center space-x-2">
<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>
</div>
<span class="text-xs text-slate-400">08:00 – 18:00</span>
</div>
<div class="space-y-2">
<?php foreach ($hours as $hour):
$hourInt = (int)explode(':', $hour)[0];
// Zoek Outlook meetings
$slotEvents = array_filter($outlookEvents, function($e) use ($hourInt) {
return (int)explode(':', $e['start'])[0] === $hourInt;
});
// Zoek taken in dit blok
$slotTasks = array_filter($tasks, function($t) use ($hourInt) {
return !empty($t['time_block_start']) && (int)explode(':', $t['time_block_start'])[0] === $hourInt;
});
?>
<div class="flex items-start group min-h-[50px] border-b border-slate-800/40 pb-2 last:border-0">
<div class="w-14 text-xs font-mono font-medium text-slate-400 pt-1 flex-shrink-0">
<?= $hour ?>
</div>
<div class="flex-1 space-y-1.5">
<!-- Outlook meetings -->
<?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 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">
<?= $evt['start'] ?> - <?= $evt['end'] ?>
</span>
<span class="font-semibold truncate"><?= htmlspecialchars($evt['subject']) ?></span>
</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">
<i data-lucide="video" class="w-3 h-3"></i>
<span>Deelnemen</span>
</a>
</div>
<?php endforeach; ?>
<!-- Geplande taken -->
<?php foreach ($slotTasks as $t): ?>
<div class="flex items-center justify-between p-2 rounded-xl border text-xs <?= $t['is_completed'] ? 'bg-slate-900/40 border-slate-800 text-slate-500 line-through' : 'bg-emerald-950/30 border-emerald-800/40 text-emerald-100' ?>">
<div class="flex items-center space-x-2 truncate mr-2">
<span class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 font-mono text-[10px] font-bold">
<?= htmlspecialchars($t['time_block_start']) ?>
</span>
<span class="font-semibold truncate"><?= htmlspecialchars($t['title']) ?></span>
</div>
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
<?= $t['priority'] ?>
</span>
</div>
<?php endforeach; ?>
<?php if (empty($slotEvents) && empty($slotTasks)): ?>
<button onclick="quickSchedule('<?= $hour ?>')" class="w-full text-left py-1 px-2 rounded-lg border border-dashed border-transparent hover:border-slate-800 hover:bg-slate-950/40 text-slate-600 hover:text-slate-400 text-xs transition">
+ Plan taak om <?= $hour ?>
</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
<script>
// --- Focus Timer Logica ---
let timerInterval = null;
let timerSeconds = 25 * 60;
let totalDuration = 25 * 60;
let isRunning = false;
function updateTimerDisplay() {
const min = Math.floor(timerSeconds / 60);
const sec = timerSeconds % 60;
document.getElementById('timerDisplay').innerText =
`${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
const progress = ((totalDuration - timerSeconds) / totalDuration) * 100;
document.getElementById('timerProgress').style.width = `${progress}%`;
}
function setTimerPreset(minutes) {
clearInterval(timerInterval);
isRunning = false;
timerSeconds = minutes * 60;
totalDuration = minutes * 60;
updateTimerDisplay();
// Toggle styling
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('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('btnPreset5').className = minutes === 5 ? 'px-2.5 py-1 rounded-lg font-bold bg-teal-500 text-slate-950' : 'px-2.5 py-1 rounded-lg text-slate-400 hover:text-slate-200';
}
function toggleTimer() {
if (isRunning) {
clearInterval(timerInterval);
isRunning = false;
document.getElementById('timerToggleText').innerText = 'Hervatten';
} else {
isRunning = true;
document.getElementById('timerToggleText').innerText = 'Pauzeren';
timerInterval = setInterval(() => {
if (timerSeconds > 0) {
timerSeconds--;
updateTimerDisplay();
} else {
clearInterval(timerInterval);
isRunning = false;
document.getElementById('timerToggleText').innerText = 'Start Focus';
alert('Focus sessie voltooid! Neem even een korte pauze.');
}
}, 1000);
}
}
function resetTimer() {
clearInterval(timerInterval);
isRunning = false;
timerSeconds = totalDuration;
document.getElementById('timerToggleText').innerText = 'Start Focus';
updateTimerDisplay();
}
function selectFocusTask(title) {
document.getElementById('activeTaskDisplay').innerText = title;
}
// --- Dagdoel & Taken AJAX ---
async function saveGoal() {
const focusGoal = document.getElementById('focusGoalInput').value;
const notes = document.getElementById('dailyNotesInput').value;
const btn = document.getElementById('saveGoalBtn');
btn.innerText = 'Opslaan...';
try {
await fetch('/api/planner.php?action=save_goal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ focus_goal: focusGoal, notes: notes, date: '<?= $today ?>' })
});
btn.innerHTML = '<i data-lucide="check" class="w-4 h-4"></i><span>Opgeslagen</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
btn.innerHTML = '<i data-lucide="check-circle-2" class="w-4 h-4"></i><span>Opslaan</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
}, 1500);
} catch(err) {
btn.innerText = 'Fout';
}
}
function toggleAddTaskForm() {
const form = document.getElementById('addTaskForm');
form.classList.toggle('hidden');
if (!form.classList.contains('hidden')) {
document.getElementById('newTaskTitle').focus();
}
}
function quickSchedule(hour) {
toggleAddTaskForm();
document.getElementById('newTaskTime').value = hour;
}
async function submitNewTask() {
const title = document.getElementById('newTaskTitle').value.trim();
const priority = document.getElementById('newTaskPriority').value;
const time = document.getElementById('newTaskTime').value.trim();
if (!title) return;
const res = await fetch('/api/planner.php?action=add_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ title, priority, time_block_start: time, date: '<?= $today ?>' })
});
const data = await res.json();
if (data.success) {
location.reload();
}
}
async function toggleTask(taskId, isCompleted) {
await fetch('/api/planner.php?action=toggle_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, is_completed: isCompleted })
});
location.reload();
}
async function deleteTask(taskId) {
if (!confirm('Taak verwijderen?')) return;
await fetch('/api/planner.php?action=delete_task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId })
});
document.getElementById(`task-row-${taskId}`)?.remove();
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+329
View File
@@ -0,0 +1,329 @@
<?php
/**
* Fociz Roadmap & Tijdlijn
*/
require_once __DIR__ . '/includes/header.php';
$db = get_db_connection();
$selectedQuarter = $_GET['quarter'] ?? 'ALL';
$selectedStatus = $_GET['status'] ?? 'ALL';
$sql = "
SELECT r.*, u.name as creator_name, c.content as brainstorm_snippet
FROM roadmap_items r
JOIN users u ON r.created_by_id = u.id
LEFT JOIN brainstorm_cards c ON r.origin_brainstorm_card_id = c.id
WHERE 1=1
";
$params = [];
if ($selectedQuarter !== 'ALL') {
$sql .= " AND r.quarter = ?";
$params[] = $selectedQuarter;
}
if ($selectedStatus !== 'ALL') {
$sql .= " AND r.status = ?";
$params[] = $selectedStatus;
}
$sql .= " ORDER BY r.quarter ASC, r.priority DESC, r.due_date ASC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$roadmapItems = $stmt->fetchAll();
$quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
?>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<div>
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="map" class="w-4 h-4"></i>
<span>Planning & Strategie</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Roadmap & Tijdlijn
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
Centraal overzicht van alle mijlpalen, features en doelen, direct voortgekomen uit brainstorms en gesynchroniseerd met Jira.
</p>
</div>
<button onclick="document.getElementById('newItemModal').classList.remove('hidden')" class="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-600 text-slate-950 text-xs font-bold transition shadow-lg shadow-emerald-500/20 flex-shrink-0">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>Nieuw Item</span>
</button>
</div>
<!-- Filters: Quarters & Status -->
<div class="flex flex-wrap items-center justify-between gap-3 bg-slate-900/40 border border-slate-800/80 rounded-2xl p-4">
<!-- Quarter Tabs -->
<div class="flex items-center space-x-1 text-xs">
<a href="/roadmap.php?quarter=ALL&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-xl transition font-medium <?= $selectedQuarter === 'ALL' ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
Alle Quarters
</a>
<?php foreach ($quarters as $q): ?>
<a href="/roadmap.php?quarter=<?= $q ?>&status=<?= htmlspecialchars($selectedStatus) ?>" class="px-3 py-1.5 rounded-xl transition font-medium <?= $selectedQuarter === $q ? 'bg-emerald-500 text-slate-950 font-bold' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' ?>">
<?= $q ?>
</a>
<?php endforeach; ?>
</div>
<!-- Status Filter -->
<div class="flex items-center space-x-2 text-xs">
<span class="text-slate-500">Status:</span>
<select onchange="window.location.href='/roadmap.php?quarter=<?= htmlspecialchars($selectedQuarter) ?>&status='+this.value" class="bg-slate-800 border border-slate-700 rounded-xl px-2.5 py-1 text-white text-xs focus:outline-none">
<option value="ALL" <?= $selectedStatus === 'ALL' ? 'selected' : '' ?>>Alle Statussen</option>
<option value="PLANNED" <?= $selectedStatus === 'PLANNED' ? 'selected' : '' ?>>Gepland</option>
<option value="IN_PROGRESS" <?= $selectedStatus === 'IN_PROGRESS' ? 'selected' : '' ?>>In Uitvoering</option>
<option value="REVIEW" <?= $selectedStatus === 'REVIEW' ? 'selected' : '' ?>>In Review</option>
<option value="COMPLETED" <?= $selectedStatus === 'COMPLETED' ? 'selected' : '' ?>>Voltooid</option>
</select>
</div>
</div>
<!-- Roadmap Items Lijst -->
<div class="space-y-4">
<?php if (empty($roadmapItems)): ?>
<div class="bg-slate-900/60 border border-slate-800/80 rounded-2xl p-12 text-center">
<i data-lucide="map-pin" class="w-12 h-12 text-emerald-500/40 mx-auto mb-3"></i>
<h3 class="text-base font-bold text-white">Geen items gevonden op de roadmap</h3>
<p class="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
Ga naar een <a href="/brainstorm.php" class="text-amber-400 underline">Brainstorm sessie</a> en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe.
</p>
</div>
<?php else: ?>
<?php foreach ($roadmapItems as $item): ?>
<div class="bg-slate-900/80 border border-slate-800 hover:border-slate-700 rounded-2xl p-5 shadow-lg transition space-y-3" id="roadmap-item-<?= $item['id'] ?>">
<!-- Top header van item -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div class="flex items-center space-x-2.5 flex-wrap">
<!-- Quarter badge -->
<span class="px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 font-mono text-xs font-bold border border-emerald-500/30">
<?= htmlspecialchars($item['quarter']) ?>
</span>
<!-- Type badge -->
<span class="px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 text-[10px] font-semibold">
<?= htmlspecialchars($item['type']) ?>
</span>
<!-- Priority badge -->
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full <?= $item['priority'] === 'CRITICAL' ? 'bg-red-500/20 text-red-400' : ($item['priority'] === 'HIGH' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-800 text-slate-400') ?>">
<?= htmlspecialchars($item['priority']) ?>
</span>
<!-- Jira Issue Key link -->
<?php if (!empty($item['jira_issue_key'])): ?>
<a href="/integrations.php" class="flex items-center space-x-1 px-2 py-0.5 rounded-full bg-blue-950/60 border border-blue-800/60 text-blue-300 text-[10px] font-mono hover:underline">
<i data-lucide="layers" class="w-3 h-3"></i>
<span><?= htmlspecialchars($item['jira_issue_key']) ?></span>
</a>
<?php endif; ?>
</div>
<!-- Status Selector -->
<div class="flex items-center space-x-2">
<select onchange="updateRoadmapStatus(<?= $item['id'] ?>, this.value)" class="bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-slate-200 font-semibold focus:outline-none">
<option value="PLANNED" <?= $item['status'] === 'PLANNED' ? 'selected' : '' ?>>⏳ Gepland</option>
<option value="IN_PROGRESS" <?= $item['status'] === 'IN_PROGRESS' ? 'selected' : '' ?>>⚡ In Uitvoering</option>
<option value="REVIEW" <?= $item['status'] === 'REVIEW' ? 'selected' : '' ?>>🔍 In Review</option>
<option value="COMPLETED" <?= $item['status'] === 'COMPLETED' ? 'selected' : '' ?>>✅ Voltooid</option>
</select>
<button onclick="deleteRoadmapItem(<?= $item['id'] ?>)" title="Verwijderen" class="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- Titel & Beschrijving -->
<div>
<h3 class="text-base font-bold text-white leading-snug">
<?= htmlspecialchars($item['title']) ?>
</h3>
<?php if (!empty($item['description'])): ?>
<p class="text-slate-400 text-xs mt-1 whitespace-pre-wrap">
<?= htmlspecialchars($item['description']) ?>
</p>
<?php endif; ?>
</div>
<!-- Origin Brainstorm snippet -->
<?php if (!empty($item['origin_brainstorm_card_id'])): ?>
<div class="bg-slate-950/50 rounded-xl p-2.5 text-[11px] text-slate-400 flex items-center space-x-2 border border-slate-800">
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-amber-400 flex-shrink-0"></i>
<span class="truncate">Ontstaan uit brainstormsessie</span>
</div>
<?php endif; ?>
<!-- Voortgangsbalk & Deadline -->
<div class="pt-2 border-t border-slate-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<!-- Progress Slider -->
<div class="flex items-center space-x-3 flex-1 max-w-md">
<span class="text-slate-400 text-[11px] font-medium w-16">Voortgang:</span>
<div class="flex-1 bg-slate-950 h-2 rounded-full overflow-hidden border border-slate-800">
<div class="bg-gradient-to-r from-emerald-500 to-teal-400 h-full transition-all" style="width: <?= $item['progress'] ?>%"></div>
</div>
<span class="font-mono text-slate-200 font-bold w-10 text-right"><?= $item['progress'] ?>%</span>
<div class="flex space-x-1">
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 25)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">25%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 50)" class="px-1.5 py-0.5 rounded bg-slate-800 text-[10px] text-slate-400 hover:text-white">50%</button>
<button onclick="updateRoadmapProgress(<?= $item['id'] ?>, 100)" class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-[10px] text-emerald-400 hover:text-emerald-300 font-bold">100%</button>
</div>
</div>
<!-- Deadline & Creator -->
<div class="flex items-center space-x-3 text-slate-500 text-[11px]">
<?php if (!empty($item['due_date'])): ?>
<span class="flex items-center space-x-1 text-slate-300">
<i data-lucide="calendar" class="w-3.5 h-3.5 text-emerald-400"></i>
<span>Deadline: <?= date('d-m-Y', strtotime($item['due_date'])) ?></span>
</span>
<?php endif; ?>
<span>Toegevoegd door <?= htmlspecialchars($item['creator_name']) ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Modal: Nieuw Roadmap Item Handmatig Toevoegen -->
<div id="newItemModal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl max-w-lg w-full">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-slate-800">
<h3 class="text-base font-bold text-white flex items-center space-x-2">
<i data-lucide="map" class="w-4 h-4 text-emerald-400"></i>
<span>Nieuw Roadmap Item Toevoegen</span>
</h3>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="text-slate-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Titel / Doel</label>
<input type="text" id="manualTitle" placeholder="Bijv. 2FA Authenticatie afronden" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-white text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500">
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Beschrijving / Specificatie</label>
<textarea id="manualDescription" rows="3" placeholder="Wat houdt deze milestone of feature in?" class="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-white text-xs placeholder-slate-500 focus:outline-none resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Kwartaal</label>
<select id="manualQuarter" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3" selected>Q3</option>
<option value="Q4">Q4</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Type</label>
<select id="manualType" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="FEATURE" selected>Feature</option>
<option value="EPIC">Epic</option>
<option value="MILESTONE">Mijlpaal</option>
<option value="TASK">Taak</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Prioriteit</label>
<select id="manualPriority" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white text-xs focus:outline-none">
<option value="CRITICAL">Kritiek</option>
<option value="HIGH" selected>Hoog</option>
<option value="MEDIUM">Normaal</option>
<option value="LOW">Laag</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Jira Issue Key (optioneel)</label>
<input type="text" id="manualJiraKey" placeholder="Bijv. FOC-101" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs font-mono focus:outline-none">
</div>
</div>
<div>
<label class="block text-xs font-medium text-slate-300 mb-1">Streefdatum</label>
<input type="date" id="manualDueDate" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-white text-xs focus:outline-none">
</div>
<div class="flex space-x-2 pt-2">
<button onclick="submitManualItem()" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition">
Toevoegen aan Roadmap
</button>
<button onclick="document.getElementById('newItemModal').classList.add('hidden')" class="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs transition">
Annuleren
</button>
</div>
</div>
</div>
</div>
<script>
async function updateRoadmapStatus(id, status) {
await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id, status })
});
}
async function updateRoadmapProgress(id, progress) {
await fetch('/api/roadmap.php?action=update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id, progress })
});
location.reload();
}
async function deleteRoadmapItem(id) {
if (!confirm('Dit roadmap item verwijderen?')) return;
await fetch('/api/roadmap.php?action=delete_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id })
});
document.getElementById(`roadmap-item-${id}`)?.remove();
}
async function submitManualItem() {
const title = document.getElementById('manualTitle').value.trim();
const description = document.getElementById('manualDescription').value.trim();
const quarter = document.getElementById('manualQuarter').value;
const type = document.getElementById('manualType').value;
const priority = document.getElementById('manualPriority').value;
const jira_key = document.getElementById('manualJiraKey').value.trim();
const due_date = document.getElementById('manualDueDate').value;
if (!title) {
alert('Titel is verplicht');
return;
}
const res = await fetch('/api/roadmap.php?action=add_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ title, description, quarter, type, priority, jira_key, due_date })
});
const data = await res.json();
if (data.success) {
location.reload();
}
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>
+128
View File
@@ -0,0 +1,128 @@
-- Fociz MariaDB Databaseschema
-- UTF-8 / utf8mb4 ondersteuning voor emoticons, sticky notes en internationale tekens
CREATE TABLE IF NOT EXISTS `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(120) NOT NULL,
`email` VARCHAR(191) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) NOT NULL,
`role` ENUM('ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER',
`two_factor_secret` VARCHAR(120) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`backup_codes` TEXT NULL,
`avatar_url` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `invites` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`token` VARCHAR(64) NOT NULL UNIQUE,
`email` VARCHAR(191) NOT NULL,
`role` ENUM('ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER',
`expires_at` DATETIME NOT NULL,
`used_at` DATETIME NULL,
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`token`),
CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `daily_plans` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`plan_date` DATE NOT NULL,
`focus_goal` TEXT NULL,
`secondary_goals` TEXT NULL,
`notes` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_date` (`user_id`, `plan_date`),
CONSTRAINT `fk_dailyplans_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `tasks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`daily_plan_id` INT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`status` ENUM('TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED') NOT NULL DEFAULT 'TODO',
`priority` ENUM('P1', 'P2', 'P3') NOT NULL DEFAULT 'P2',
`time_block_start` VARCHAR(10) NULL,
`time_block_end` VARCHAR(10) NULL,
`duration_minutes` INT NULL DEFAULT 30,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ',
`external_id` VARCHAR(100) NULL,
`external_url` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`user_id`),
CONSTRAINT `fk_tasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tasks_plan` FOREIGN KEY (`daily_plan_id`) REFERENCES `daily_plans` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `brainstorm_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`theme` VARCHAR(100) NOT NULL DEFAULT 'Algemeen',
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`created_by_id`),
CONSTRAINT `fk_brainstorm_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `brainstorm_cards` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`session_id` INT NOT NULL,
`content` TEXT NOT NULL,
`category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën',
`color` VARCHAR(20) NOT NULL DEFAULT 'amber',
`votes` INT NOT NULL DEFAULT 0,
`author_name` VARCHAR(120) NULL,
`converted_to_roadmap_item_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`session_id`),
CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `roadmap_items` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`type` VARCHAR(50) NOT NULL DEFAULT 'FEATURE',
`status` VARCHAR(50) NOT NULL DEFAULT 'PLANNED',
`quarter` VARCHAR(20) NOT NULL DEFAULT 'Q3',
`priority` VARCHAR(20) NOT NULL DEFAULT 'MEDIUM',
`progress` INT NOT NULL DEFAULT 0,
`start_date` DATE NULL,
`due_date` DATE NULL,
`jira_issue_key` VARCHAR(50) NULL,
`origin_brainstorm_card_id` INT NULL,
`created_by_id` INT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (`created_by_id`),
CONSTRAINT `fk_roadmap_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `integrations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`provider` ENUM('OUTLOOK', 'TEAMS', 'JIRA') NOT NULL,
`is_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_demo_mode` TINYINT(1) NOT NULL DEFAULT 1,
`access_token` TEXT NULL,
`refresh_token` TEXT NULL,
`settings` TEXT NULL,
`last_sync_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_provider` (`user_id`, `provider`),
CONSTRAINT `fk_integrations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+266
View File
@@ -0,0 +1,266 @@
<?php
/**
* Fociz Team & Uitnodigingen Beheer (Enkel voor Admin)
*/
require_once __DIR__ . '/includes/header.php';
$adminUser = require_admin();
$db = get_db_connection();
$message = null;
$generatedLink = null;
$error = null;
// Uitnodiging aanmaken
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_invite') {
$email = strtolower(trim($_POST['email'] ?? ''));
$role = in_array($_POST['role'] ?? '', ['ADMIN', 'MEMBER']) ? $_POST['role'] : 'MEMBER';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in.";
} else {
// Controleer of e-mail al bestaat
$stmtCheck = $db->prepare("SELECT id FROM users WHERE email = ?");
$stmtCheck->execute([$email]);
if ($stmtCheck->fetch()) {
$error = "Er bestaat al een gebruiker met dit e-mailadres.";
} else {
$token = create_invite($adminUser['id'], $email, $role);
$baseUrl = rtrim(APP_URL, '/');
$generatedLink = "{$baseUrl}/auth/invite.php?token={$token}";
$message = "Uitnodigingslink succesvol aangemaakt!";
}
}
}
// Uitnodiging intrekken
if (isset($_GET['delete_invite'])) {
$inviteId = (int)$_GET['delete_invite'];
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
$stmtDel->execute([$inviteId]);
header('Location: /users.php');
exit;
}
// Gebruiker verwijderen
if (isset($_GET['delete_user'])) {
$delUserId = (int)$_GET['delete_user'];
if ($delUserId !== (int)$adminUser['id']) {
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
$stmtDelUser->execute([$delUserId]);
}
header('Location: /users.php');
exit;
}
// Haal alle gebruikers op
$users = $db->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll();
// Haal alle uitnodigingen op
$invites = $db->query("
SELECT i.*, u.name as inviter_name
FROM invites i
JOIN users u ON i.created_by_id = u.id
ORDER BY i.created_at DESC
")->fetchAll();
?>
<div class="space-y-6">
<!-- Header -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<div class="flex items-center space-x-2 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-1">
<i data-lucide="shield" class="w-4 h-4"></i>
<span>Beheerderspaneel</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight">
Team & Uitnodigingen
</h1>
<p class="text-slate-400 text-xs sm:text-sm mt-1">
Fociz is strikt afgesloten: nieuwe collega's kunnen enkel toegang krijgen na het ontvangen van een uitnodigingslink en verplichte 2FA-activatie.
</p>
</div>
<?php if ($error): ?>
<div class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-semibold flex items-center space-x-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<?php if ($message && $generatedLink): ?>
<div class="p-5 rounded-2xl bg-emerald-950/40 border border-emerald-500/40 text-white space-y-2 shadow-xl">
<div class="flex items-center space-x-2 text-emerald-400 font-bold text-xs">
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
<span><?= htmlspecialchars($message) ?></span>
</div>
<p class="text-xs text-slate-300">
Kopieer onderstaande beveiligde registratielink en stuur deze naar de collega:
</p>
<div class="flex items-center space-x-2 bg-slate-950 p-2.5 rounded-xl border border-slate-800">
<input type="text" readonly id="inviteLinkInput" value="<?= htmlspecialchars($generatedLink) ?>" class="bg-transparent text-emerald-300 font-mono text-xs flex-1 outline-none">
<button onclick="copyInviteLink()" class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold text-xs rounded-lg transition flex items-center space-x-1">
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
<span id="copyBtnText">Kopiëren</span>
</button>
</div>
</div>
<?php endif; ?>
<!-- Uitnodiging Genereren Kaart -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-6 shadow-xl">
<h3 class="text-base font-bold text-white mb-4 flex items-center space-x-2">
<i data-lucide="user-plus" class="w-5 h-5 text-emerald-400"></i>
<span>Nieuwe Collega Uitnodigen</span>
</h3>
<form method="POST" action="/users.php" class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
<input type="hidden" name="action" value="create_invite">
<div class="md:col-span-6">
<label class="block text-xs font-medium text-slate-300 mb-1">E-mailadres van collega</label>
<input type="email" name="email" required placeholder="collega@bedrijf.nl" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 text-xs">
</div>
<div class="md:col-span-3">
<label class="block text-xs font-medium text-slate-300 mb-1">Rol</label>
<select name="role" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-white text-xs focus:outline-none">
<option value="MEMBER">Collega (Standaard)</option>
<option value="ADMIN">Mede-beheerder</option>
</select>
</div>
<div class="md:col-span-3">
<button type="submit" class="w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2.5 px-4 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 flex items-center justify-center space-x-2">
<i data-lucide="link" class="w-4 h-4"></i>
<span>Genereer Link</span>
</button>
</div>
</form>
</div>
<!-- Actieve Gebruikers Tabel -->
<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">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="users" class="w-4 h-4 text-emerald-400"></i>
<span>Geregistreerde Teamleden (<?= count($users) ?>)</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
<tr>
<th class="p-3">Naam & E-mail</th>
<th class="p-3">Rol</th>
<th class="p-3">2FA Beveiliging</th>
<th class="p-3">Lid sinds</th>
<th class="p-3 text-right">Acties</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
<?php foreach ($users as $u): ?>
<tr class="hover:bg-slate-800/30 transition">
<td class="p-3">
<div class="font-bold text-white"><?= htmlspecialchars($u['name']) ?></div>
<div class="text-[11px] text-slate-400 font-mono"><?= htmlspecialchars($u['email']) ?></div>
</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold <?= $u['role'] === 'ADMIN' ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' : 'bg-slate-800 text-slate-300' ?>">
<?= $u['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
</span>
</td>
<td class="p-3">
<span class="px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] font-medium flex items-center space-x-1 w-max">
<i data-lucide="shield-check" class="w-3 h-3"></i>
<span>2FA Actief</span>
</span>
</td>
<td class="p-3 text-slate-400">
<?= date('d-m-Y', strtotime($u['created_at'])) ?>
</td>
<td class="p-3 text-right">
<?php if ($u['id'] !== $adminUser['id']): ?>
<a href="/users.php?delete_user=<?= $u['id'] ?>" onclick="return confirm('Gebruiker verwijderen?')" class="text-slate-500 hover:text-red-400 transition p-1">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
<?php else: ?>
<span class="text-[10px] text-slate-600 font-medium">Uzelf</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Uitstaande Uitnodigingen Tabel -->
<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">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center space-x-2">
<i data-lucide="mail" class="w-4 h-4 text-amber-400"></i>
<span>Uitnodigingen Status (<?= count($invites) ?>)</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-950/60 text-slate-400 uppercase text-[10px] tracking-wider border-b border-slate-800">
<tr>
<th class="p-3">Uitgenodigd E-mailadres</th>
<th class="p-3">Rol</th>
<th class="p-3">Status</th>
<th class="p-3">Verloopt op</th>
<th class="p-3 text-right">Actie</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
<?php if (empty($invites)): ?>
<tr><td colspan="5" class="p-4 text-center text-slate-500">Nog geen uitnodigingen verstuurd.</td></tr>
<?php else: ?>
<?php foreach ($invites as $inv):
$isUsed = !empty($inv['used_at']);
$isExpired = strtotime($inv['expires_at']) < time();
?>
<tr class="hover:bg-slate-800/30 transition">
<td class="p-3 font-mono"><?= htmlspecialchars($inv['email']) ?></td>
<td class="p-3"><?= htmlspecialchars($inv['role']) ?></td>
<td class="p-3">
<?php if ($isUsed): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400">Geaccepteerd</span>
<?php elseif ($isExpired): ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400">Verlopen</span>
<?php else: ?>
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400">In afwachting</span>
<?php endif; ?>
</td>
<td class="p-3 text-slate-400"><?= date('d-m-Y H:i', strtotime($inv['expires_at'])) ?></td>
<td class="p-3 text-right">
<a href="/users.php?delete_invite=<?= $inv['id'] ?>" onclick="return confirm('Uitnodiging intrekken?')" class="text-slate-500 hover:text-red-400 transition p-1">
<i data-lucide="trash-2" class="w-4 h-4 inline"></i>
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
function copyInviteLink() {
const input = document.getElementById('inviteLinkInput');
input.select();
navigator.clipboard.writeText(input.value);
document.getElementById('copyBtnText').innerText = 'Gekopieerd!';
setTimeout(() => {
document.getElementById('copyBtnText').innerText = 'Kopiëren';
}, 2000);
}
</script>
<?php require_once __DIR__ . '/includes/footer.php'; ?>