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:
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Systeem & Editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
@@ -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()]);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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'; ?>
|
||||||
|
|
||||||
@@ -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'; ?>
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>•</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>
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
@@ -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'; ?>
|
||||||
|
|
||||||
Reference in New Issue
Block a user