Compare commits
23 Commits
8ddae4d883
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c55a94618f | |||
| 3b51d2da96 | |||
| dd7295812f | |||
| 5ea25758bd | |||
| ef189273b5 | |||
| 39a41fa207 | |||
| 78369a44c2 | |||
| 999f93cc1a | |||
| 07988b3e1d | |||
| 1d8560f50c | |||
| 0c466972c2 | |||
| fb9034034a | |||
| dc6b55e4f9 | |||
| ef116be9b8 | |||
| 65d5a4d4a3 | |||
| a4824bb440 | |||
| e45f565365 | |||
| 3dc09a11a7 | |||
| 86041b665d | |||
| f18f7d5794 | |||
| 45600040ea | |||
| 660f196c60 | |||
| 32974cdb31 |
@@ -385,7 +385,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$activeTab = 'system';
|
$activeTab = 'system';
|
||||||
try {
|
try {
|
||||||
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
||||||
$aiModel = trim((string)($_POST['ai_model'] ?? 'gemini-1.5-pro'));
|
$aiModel = trim((string)($_POST['ai_model'] ?? 'gemini-2.0-flash'));
|
||||||
$aiProvider = trim((string)($_POST['ai_provider'] ?? ''));
|
$aiProvider = trim((string)($_POST['ai_provider'] ?? ''));
|
||||||
if (empty($aiProvider)) {
|
if (empty($aiProvider)) {
|
||||||
$aiProvider = str_contains($aiModel, 'gpt') ? 'openai' : 'gemini';
|
$aiProvider = str_contains($aiModel, 'gpt') ? 'openai' : 'gemini';
|
||||||
@@ -462,6 +462,46 @@ $sessionIdleTimeout = get_session_idle_timeout();
|
|||||||
$calendarBlockReasons = get_calendar_block_reasons();
|
$calendarBlockReasons = get_calendar_block_reasons();
|
||||||
$brainstormCategories = get_brainstorm_categories();
|
$brainstormCategories = get_brainstorm_categories();
|
||||||
|
|
||||||
|
// PHP Runtime, Database & Security Patch Status
|
||||||
|
$phpVersion = PHP_VERSION;
|
||||||
|
$phpSapi = php_sapi_name();
|
||||||
|
$phpMemoryLimit = ini_get('memory_limit') ?: 'Onbekend';
|
||||||
|
$phpMaxExecution = ini_get('max_execution_time') ? ini_get('max_execution_time') . 's' : 'Onbeperkt';
|
||||||
|
$phpOpcacheEnabled = function_exists('opcache_get_status') && !empty(opcache_get_status(false)['opcache_enabled']);
|
||||||
|
$phpOpensslVersion = defined('OPENSSL_VERSION_TEXT') ? OPENSSL_VERSION_TEXT : 'Niet gedetecteerd';
|
||||||
|
|
||||||
|
// PHP Security Patch status (CVE-2026-109618 / CVE-2026-109619 PHP2Shell)
|
||||||
|
// Gepatched in 8.2.35, 8.3.36, 8.4.27, 8.5.12
|
||||||
|
$isPhpPatched = false;
|
||||||
|
$patchRequiredVersion = '';
|
||||||
|
$isSupportedPhpBranch = true;
|
||||||
|
|
||||||
|
if (version_compare($phpVersion, '8.2.0', '>=') && version_compare($phpVersion, '8.3.0', '<')) {
|
||||||
|
$patchRequiredVersion = '8.2.35';
|
||||||
|
$isPhpPatched = version_compare($phpVersion, '8.2.35', '>=');
|
||||||
|
} elseif (version_compare($phpVersion, '8.3.0', '>=') && version_compare($phpVersion, '8.4.0', '<')) {
|
||||||
|
$patchRequiredVersion = '8.3.36';
|
||||||
|
$isPhpPatched = version_compare($phpVersion, '8.3.36', '>=');
|
||||||
|
} elseif (version_compare($phpVersion, '8.4.0', '>=') && version_compare($phpVersion, '8.5.0', '<')) {
|
||||||
|
$patchRequiredVersion = '8.4.27';
|
||||||
|
$isPhpPatched = version_compare($phpVersion, '8.4.27', '>=');
|
||||||
|
} elseif (version_compare($phpVersion, '8.5.0', '>=')) {
|
||||||
|
$patchRequiredVersion = '8.5.12';
|
||||||
|
$isPhpPatched = version_compare($phpVersion, '8.5.12', '>=');
|
||||||
|
} else {
|
||||||
|
// PHP < 8.2 (End of Life)
|
||||||
|
$patchRequiredVersion = '8.2.35+';
|
||||||
|
$isPhpPatched = false;
|
||||||
|
$isSupportedPhpBranch = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbVersion = 'Onbekend';
|
||||||
|
try {
|
||||||
|
$dbVersion = (string)$db->query("SELECT VERSION()")->fetchColumn() ?: 'Onbekend';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$dbVersion = 'Niet beschikbaar';
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect URI
|
// Redirect URI
|
||||||
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
|
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
|
||||||
|
|
||||||
@@ -1973,6 +2013,94 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
|
|
||||||
<!-- Tab 6: Systeem & Database (inclusief SMTP Mail Server & AI Co-Pilot) -->
|
<!-- Tab 6: Systeem & Database (inclusief SMTP Mail Server & AI Co-Pilot) -->
|
||||||
<div id="tab-pane-system" class="admin-tab-pane space-y-8 hidden">
|
<div id="tab-pane-system" class="admin-tab-pane space-y-8 hidden">
|
||||||
|
<!-- PHP Runtime & Serverstatus (Beveiliging & Environment) -->
|
||||||
|
<div class="fociz-card p-6 sm:p-7 space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||||
|
<div class="flex items-center space-x-3.5">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/60 flex items-center justify-center text-violet-600 dark:text-violet-400 font-bold shadow-xs">
|
||||||
|
<i data-lucide="cpu" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">PHP Runtime & Serverstatus</h2>
|
||||||
|
<span class="px-2 py-0.5 rounded-md bg-violet-100 dark:bg-violet-900/50 text-violet-800 dark:text-violet-300 font-mono text-xs font-bold">
|
||||||
|
PHP <?= htmlspecialchars($phpVersion) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">Actuele PHP versie, beveiligingsstatus, server interface en resource limieten</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if ($isPhpPatched): ?>
|
||||||
|
<span class="px-3 py-1.5 rounded-full bg-teal-50 dark:bg-teal-950/40 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-xs font-bold inline-flex items-center space-x-1.5 self-start sm:self-auto">
|
||||||
|
<i data-lucide="shield-check" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
<span>PHP Beveiliging: Up-to-date</span>
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="px-3 py-1.5 rounded-full bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800/60 text-xs font-bold inline-flex items-center space-x-1.5 self-start sm:self-auto">
|
||||||
|
<i data-lucide="alert-triangle" class="w-4 h-4 flex-shrink-0 text-amber-500"></i>
|
||||||
|
<span>Update Aanbevolen (Min. <?= htmlspecialchars($patchRequiredVersion) ?>)</span>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beveiligingsalert / Patchadvies -->
|
||||||
|
<?php if (!$isPhpPatched): ?>
|
||||||
|
<div class="p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-900 dark:text-amber-200 text-xs space-y-2">
|
||||||
|
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
|
||||||
|
<i data-lucide="alert-circle" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
<span>Beveiligingsupdate Beschikbaar (CVE-2026-109618 / CVE-2026-109619)</span>
|
||||||
|
</div>
|
||||||
|
<p class="leading-relaxed">
|
||||||
|
Er is recent een kritieke PHP-beveiligingsupdate uitgebracht. Jouw server draait momenteel op <strong class="font-mono"><?= htmlspecialchars($phpVersion) ?></strong>.
|
||||||
|
<?php if (!$isSupportedPhpBranch): ?>
|
||||||
|
Deze PHP-versie is end-of-life. We adviseren je webhosting/server zo spoedig mogelijk te upgraden naar minimaal <strong>PHP 8.2.35</strong> of hoger.
|
||||||
|
<?php else: ?>
|
||||||
|
We adviseren om via het hosting controlepaneel (zoals DirectAdmin of cPanel) of via je serverbeheerder de PHP-versie te updaten naar minimaal <strong class="font-mono"><?= htmlspecialchars($patchRequiredVersion) ?></strong>.
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="p-3.5 rounded-xl bg-teal-500/10 border border-teal-500/20 text-teal-900 dark:text-teal-200 text-xs flex items-center space-x-2.5">
|
||||||
|
<i data-lucide="shield-check" class="w-4 h-4 flex-shrink-0 text-teal-600 dark:text-teal-400"></i>
|
||||||
|
<span>Deze PHP-versie (<strong class="font-mono"><?= htmlspecialchars($phpVersion) ?></strong>) bevat de recentste beveiligingspatches tegen de bekende PHP2Shell kwetsbaarheden.</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Grid met systeemeigenschappen -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs">
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">PHP SAPI / Interface</span>
|
||||||
|
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($phpSapi) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">Geheugenlimiet (Memory)</span>
|
||||||
|
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($phpMemoryLimit) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">Max Execution Time</span>
|
||||||
|
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($phpMaxExecution) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">Database Versie</span>
|
||||||
|
<span class="font-mono font-bold text-slate-900 dark:text-white truncate block" title="<?= htmlspecialchars($dbVersion) ?>"><?= htmlspecialchars($dbVersion) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">Zend OPcache:</span>
|
||||||
|
<span class="font-semibold <?= $phpOpcacheEnabled ? 'text-teal-600 dark:text-teal-400' : 'text-slate-500 dark:text-slate-400' ?> flex items-center space-x-1">
|
||||||
|
<i data-lucide="<?= $phpOpcacheEnabled ? 'check-circle-2' : 'circle' ?>" class="w-3.5 h-3.5"></i>
|
||||||
|
<span><?= $phpOpcacheEnabled ? 'Ingeschakeld' : 'Niet actief' ?></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">OpenSSL Bibliotheek:</span>
|
||||||
|
<span class="font-mono text-slate-700 dark:text-slate-300 truncate max-w-[220px]" title="<?= htmlspecialchars($phpOpensslVersion) ?>"><?= htmlspecialchars($phpOpensslVersion) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 5. SMTP Mail Server & Notificaties (Release v1.6) -->
|
<!-- 5. SMTP Mail Server & Notificaties (Release v1.6) -->
|
||||||
<div class="fociz-card p-6 sm:p-7 space-y-6">
|
<div class="fociz-card p-6 sm:p-7 space-y-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||||
@@ -2126,10 +2254,9 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
||||||
<select name="ai_model" id="admin_ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
<select name="ai_model" id="admin_ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
<optgroup label="Google Gemini (Aanbevolen)">
|
<optgroup label="Google Gemini (Aanbevolen)">
|
||||||
<option value="gemini-3.6-flash" <?= ($aiSettings['ai_model'] === 'gemini-3.6-flash' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini') ? 'selected' : '' ?>>Google Gemini 3.6 Flash (Aanbevolen, Snel & Nieuwste)</option>
|
<option value="gemini-2.0-flash" <?= ($aiSettings['ai_model'] === 'gemini-2.0-flash' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini' || $aiSettings['ai_model'] === 'gemini-3.6-flash') ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
|
||||||
<option value="gemini-1.5-pro" <?= $aiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Slim & Uitgebreid)</option>
|
|
||||||
<option value="gemini-2.0-flash" <?= $aiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash</option>
|
|
||||||
<option value="gemini-1.5-flash" <?= $aiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
<option value="gemini-1.5-flash" <?= $aiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
||||||
|
<option value="gemini-1.5-pro" <?= $aiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Slim & Uitgebreid)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="OpenAI">
|
<optgroup label="OpenAI">
|
||||||
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
||||||
@@ -2208,7 +2335,7 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
|
|
||||||
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
|
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
|
||||||
<span>Database Versie</span>
|
<span>Database Versie</span>
|
||||||
<span class="text-slate-500 dark:text-slate-400 font-mono text-[11px]">MariaDB 10.x</span>
|
<span class="text-slate-500 dark:text-slate-400 font-mono text-[11px] truncate max-w-[200px]" title="<?= htmlspecialchars($dbVersion) ?>"><?= htmlspecialchars($dbVersion) ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
|
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
|
||||||
|
|||||||
+9
-4
@@ -142,9 +142,14 @@ try {
|
|||||||
// Fallback: reguliere generatie
|
// Fallback: reguliere generatie
|
||||||
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
|
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
|
||||||
|
|
||||||
if (!$aiRes['success']) {
|
$ideas = $aiRes['ideas'] ?? [];
|
||||||
echo json_encode(['error' => $aiRes['error']]);
|
if (empty($ideas)) {
|
||||||
exit;
|
$ideas = [
|
||||||
|
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
|
||||||
|
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||||
|
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||||
|
"Feedback- en validatielus inrichten met eindgebruikers",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$insertStmt = $db->prepare("
|
$insertStmt = $db->prepare("
|
||||||
@@ -152,7 +157,7 @@ try {
|
|||||||
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
|
||||||
");
|
");
|
||||||
|
|
||||||
foreach ($aiRes['ideas'] as $ideaText) {
|
foreach ($ideas as $ideaText) {
|
||||||
$ideaText = trim($ideaText);
|
$ideaText = trim($ideaText);
|
||||||
if (!empty($ideaText)) {
|
if (!empty($ideaText)) {
|
||||||
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
$insertStmt->execute([$sessionId, $ideaText, $authorName]);
|
||||||
|
|||||||
+413
@@ -87,6 +87,419 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1d. Snelle Bullet Notities ophalen
|
||||||
|
if ($action === 'get_bullets') {
|
||||||
|
$date = $_GET['date'] ?? $today;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT b.*, t.time_block_start as live_start, t.time_block_end as live_end, t.status as task_status
|
||||||
|
FROM dashboard_bullets b
|
||||||
|
LEFT JOIN tasks t ON b.scheduled_task_id = t.id
|
||||||
|
WHERE b.user_id = ? AND b.plan_date = ?
|
||||||
|
ORDER BY b.position ASC, b.id ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$user['id'], $date]);
|
||||||
|
$bullets = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Tel openstaande items van eerdere dagen
|
||||||
|
$stmtPast = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM dashboard_bullets
|
||||||
|
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
|
||||||
|
");
|
||||||
|
$stmtPast->execute([$user['id'], $date]);
|
||||||
|
$pastOpenCount = (int)$stmtPast->fetchColumn();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'bullets' => $bullets,
|
||||||
|
'past_open_count' => $pastOpenCount
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1e. Bullet notitie toevoegen
|
||||||
|
if ($action === 'add_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$date = !empty($data['date']) ? trim($data['date']) : $today;
|
||||||
|
$content = trim($data['content'] ?? '');
|
||||||
|
|
||||||
|
if ($content === '') {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Notitie mag niet leeg zijn']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bepaal positie
|
||||||
|
$stmtPos = $db->prepare("SELECT COALESCE(MAX(position), 0) + 1 FROM dashboard_bullets WHERE user_id = ? AND plan_date = ?");
|
||||||
|
$stmtPos->execute([$user['id'], $date]);
|
||||||
|
$pos = (int)$stmtPos->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO dashboard_bullets (user_id, plan_date, content, position)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([$user['id'], $date, $content, $pos]);
|
||||||
|
$bulletId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
$stmtFetch = $db->prepare("SELECT * FROM dashboard_bullets WHERE id = ?");
|
||||||
|
$stmtFetch->execute([$bulletId]);
|
||||||
|
$newBullet = $stmtFetch->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'bullet' => $newBullet
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1f. Bullet notitie status wisselen (afvinken / openen)
|
||||||
|
if ($action === 'toggle_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$isCompleted = !empty($data['is_completed']) ? 1 : 0;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE dashboard_bullets SET is_completed = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmt->execute([$isCompleted, $bulletId, $user['id']]);
|
||||||
|
|
||||||
|
// Als er een gekoppelde taak is, werk de status daarvan ook bij
|
||||||
|
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtCheck->fetchColumn();
|
||||||
|
|
||||||
|
if ($taskId > 0) {
|
||||||
|
$taskStatus = $isCompleted ? 'DONE' : 'TODO';
|
||||||
|
$stmtTask = $db->prepare("UPDATE tasks SET is_completed = ?, status = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$isCompleted, $taskStatus, $taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'is_completed' => $isCompleted]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1g. Bullet notitie bewerken
|
||||||
|
if ($action === 'update_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$content = trim($data['content'] ?? '');
|
||||||
|
|
||||||
|
if ($content === '') {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Inhoud mag niet leeg zijn']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE dashboard_bullets SET content = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmt->execute([$content, $bulletId, $user['id']]);
|
||||||
|
|
||||||
|
// Indien gekoppeld aan taak, werk ook taaktitel bij
|
||||||
|
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtCheck->fetchColumn();
|
||||||
|
if ($taskId > 0) {
|
||||||
|
$stmtTask = $db->prepare("UPDATE tasks SET title = ? WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$content, $taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1h. Bullet notitie verwijderen
|
||||||
|
if ($action === 'delete_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$deleteTask = !empty($data['delete_task']);
|
||||||
|
|
||||||
|
// Check voor gekoppelde taak
|
||||||
|
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtCheck->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtCheck->fetchColumn();
|
||||||
|
|
||||||
|
if ($taskId > 0 && $deleteTask) {
|
||||||
|
// Verwijder eventuele Outlook afspraak
|
||||||
|
$stmtTask = $db->prepare("SELECT external_id FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$taskId, $user['id']]);
|
||||||
|
$externalId = $stmtTask->fetchColumn();
|
||||||
|
if (!empty($externalId) && function_exists('microsoft_delete_event')) {
|
||||||
|
try {
|
||||||
|
microsoft_delete_event($user['id'], $externalId);
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
$stmtDelTask = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtDelTask->execute([$taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("DELETE FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmt->execute([$bulletId, $user['id']]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1i. Bullet inplannen in agenda / vrij blok
|
||||||
|
if ($action === 'schedule_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
$date = !empty($data['date']) ? trim($data['date']) : $today;
|
||||||
|
$title = !empty($data['title']) ? trim($data['title']) : '';
|
||||||
|
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : '';
|
||||||
|
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : '';
|
||||||
|
$duration = max(5, (int)($data['duration_minutes'] ?? 30));
|
||||||
|
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
|
||||||
|
$syncOutlook = !empty($data['sync_to_outlook']);
|
||||||
|
|
||||||
|
// Haal bullet op
|
||||||
|
$stmtB = $db->prepare("SELECT * FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtB->execute([$bulletId, $user['id']]);
|
||||||
|
$bullet = $stmtB->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$bullet) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Notitie niet gevonden']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$title = $bullet['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($startTime)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Kies een starttijd voor het agendablok']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($endTime)) {
|
||||||
|
$dtEnd = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||||
|
$dtEnd->modify("+{$duration} minutes");
|
||||||
|
$endTime = $dtEnd->format('H:i');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zorg dat daily_plan bestaat
|
||||||
|
$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 = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
$source = 'FOCIZ';
|
||||||
|
$externalId = null;
|
||||||
|
$externalUrl = null;
|
||||||
|
$outlookSyncMsg = null;
|
||||||
|
|
||||||
|
// Microsoft Outlook synchronisatie
|
||||||
|
if ($syncOutlook) {
|
||||||
|
require_once __DIR__ . '/../includes/integrations/microsoft.php';
|
||||||
|
try {
|
||||||
|
$msResult = microsoft_create_event($user['id'], [
|
||||||
|
'subject' => $title,
|
||||||
|
'description' => "Ingepland vanuit Todo's & Notities",
|
||||||
|
'date' => $date,
|
||||||
|
'start_time' => $startTime,
|
||||||
|
'end_time' => $endTime,
|
||||||
|
'duration_minutes' => $duration,
|
||||||
|
]);
|
||||||
|
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
|
||||||
|
$source = 'OUTLOOK';
|
||||||
|
$externalId = $msResult['event_id'];
|
||||||
|
$externalUrl = $msResult['web_link'] ?? null;
|
||||||
|
$outlookSyncMsg = 'Succesvol gesynchroniseerd met Microsoft Outlook agenda.';
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskId = (int)($bullet['scheduled_task_id'] ?? 0);
|
||||||
|
if ($taskId > 0) {
|
||||||
|
// Update bestaande taak
|
||||||
|
$stmtUpdateTask = $db->prepare("
|
||||||
|
UPDATE tasks
|
||||||
|
SET daily_plan_id = ?, title = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?, source = ?, external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url)
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
");
|
||||||
|
$stmtUpdateTask->execute([$planId, $title, $priority, $startTime, $endTime, $duration, $source, $externalId, $externalUrl, $taskId, $user['id']]);
|
||||||
|
} else {
|
||||||
|
// Nieuwe taak aanmaken
|
||||||
|
$stmtInsertTask = $db->prepare("
|
||||||
|
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
|
||||||
|
VALUES (?, ?, ?, 'Ingepland vanuit Todo\'s & Notities', ?, ?, ?, ?, 'TODO', ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmtInsertTask->execute([$user['id'], $planId, $title, $priority, $startTime, $endTime, $duration, $source, $externalId, $externalUrl]);
|
||||||
|
$taskId = (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update de bullet record
|
||||||
|
$stmtUpdateB = $db->prepare("
|
||||||
|
UPDATE dashboard_bullets
|
||||||
|
SET scheduled_task_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
");
|
||||||
|
$stmtUpdateB->execute([$taskId, $date, $startTime, $endTime, $bulletId, $user['id']]);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'task_id' => $taskId,
|
||||||
|
'scheduled_date' => $date,
|
||||||
|
'scheduled_start_time' => $startTime,
|
||||||
|
'scheduled_end_time' => $endTime,
|
||||||
|
'outlook_message' => $outlookSyncMsg
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1j. Bullet uit agenda halen
|
||||||
|
if ($action === 'unschedule_bullet') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$bulletId = (int)($data['bullet_id'] ?? 0);
|
||||||
|
|
||||||
|
$stmtB = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtB->execute([$bulletId, $user['id']]);
|
||||||
|
$taskId = (int)$stmtB->fetchColumn();
|
||||||
|
|
||||||
|
if ($taskId > 0) {
|
||||||
|
$stmtTask = $db->prepare("SELECT external_id FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtTask->execute([$taskId, $user['id']]);
|
||||||
|
$externalId = $stmtTask->fetchColumn();
|
||||||
|
if (!empty($externalId) && function_exists('microsoft_delete_event')) {
|
||||||
|
try {
|
||||||
|
microsoft_delete_event($user['id'], $externalId);
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
$stmtDel = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
|
||||||
|
$stmtDel->execute([$taskId, $user['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtUpdate = $db->prepare("
|
||||||
|
UPDATE dashboard_bullets
|
||||||
|
SET scheduled_task_id = NULL, scheduled_date = NULL, scheduled_start_time = NULL, scheduled_end_time = NULL
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
");
|
||||||
|
$stmtUpdate->execute([$bulletId, $user['id']]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1k. Openstaande bullets van eerdere dagen overnemen naar geselecteerde dag
|
||||||
|
if ($action === 'carryover_bullets') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$targetDate = !empty($data['target_date']) ? trim($data['target_date']) : $today;
|
||||||
|
|
||||||
|
// Verplaats alle openstaande notities van eerdere dagen naar targetDate
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE dashboard_bullets
|
||||||
|
SET plan_date = ?
|
||||||
|
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
|
||||||
|
");
|
||||||
|
$stmt->execute([$targetDate, $user['id'], $targetDate]);
|
||||||
|
$movedCount = $stmt->rowCount();
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'moved_count' => $movedCount]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1l. Haal alle beschikbare vrije agendablokken op voor een specifieke dag
|
||||||
|
if ($action === 'get_day_free_slots') {
|
||||||
|
$targetDate = $_GET['date'] ?? $today;
|
||||||
|
$duration = max(5, (int)($_GET['duration_minutes'] ?? 30));
|
||||||
|
|
||||||
|
$workdaySettings = get_workday_settings();
|
||||||
|
$workdayStartStr = $workdaySettings['workday_start'] ?? '08:30';
|
||||||
|
$workdayEndStr = $workdaySettings['workday_end'] ?? '17:00';
|
||||||
|
$lunchStartStr = $workdaySettings['lunch_start'] ?? '12:30';
|
||||||
|
$lunchEndStr = $workdaySettings['lunch_end'] ?? '13:00';
|
||||||
|
|
||||||
|
$toMins = function(string $timeStr): int {
|
||||||
|
$parts = explode(':', trim($timeStr));
|
||||||
|
return ((int)($parts[0] ?? 0) * 60) + (int)($parts[1] ?? 0);
|
||||||
|
};
|
||||||
|
$toTimeStr = function(int $mins): string {
|
||||||
|
$h = (int)floor($mins / 60);
|
||||||
|
$m = $mins % 60;
|
||||||
|
return sprintf('%02d:%02d', $h, $m);
|
||||||
|
};
|
||||||
|
|
||||||
|
$workdayStartMin = $toMins($workdayStartStr);
|
||||||
|
$workdayEndMin = $toMins($workdayEndStr);
|
||||||
|
$lunchStartMin = $toMins($lunchStartStr);
|
||||||
|
$lunchEndMin = $toMins($lunchEndStr);
|
||||||
|
|
||||||
|
$tz = new DateTimeZone('Europe/Amsterdam');
|
||||||
|
$now = new DateTime('now', $tz);
|
||||||
|
$isToday = ($targetDate === $now->format('Y-m-d'));
|
||||||
|
|
||||||
|
// Starttijd afronden naar kwartier
|
||||||
|
$earliestMin = $workdayStartMin;
|
||||||
|
if ($isToday) {
|
||||||
|
$nowMin = ((int)$now->format('G') * 60) + (int)$now->format('i');
|
||||||
|
if ($nowMin % 15 !== 0) {
|
||||||
|
$nowMin = (int)(ceil($nowMin / 15) * 15);
|
||||||
|
}
|
||||||
|
$earliestMin = max($workdayStartMin, $nowMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
$busyIntervals = [];
|
||||||
|
if ($lunchEndMin > $lunchStartMin) {
|
||||||
|
$busyIntervals[] = [$lunchStartMin, $lunchEndMin];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken op deze dag
|
||||||
|
$stmtTasks = $db->prepare("
|
||||||
|
SELECT time_block_start, time_block_end, duration_minutes
|
||||||
|
FROM tasks t
|
||||||
|
INNER JOIN daily_plans dp ON t.daily_plan_id = dp.id
|
||||||
|
WHERE t.user_id = ? AND dp.plan_date = ? AND t.time_block_start IS NOT NULL AND t.time_block_start != '' AND t.status != 'CANCELLED'
|
||||||
|
");
|
||||||
|
$stmtTasks->execute([$user['id'], $targetDate]);
|
||||||
|
while ($t = $stmtTasks->fetch()) {
|
||||||
|
$tStart = $toMins($t['time_block_start']);
|
||||||
|
$tDur = (int)($t['duration_minutes'] ?: 30);
|
||||||
|
$tEnd = !empty($t['time_block_end']) ? $toMins($t['time_block_end']) : ($tStart + $tDur);
|
||||||
|
if ($tEnd > $tStart) {
|
||||||
|
$busyIntervals[] = [$tStart, $tEnd];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outlook afspraken
|
||||||
|
if (function_exists('microsoft_fetch_events')) {
|
||||||
|
try {
|
||||||
|
$outlookEvents = microsoft_fetch_events($user['id'], $targetDate, false);
|
||||||
|
foreach ($outlookEvents as $oe) {
|
||||||
|
if (!empty($oe['is_all_day'])) continue;
|
||||||
|
if (empty($oe['start']) || empty($oe['end'])) continue;
|
||||||
|
$oStart = $toMins($oe['start']);
|
||||||
|
$oEnd = $toMins($oe['end']);
|
||||||
|
if ($oEnd > $oStart) {
|
||||||
|
$busyIntervals[] = [$oStart, $oEnd];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$freeSlots = [];
|
||||||
|
for ($slotStart = $earliestMin; ($slotStart + $duration) <= $workdayEndMin; $slotStart += 15) {
|
||||||
|
$slotEnd = $slotStart + $duration;
|
||||||
|
$collision = false;
|
||||||
|
foreach ($busyIntervals as [$bStart, $bEnd]) {
|
||||||
|
if ($slotStart < $bEnd && $slotEnd > $bStart) {
|
||||||
|
$collision = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$collision) {
|
||||||
|
$freeSlots[] = [
|
||||||
|
'start' => $toTimeStr($slotStart),
|
||||||
|
'end' => $toTimeStr($slotEnd),
|
||||||
|
'label' => $toTimeStr($slotStart) . ' - ' . $toTimeStr($slotEnd)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'date' => $targetDate,
|
||||||
|
'duration_minutes' => $duration,
|
||||||
|
'free_slots' => $freeSlots
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Taak toevoegen
|
// 2. Taak toevoegen
|
||||||
if ($action === 'add_task') {
|
if ($action === 'add_task') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|||||||
+82
-9
@@ -49,11 +49,40 @@ $step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
|
|||||||
|
|
||||||
// Terug naar stap 1
|
// Terug naar stap 1
|
||||||
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
if (isset($_GET['action']) && $_GET['action'] === 'restart') {
|
||||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name'], $_SESSION['pending_new_ip']);
|
unset(
|
||||||
|
$_SESSION['pending_2fa_user_id'],
|
||||||
|
$_SESSION['pending_2fa_user_email'],
|
||||||
|
$_SESSION['pending_2fa_user_name'],
|
||||||
|
$_SESSION['pending_new_ip'],
|
||||||
|
$_SESSION['pending_new_ip_mail_sent']
|
||||||
|
);
|
||||||
header('Location: /auth/login.php');
|
header('Location: /auth/login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificatiemail opnieuw verzenden bij nieuw IP
|
||||||
|
if (isset($_GET['action']) && $_GET['action'] === 'resend_ip_mail' && !empty($_SESSION['pending_2fa_user_id']) && !empty($_SESSION['pending_new_ip'])) {
|
||||||
|
$resendUserId = (int)$_SESSION['pending_2fa_user_id'];
|
||||||
|
$resendRateKey = "resend_ip_mail_{$resendUserId}";
|
||||||
|
$resendLimit = fociz_check_rate_limit($resendRateKey, 2, 60);
|
||||||
|
|
||||||
|
if (!$resendLimit['allowed']) {
|
||||||
|
$error = "Wacht even voordat u opnieuw een e-mail aanvraagt (over " . ceil($resendLimit['retry_after']) . " seconden).";
|
||||||
|
} else {
|
||||||
|
fociz_hit_rate_limit($resendRateKey, 60);
|
||||||
|
$resendEmail = $_SESSION['pending_2fa_user_email'] ?? '';
|
||||||
|
$resendName = $_SESSION['pending_2fa_user_name'] ?? '';
|
||||||
|
$resendIp = $_SESSION['pending_new_ip'];
|
||||||
|
$sent = fociz_send_new_ip_verification($resendUserId, $resendEmail, $resendName, $resendIp);
|
||||||
|
if ($sent) {
|
||||||
|
$infoMessage = "Er is een nieuwe verificatielink verstuurd naar " . htmlspecialchars($resendEmail) . ".";
|
||||||
|
$_SESSION['pending_new_ip_mail_sent'] = true;
|
||||||
|
} else {
|
||||||
|
$error = "De mailserver kon de e-mail niet direct afleveren. U kunt echter direct hieronder inloggen met uw 2FA Authenticator code om deze locatie te autoriseren.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST Verwerking
|
// POST Verwerking
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$db = get_db_connection();
|
$db = get_db_connection();
|
||||||
@@ -87,15 +116,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
|
||||||
|
|
||||||
if (!$isIpKnown) {
|
if (!$isIpKnown) {
|
||||||
// Stuur verificatie-mail en informeer gebruiker
|
// Stuur verificatie-mail en log audit challenge
|
||||||
fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
|
$mailSent = fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
|
||||||
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp], 'PENDING', $user['email']);
|
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
|
||||||
$error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan.";
|
|
||||||
} else {
|
// Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
|
||||||
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
|
// Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
|
||||||
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||||
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||||
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||||
|
$_SESSION['pending_new_ip'] = $clientIp;
|
||||||
|
$_SESSION['pending_new_ip_mail_sent'] = $mailSent;
|
||||||
|
$step = 2;
|
||||||
|
} else {
|
||||||
|
// Bekende locatie: direct door naar 2FA
|
||||||
|
$_SESSION['pending_2fa_user_id'] = $user['id'];
|
||||||
|
$_SESSION['pending_2fa_user_email'] = $user['email'];
|
||||||
|
$_SESSION['pending_2fa_user_name'] = $user['name'];
|
||||||
|
unset($_SESSION['pending_new_ip'], $_SESSION['pending_new_ip_mail_sent']);
|
||||||
$step = 2;
|
$step = 2;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -161,8 +199,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
fociz_clear_rate_limit($ipRateKey);
|
fociz_clear_rate_limit($ipRateKey);
|
||||||
|
|
||||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
||||||
|
// Autoriseert het IP-adres definitief in user_known_ips
|
||||||
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
||||||
|
|
||||||
|
$wasNewIp = !empty($_SESSION['pending_new_ip']);
|
||||||
|
if ($wasNewIp) {
|
||||||
|
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_VERIFIED_VIA_2FA', 'user_known_ips', (int)$user['id'], ['ip' => $clientIp], 'SUCCESS', $user['email']);
|
||||||
|
}
|
||||||
|
|
||||||
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
||||||
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
||||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
@@ -189,9 +233,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||||
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
||||||
|
|
||||||
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA'], 'SUCCESS', $user['email']);
|
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
|
||||||
|
|
||||||
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
|
unset(
|
||||||
|
$_SESSION['pending_2fa_user_id'],
|
||||||
|
$_SESSION['pending_2fa_user_email'],
|
||||||
|
$_SESSION['pending_2fa_user_name'],
|
||||||
|
$_SESSION['pending_new_ip'],
|
||||||
|
$_SESSION['pending_new_ip_mail_sent']
|
||||||
|
);
|
||||||
|
|
||||||
header('Location: /planner.php');
|
header('Location: /planner.php');
|
||||||
exit;
|
exit;
|
||||||
@@ -315,6 +365,29 @@ $pageTitle = "Fociz — Inloggen";
|
|||||||
<!-- STAP 2: 2FA TOTP Code -->
|
<!-- STAP 2: 2FA TOTP Code -->
|
||||||
<form method="POST" action="/auth/login.php" class="space-y-5">
|
<form method="POST" action="/auth/login.php" class="space-y-5">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['pending_new_ip'])): ?>
|
||||||
|
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-900 dark:text-amber-200 text-xs space-y-2 text-left">
|
||||||
|
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
|
||||||
|
<i data-lucide="shield-alert" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
<span>Nieuwe inloglocatie gedetecteerd</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300/90">
|
||||||
|
U logt in vanaf een nieuw netwerkadres (<code class="font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded text-[10px]"><?= htmlspecialchars($_SESSION['pending_new_ip']) ?></code>).
|
||||||
|
Voer uw 2FA Authenticator code in om uw identiteit te bevestigen en deze locatie automatisch te autoriseren.
|
||||||
|
</p>
|
||||||
|
<div class="pt-1.5 flex items-center justify-between text-[11px] border-t border-amber-200/60 dark:border-amber-800/40 text-amber-700 dark:text-amber-400">
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<i data-lucide="mail-check" class="w-3.5 h-3.5"></i>
|
||||||
|
<span><?= !empty($_SESSION['pending_new_ip_mail_sent']) ? 'Verificatiemail verzonden' : '2FA verificatie actief' ?></span>
|
||||||
|
</span>
|
||||||
|
<a href="/auth/login.php?action=resend_ip_mail" class="font-bold underline hover:text-amber-900 dark:hover:text-amber-200 cursor-pointer">
|
||||||
|
Mail opnieuw sturen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
|
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
|
||||||
<i data-lucide="key" class="w-6 h-6"></i>
|
<i data-lucide="key" class="w-6 h-6"></i>
|
||||||
|
|||||||
+162
-57
@@ -1326,8 +1326,8 @@ async function submitConvertToRoadmap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
|
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
|
||||||
async function aiGenerateSessionBoard(btn, silent = false) {
|
async function aiGenerateSessionBoard(btn, silent = false, attempt = 1, maxAttempts = 3) {
|
||||||
if (!silent) {
|
if (!silent && attempt === 1) {
|
||||||
const hasBriefing = <?= !empty($session['description']) ? 'true' : 'false' ?>;
|
const hasBriefing = <?= !empty($session['description']) ? 'true' : 'false' ?>;
|
||||||
const confirmMsg = hasBriefing
|
const confirmMsg = hasBriefing
|
||||||
? 'Wil je dat AI Co-Pilot op basis van de sessie briefing direct een complete set post-its genereert voor Ideeën, Knelpunten, Oplossingen en Quick Wins?'
|
? 'Wil je dat AI Co-Pilot op basis van de sessie briefing direct een complete set post-its genereert voor Ideeën, Knelpunten, Oplossingen en Quick Wins?'
|
||||||
@@ -1335,10 +1335,18 @@ async function aiGenerateSessionBoard(btn, silent = false) {
|
|||||||
if (!confirm(confirmMsg)) return;
|
if (!confirm(confirmMsg)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const origHtml = btn ? btn.innerHTML : '';
|
const origHtml = btn ? (btn.dataset.origHtml || btn.innerHTML) : '';
|
||||||
|
if (btn && !btn.dataset.origHtml) {
|
||||||
|
btn.dataset.origHtml = origHtml;
|
||||||
|
}
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
if (attempt > 1) {
|
||||||
|
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Piekdrukte, wachtrij (${attempt}/${maxAttempts})...</span></span>`;
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
|
||||||
|
}
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,17 +1363,27 @@ async function aiGenerateSessionBoard(btn, silent = false) {
|
|||||||
window.history.replaceState({}, document.title, cleanUrl);
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
|
const err = (data.error || '').toLowerCase();
|
||||||
|
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
|
||||||
|
if (attempt < maxAttempts && isOverloaded) {
|
||||||
|
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert(data.error || 'Kon geen post-its genereren via AI');
|
alert(data.error || 'Kon geen post-its genereren via AI');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = btn.dataset.origHtml || origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert('Netwerkfout bij aanroepen van AI: ' + err.message);
|
alert('Netwerkfout bij aanroepen van AI: ' + err.message);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = btn.dataset.origHtml || origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}
|
}
|
||||||
@@ -1421,57 +1439,87 @@ async function aiSuggestRoadmapMeta(btn) {
|
|||||||
if (!firstContent) return;
|
if (!firstContent) return;
|
||||||
|
|
||||||
const origHtml = btn ? btn.innerHTML : '';
|
const origHtml = btn ? btn.innerHTML : '';
|
||||||
if (btn) {
|
let attempt = 0;
|
||||||
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Analyseren...</span></span>';
|
const maxAttempts = 3;
|
||||||
btn.disabled = true;
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const executeRequest = async () => {
|
||||||
const res = await fetch('/api/ai.php?action=summarize_to_roadmap', {
|
attempt++;
|
||||||
method: 'POST',
|
if (btn) {
|
||||||
headers: {'Content-Type': 'application/json'},
|
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>${attempt > 1 ? `Wachtrij (${attempt}/${maxAttempts})...` : 'Analyseren...'}</span></span>`;
|
||||||
body: JSON.stringify({
|
btn.disabled = true;
|
||||||
content: firstContent,
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
session_title: <?= json_encode($session['title']) ?>
|
}
|
||||||
})
|
|
||||||
});
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch('/api/ai.php?action=summarize_to_roadmap', {
|
||||||
if (data.success && data.item) {
|
method: 'POST',
|
||||||
if (data.item.quarter && document.getElementById('roadmapQuarter')) {
|
headers: {'Content-Type': 'application/json'},
|
||||||
document.getElementById('roadmapQuarter').value = data.item.quarter;
|
body: JSON.stringify({
|
||||||
|
content: firstContent,
|
||||||
|
session_title: <?= json_encode($session['title']) ?>
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.item) {
|
||||||
|
if (data.item.quarter && document.getElementById('roadmapQuarter')) {
|
||||||
|
document.getElementById('roadmapQuarter').value = data.item.quarter;
|
||||||
|
}
|
||||||
|
if (data.item.type && document.getElementById('roadmapType')) {
|
||||||
|
document.getElementById('roadmapType').value = data.item.type;
|
||||||
|
}
|
||||||
|
if (data.item.priority && document.getElementById('roadmapPriority')) {
|
||||||
|
document.getElementById('roadmapPriority').value = data.item.priority;
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>' + (data.is_fallback ? 'Ingevuld (Standaard)' : 'Ingevuld!') + '</span>';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = (data.error || '').toLowerCase();
|
||||||
|
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
|
||||||
|
|
||||||
|
if (attempt < maxAttempts && isOverloaded) {
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="clock" class="w-3.5 h-3.5"></i><span>Piekdrukte, wachtrij...</span></span>`;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
setTimeout(executeRequest, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: vul standaardwaarden in zodat het omzetten altijd kan doorgaan
|
||||||
|
if (document.getElementById('roadmapQuarter') && !document.getElementById('roadmapQuarter').value) {
|
||||||
|
document.getElementById('roadmapQuarter').value = 'Q3';
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld (Standaard)</span>';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (data.item.type && document.getElementById('roadmapType')) {
|
} catch(err) {
|
||||||
document.getElementById('roadmapType').value = data.item.type;
|
if (attempt < maxAttempts) {
|
||||||
}
|
setTimeout(executeRequest, 2000);
|
||||||
if (data.item.priority && document.getElementById('roadmapPriority')) {
|
} else {
|
||||||
document.getElementById('roadmapPriority').value = data.item.priority;
|
if (btn) {
|
||||||
}
|
|
||||||
if (btn) {
|
|
||||||
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld!</span>';
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.innerHTML = origHtml;
|
btn.innerHTML = origHtml;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
}, 1800);
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert(data.error || 'Kon geen AI advies ophalen');
|
|
||||||
if (btn) {
|
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
btn.disabled = false;
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(err) {
|
};
|
||||||
alert('Netwerkfout bij aanroepen van AI');
|
|
||||||
if (btn) {
|
await executeRequest();
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
btn.disabled = false;
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1535,7 +1583,16 @@ async function regenerateBreakdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAiBreakdown(content) {
|
async function fetchAiBreakdown(content, attempt = 1, maxAttempts = 3) {
|
||||||
|
const loadingEl = document.getElementById('breakdownLoading');
|
||||||
|
const loadingText = loadingEl ? loadingEl.querySelector('p') : null;
|
||||||
|
|
||||||
|
if (attempt > 1 && loadingText) {
|
||||||
|
loadingText.innerHTML = `<span class="font-bold text-amber-500">⏳ AI Model heeft piekdruk (high demand).</span><br><span class="text-xs text-slate-400">Aanvraag staat in de wachtrij en wordt opnieuw geprobeerd (poging ${attempt}/${maxAttempts})...</span>`;
|
||||||
|
} else if (loadingText) {
|
||||||
|
loadingText.innerHTML = 'AI ontleedt het idee in behapbare taken...';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai.php?action=breakdown_idea', {
|
const res = await fetch('/api/ai.php?action=breakdown_idea', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1551,10 +1608,46 @@ async function fetchAiBreakdown(content) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!data.success || !data.items || data.items.length === 0) {
|
if (!data.success || !data.items || data.items.length === 0) {
|
||||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
const err = (data.error || '').toLowerCase();
|
||||||
document.getElementById('breakdownError').classList.remove('hidden');
|
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
|
||||||
document.getElementById('breakdownErrorMsg').innerText = data.error || 'Geen taken gegenereerd door AI.';
|
|
||||||
return;
|
if (attempt < maxAttempts && isOverloaded) {
|
||||||
|
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful Fallback: zorg dat het omzetten van het idee altijd vlekkeloos doorgaat
|
||||||
|
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
|
||||||
|
data = {
|
||||||
|
success: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Onderzoek & specificatie: ' + cleanTitle,
|
||||||
|
description: 'Randvoorwaarden en functionele specificaties vastleggen.',
|
||||||
|
priority: 'P1',
|
||||||
|
estimated_minutes: 45,
|
||||||
|
color: 'indigo',
|
||||||
|
category: 'Onderzoek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Realisatie & uitwerking: ' + cleanTitle,
|
||||||
|
description: 'Implementatie en concrete uitvoering van het idee.',
|
||||||
|
priority: 'P2',
|
||||||
|
estimated_minutes: 90,
|
||||||
|
color: 'blue',
|
||||||
|
category: 'Frontend'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle,
|
||||||
|
description: 'Testen, feedback verwerken en afronden.',
|
||||||
|
priority: 'P2',
|
||||||
|
estimated_minutes: 30,
|
||||||
|
color: 'emerald',
|
||||||
|
category: 'Testing'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
summary: 'AI model had tijdelijke piekdruk; standaard deeltaken zijn direct klaargezet zodat je meteen kunt doorwerken.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
currentBreakdownItems = data.items.map((it, idx) => ({
|
currentBreakdownItems = data.items.map((it, idx) => ({
|
||||||
@@ -1576,9 +1669,21 @@ async function fetchAiBreakdown(content) {
|
|||||||
document.getElementById('breakdownResult').classList.remove('hidden');
|
document.getElementById('breakdownResult').classList.remove('hidden');
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
|
||||||
|
currentBreakdownItems = [
|
||||||
|
{ id: 1, selected: true, title: 'Onderzoek & specificatie: ' + cleanTitle, description: 'Randvoorwaarden en specificaties vastleggen.', priority: 'P1', estimated_minutes: 45, color: 'indigo', category: 'Onderzoek' },
|
||||||
|
{ id: 2, selected: true, title: 'Realisatie & uitwerking: ' + cleanTitle, description: 'Implementatie en concrete uitvoering van het idee.', priority: 'P2', estimated_minutes: 90, color: 'blue', category: 'Frontend' },
|
||||||
|
{ id: 3, selected: true, title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle, description: 'Testen, feedback verwerken en afronden.', priority: 'P2', estimated_minutes: 30, color: 'emerald', category: 'Testing' }
|
||||||
|
];
|
||||||
|
document.getElementById('breakdownSummaryText').innerText = 'AI service was tijdelijk onbereikbaar; concept-deeltaken zijn direct klaargezet.';
|
||||||
|
renderBreakdownTasks();
|
||||||
document.getElementById('breakdownLoading').classList.add('hidden');
|
document.getElementById('breakdownLoading').classList.add('hidden');
|
||||||
document.getElementById('breakdownError').classList.remove('hidden');
|
document.getElementById('breakdownResult').classList.remove('hidden');
|
||||||
document.getElementById('breakdownErrorMsg').innerText = 'Netwerkfout bij communicatie met AI: ' + err.message;
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.4",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"ext-pdo_mysql": "*",
|
"ext-pdo_mysql": "*",
|
||||||
"ext-mysqli": "*"
|
"ext-mysqli": "*"
|
||||||
|
|||||||
+9
-12
@@ -1,17 +1,14 @@
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# FOCIZ CONFIGURATIE — LIVE PRODUCTIE (www.fociz.nl)
|
# FOCIZ CONFIGURATIE (Centraal)
|
||||||
|
# De omgeving en database worden automatisch veilig bepaald op basis van het domein:
|
||||||
|
# - dev.fociz.nl / localhost => development (focizdev / focizdev_user)
|
||||||
|
# - www.fociz.nl / fociz.nl => live (fociz / focizlive_user)
|
||||||
|
# Optionele serverspecifieke overrides kunnen in de root /.env worden geplaatst.
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
# MariaDB Verbinding
|
# MariaDB Server
|
||||||
DB_HOST="192.168.2.33"
|
DB_HOST="192.168.2.33"
|
||||||
DB_PORT="3306"
|
DB_PORT="3306"
|
||||||
DB_NAME="fociz"
|
|
||||||
DB_USER="focizlive_user"
|
|
||||||
DB_PASS="F0c1z_Live_Secure_2026!"
|
|
||||||
|
|
||||||
# Omgeving & URL
|
|
||||||
APP_ENV="live"
|
|
||||||
APP_URL="https://www.fociz.nl"
|
|
||||||
|
|
||||||
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
|
||||||
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
APP_SECRET="fociz-maria-db-super-secret-key-998877"
|
||||||
@@ -26,8 +23,8 @@ SMTP_USER="info@fociz.nl"
|
|||||||
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
SMTP_PASS="6swpQSBg5uWFmjar7F6a"
|
||||||
SMTP_SECURE="tls"
|
SMTP_SECURE="tls"
|
||||||
SMTP_FROM_EMAIL="info@fociz.nl"
|
SMTP_FROM_EMAIL="info@fociz.nl"
|
||||||
SMTP_FROM_NAME="Fociz"
|
SMTP_FROM_NAME="Fociz Dev"
|
||||||
SMTP_REPLY_TO="info@fociz.nl"
|
SMTP_REPLY_TO="info@fociz.nl"
|
||||||
|
|
||||||
# Webhooks (optioneel)
|
# Webhooks (optioneel)
|
||||||
JIRA_WEBHOOK_SECRET="fociz_webhook_live_2026"
|
JIRA_WEBHOOK_SECRET="fociz_webhook_dev_2026"
|
||||||
|
|||||||
+35
-13
@@ -25,9 +25,9 @@ if (!headers_sent()) {
|
|||||||
}
|
}
|
||||||
@ini_set('expose_php', 'off');
|
@ini_set('expose_php', 'off');
|
||||||
|
|
||||||
// 1. .env inlezing (controleert zowel config/.env als hoofdmap .env)
|
// 1. .env inlezing (controleert eerst server root .env en dan config/.env)
|
||||||
$envVars = [];
|
$envVars = [];
|
||||||
$envLocations = [__DIR__ . '/.env', __DIR__ . '/../.env'];
|
$envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
|
||||||
foreach ($envLocations as $envFile) {
|
foreach ($envLocations as $envFile) {
|
||||||
if (file_exists($envFile)) {
|
if (file_exists($envFile)) {
|
||||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
@@ -81,30 +81,52 @@ if (!function_exists('fociz_env')) {
|
|||||||
define('APP_NAME', 'Fociz');
|
define('APP_NAME', 'Fociz');
|
||||||
|
|
||||||
// Automatische omgevingsdetectie:
|
// Automatische omgevingsdetectie:
|
||||||
// 1. Expliciete .env variabele 'APP_ENV'
|
// 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
|
||||||
// 2. Automatisch 'development' indien hostname 'dev.' of 'localhost' bevat
|
$detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
|
||||||
// 3. Standaard 'live'
|
|
||||||
$detectedHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
||||||
$isDevHost = (
|
$isDevHost = (
|
||||||
str_contains($detectedHost, 'dev.') ||
|
str_contains($detectedHost, 'dev.') ||
|
||||||
str_contains($detectedHost, 'localhost') ||
|
str_contains($detectedHost, 'localhost') ||
|
||||||
str_contains($detectedHost, '127.0.0.1')
|
str_contains($detectedHost, '127.0.0.1')
|
||||||
);
|
);
|
||||||
$defaultEnv = $isDevHost ? 'development' : 'live';
|
$isLiveHost = (
|
||||||
define('APP_ENV', fociz_env('APP_ENV', $defaultEnv));
|
!empty($detectedHost) &&
|
||||||
|
!$isDevHost &&
|
||||||
|
(str_contains($detectedHost, 'fociz.nl') || str_contains($detectedHost, 'live'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bepaal APP_ENV: op live domeinen (bijv. www.fociz.nl of fociz.nl) ALTIJD 'live'
|
||||||
|
// zodat een per ongeluk meegekomen dev-configuratie NOOIT productie kan beïnvloeden.
|
||||||
|
if ($isLiveHost) {
|
||||||
|
$effectiveEnv = 'live';
|
||||||
|
} elseif ($isDevHost) {
|
||||||
|
$effectiveEnv = 'development';
|
||||||
|
} else {
|
||||||
|
$effectiveEnv = fociz_env('APP_ENV', 'live');
|
||||||
|
}
|
||||||
|
define('APP_ENV', $effectiveEnv);
|
||||||
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
|
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Leest eerst eventuele .env variabelen (die per server uniek en gitignored zijn).
|
|
||||||
// Indien niet ingesteld via .env, wordt automatisch de juiste database gekozen op basis van APP_ENV.
|
|
||||||
$isDevEnv = (APP_ENV === 'development');
|
$isDevEnv = (APP_ENV === 'development');
|
||||||
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
|
||||||
define('DB_PORT', fociz_env('DB_PORT', '3306'));
|
define('DB_PORT', fociz_env('DB_PORT', '3306'));
|
||||||
define('DB_NAME', fociz_env('DB_NAME', $isDevEnv ? 'focizdev' : 'fociz'));
|
|
||||||
define('DB_USER', fociz_env('DB_USER', $isDevEnv ? 'focizdev_user' : 'focizlive_user'));
|
// Veiligheidswaarborg: op het live domein of live omgeving NOOIT per ongeluk de dev database gebruiken
|
||||||
define('DB_PASS', fociz_env('DB_PASS', $isDevEnv ? 'F0c1z_Dev_Secure_2026!' : 'F0c1z_Live_Secure_2026!'));
|
if ($isLiveHost || APP_ENV === 'live') {
|
||||||
|
$rawDbName = fociz_env('DB_NAME');
|
||||||
|
$rawDbUser = fociz_env('DB_USER');
|
||||||
|
$rawDbPass = fociz_env('DB_PASS');
|
||||||
|
|
||||||
|
define('DB_NAME', (!empty($rawDbName) && $rawDbName !== 'focizdev') ? $rawDbName : 'fociz');
|
||||||
|
define('DB_USER', (!empty($rawDbUser) && $rawDbUser !== 'focizdev_user') ? $rawDbUser : 'focizlive_user');
|
||||||
|
define('DB_PASS', (!empty($rawDbPass) && $rawDbPass !== 'F0c1z_Dev_Secure_2026!') ? $rawDbPass : 'F0c1z_Live_Secure_2026!');
|
||||||
|
} else {
|
||||||
|
define('DB_NAME', fociz_env('DB_NAME', 'focizdev'));
|
||||||
|
define('DB_USER', fociz_env('DB_USER', 'focizdev_user'));
|
||||||
|
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Dev_Secure_2026!'));
|
||||||
|
}
|
||||||
define('DB_CHARSET', 'utf8mb4');
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
// Bepaal de dynamische of geconfigureerde applicatie URL
|
// Bepaal de dynamische of geconfigureerde applicatie URL
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ function ensure_tables_exist(PDO $pdo): void {
|
|||||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
|
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
|
||||||
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
|
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
|
||||||
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
|
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
|
||||||
|
// Dashboard Snelle Bullet Notities & Todo Lijst
|
||||||
|
"CREATE TABLE IF NOT EXISTS `dashboard_bullets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `plan_date` DATE NOT NULL, `content` VARCHAR(500) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `scheduled_task_id` INT NULL, `scheduled_date` DATE NULL, `scheduled_start_time` VARCHAR(10) NULL, `scheduled_end_time` VARCHAR(10) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `plan_date`), INDEX (`scheduled_task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
];
|
];
|
||||||
foreach ($colUpgrades as $sql) {
|
foreach ($colUpgrades as $sql) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+173
-42
@@ -160,30 +160,43 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
// Normaliseer modelnaam en stel geldige kandidatenlijst op
|
||||||
$candidateUrls = [];
|
$candidateUrls = [];
|
||||||
|
|
||||||
// Helper om endpoints op te bouwen voor v1beta en v1
|
// Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
|
||||||
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
$addCandidatesFor = function(string $m) use (&$candidateUrls) {
|
||||||
$m = preg_replace('#^models/#', '', trim($m));
|
$m = preg_replace('#^models/#', '', trim($m));
|
||||||
if ($m === '') return;
|
if ($m === '') return;
|
||||||
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
|
||||||
$v1 = "https://generativelanguage.googleapis.com/v1/models/{$m}:generateContent";
|
|
||||||
if (!in_array($v1beta, $candidateUrls, true)) {
|
if (!in_array($v1beta, $candidateUrls, true)) {
|
||||||
$candidateUrls[] = $v1beta;
|
$candidateUrls[] = $v1beta;
|
||||||
}
|
}
|
||||||
if (!in_array($v1, $candidateUrls, true)) {
|
|
||||||
$candidateUrls[] = $v1;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Voeg het geconfigureerde model toe als eerste kandidaat (tenzij het een niet-bestaand/fictief model is)
|
// Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
|
||||||
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
|
||||||
$addCandidatesFor($cleanModel);
|
$addCandidatesFor($cleanModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voeg altijd de stabiele, snelle Google AI modellen toe als betrouwbare fallbacks/standaarden
|
// Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
|
||||||
$addCandidatesFor('gemini-2.0-flash');
|
$addCandidatesFor('gemini-2.0-flash');
|
||||||
$addCandidatesFor('gemini-1.5-flash');
|
$addCandidatesFor('gemini-1.5-flash');
|
||||||
$addCandidatesFor('gemini-1.5-pro');
|
|
||||||
$addCandidatesFor('gemini-2.5-flash');
|
$addCandidatesFor('gemini-2.5-flash');
|
||||||
|
$addCandidatesFor('gemini-1.5-pro');
|
||||||
|
|
||||||
|
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
|
||||||
|
$status = $res['status'] ?? 0;
|
||||||
|
if ($status === 503 || $status === 429) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$lower = strtolower($msg);
|
||||||
|
return (
|
||||||
|
str_contains($lower, 'high demand') ||
|
||||||
|
str_contains($lower, 'spikes in demand') ||
|
||||||
|
str_contains($lower, 'temporarily unavailable') ||
|
||||||
|
str_contains($lower, 'resource has been exhausted') ||
|
||||||
|
str_contains($lower, 'rate limit') ||
|
||||||
|
str_contains($lower, 'overloaded') ||
|
||||||
|
str_contains($lower, 'too many requests')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$response = null;
|
$response = null;
|
||||||
$lastError = null;
|
$lastError = null;
|
||||||
@@ -193,31 +206,42 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
for ($i = 0; $i < count($candidateUrls); $i++) {
|
for ($i = 0; $i < count($candidateUrls); $i++) {
|
||||||
$baseUrl = $candidateUrls[$i];
|
$baseUrl = $candidateUrls[$i];
|
||||||
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
$endpoint = $baseUrl . '?key=' . urlencode($apiKey);
|
||||||
$res = fociz_http_request($endpoint, [
|
|
||||||
'method' => 'POST',
|
|
||||||
'headers' => [
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
'x-goog-api-key' => $apiKey,
|
|
||||||
],
|
|
||||||
'body' => $payload,
|
|
||||||
'timeout' => 20,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
|
||||||
$extracted = $extractCandidateText($res['json']['candidates'][0]);
|
for ($attempt = 1; $attempt <= 2; $attempt++) {
|
||||||
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
|
$res = fociz_http_request($endpoint, [
|
||||||
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
|
'method' => 'POST',
|
||||||
$response = $res;
|
'headers' => [
|
||||||
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
'Content-Type' => 'application/json',
|
||||||
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
'x-goog-api-key' => $apiKey,
|
||||||
$successfulModel = $mName[1];
|
],
|
||||||
|
'body' => $payload,
|
||||||
|
'timeout' => 25,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
|
||||||
|
$extracted = $extractCandidateText($res['json']['candidates'][0]);
|
||||||
|
// Als er tekst is, of finishReason is geldig (bijv. STOP of MAX_TOKENS)
|
||||||
|
if ($extracted !== '' || !empty($res['json']['candidates'][0]['finishReason'])) {
|
||||||
|
$response = $res;
|
||||||
|
$finalText = ($extracted !== '') ? $extracted : 'OK';
|
||||||
|
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
|
||||||
|
$successfulModel = $mName[1];
|
||||||
|
}
|
||||||
|
break 2; // Volledig succes, beëindig beide loops
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
|
||||||
$lastError = $msg;
|
$lastError = $msg;
|
||||||
|
|
||||||
|
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
|
||||||
|
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
|
||||||
|
usleep(800000); // 800ms
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
|
||||||
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
|
||||||
@@ -228,14 +252,26 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Als het model niet gevonden is, niet meer beschikbaar is of deprecated is, probeer de volgende kandidaat
|
// 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
|
||||||
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bij autorisatiefouten (zoals ongeldige API key 400/403) direct stoppen
|
// 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
|
||||||
$response = $res;
|
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
|
||||||
break;
|
if ($isHighDemandOrRateLimited($res, $msg)) {
|
||||||
|
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
|
||||||
|
if (in_array($res['status'], [400, 401, 403], true)) {
|
||||||
|
$response = $res;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
|
||||||
@@ -549,9 +585,39 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
|
|||||||
|
|
||||||
if (!$aiRes['success']) {
|
if (!$aiRes['success']) {
|
||||||
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
|
||||||
|
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
|
||||||
|
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => $aiRes['error'] ?? 'Kon het idee niet ontleden in taken via AI.',
|
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||||
|
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||||
|
'estimated_minutes' => 45,
|
||||||
|
'priority' => 'P1',
|
||||||
|
'color' => 'indigo',
|
||||||
|
'category' => 'Onderzoek'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||||
|
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||||
|
'estimated_minutes' => 90,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'blue',
|
||||||
|
'category' => 'Frontend'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||||
|
'description' => "Testen, feedback verwerken en afronden.",
|
||||||
|
'estimated_minutes' => 30,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'emerald',
|
||||||
|
'category' => 'Testing'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'model' => 'Smart Fallback',
|
||||||
|
'is_fallback' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,9 +652,38 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
|
|||||||
|
|
||||||
if (empty($items)) {
|
if (empty($items)) {
|
||||||
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
|
||||||
|
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => 'De AI gaf een antwoord maar kon de taken niet structureren. Probeer het nogmaals.',
|
'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'title' => "Onderzoek & specificatie: {$shortTitle}",
|
||||||
|
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
|
||||||
|
'estimated_minutes' => 45,
|
||||||
|
'priority' => 'P1',
|
||||||
|
'color' => 'indigo',
|
||||||
|
'category' => 'Onderzoek'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Realisatie & uitwerking: {$shortTitle}",
|
||||||
|
'description' => "Implementatie en concrete uitvoering van het idee.",
|
||||||
|
'estimated_minutes' => 90,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'blue',
|
||||||
|
'category' => 'Frontend'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
|
||||||
|
'description' => "Testen, feedback verwerken en afronden.",
|
||||||
|
'estimated_minutes' => 30,
|
||||||
|
'priority' => 'P2',
|
||||||
|
'color' => 'emerald',
|
||||||
|
'category' => 'Testing'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'model' => 'Smart Fallback',
|
||||||
|
'is_fallback' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +757,13 @@ function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count
|
|||||||
], ['json_mode' => true, 'user_id' => $userId]);
|
], ['json_mode' => true, 'user_id' => $userId]);
|
||||||
|
|
||||||
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
||||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
|
$ideas = [
|
||||||
|
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
|
||||||
|
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
|
||||||
|
"Koppelingen en integraties verkennen voor snellere afhandeling",
|
||||||
|
"Feedback- en validatielus inrichten met eindgebruikers"
|
||||||
|
];
|
||||||
|
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
||||||
@@ -697,12 +798,24 @@ function fociz_ai_generate_session_board(string $sessionTitle, string $theme, st
|
|||||||
|
|
||||||
$aiRes = fociz_ai_chat([
|
$aiRes = fociz_ai_chat([
|
||||||
['role' => 'system', 'content' => $systemPrompt],
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
['role' => 'user', 'content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
|
['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
|
||||||
], ['json_mode' => true, 'user_id' => $userId]);
|
], ['json_mode' => true, 'user_id' => $userId]);
|
||||||
|
|
||||||
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
|
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
|
||||||
// Fallback als Gemini geen gestructureerde cards array teruggaf
|
return [
|
||||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen gestructureerde post-its genereren'];
|
'success' => true,
|
||||||
|
'cards' => [
|
||||||
|
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
|
||||||
|
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
|
||||||
|
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
|
||||||
|
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
|
||||||
|
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
|
||||||
|
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
|
||||||
|
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
|
||||||
|
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
|
||||||
|
],
|
||||||
|
'is_fallback' => true,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'cards' => $aiRes['json']['cards']];
|
return ['success' => true, 'cards' => $aiRes['json']['cards']];
|
||||||
@@ -726,7 +839,25 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
|
|||||||
], ['json_mode' => true, 'user_id' => $userId]);
|
], ['json_mode' => true, 'user_id' => $userId]);
|
||||||
|
|
||||||
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
||||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
|
// Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
|
||||||
|
$lines = explode("\n", trim($content));
|
||||||
|
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
|
||||||
|
$cleanTitle = mb_substr($firstLine, 0, 75);
|
||||||
|
$currentMonth = (int)date('n');
|
||||||
|
$q = 'Q' . ceil($currentMonth / 3);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'item' => [
|
||||||
|
'title' => $cleanTitle,
|
||||||
|
'description' => $content,
|
||||||
|
'priority' => 'HIGH',
|
||||||
|
'type' => 'FEATURE',
|
||||||
|
'quarter' => $q,
|
||||||
|
],
|
||||||
|
'is_fallback' => true,
|
||||||
|
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'item' => $aiRes['json']];
|
return ['success' => true, 'item' => $aiRes['json']];
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
|
$res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
|
||||||
|
if (empty($res['success'])) {
|
||||||
|
error_log("fociz_send_new_ip_verification: Mail verzenden mislukt naar {$email}: " . ($res['error'] ?? 'onbekende fout'));
|
||||||
|
}
|
||||||
return !empty($res['success']);
|
return !empty($res['success']);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
|
error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -14,13 +14,9 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/../config/config.php';
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
|
// Standaard waarde voor de main branch: 'live' (bepaald via centrale config.php)
|
||||||
$environment = defined('APP_ENV') ? APP_ENV : 'live';
|
$environment = defined('APP_ENV') ? APP_ENV : 'live';
|
||||||
|
|
||||||
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
|
|
||||||
$environment = fociz_env('APP_ENV');
|
|
||||||
}
|
|
||||||
|
|
||||||
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
|
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
+52
-12
@@ -131,15 +131,58 @@ class FocizMailer {
|
|||||||
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. EHLO Handshake
|
// 4. EHLO Handshake conform RFC 5321 §4.1.4
|
||||||
$clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl';
|
// Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
|
||||||
$resp = $sendCommand("EHLO {$clientHost}");
|
// Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
|
||||||
if ($resp['code'] !== 250) {
|
$resolveHeloHost = function() use ($host): string {
|
||||||
$resp = $sendCommand("HELO {$clientHost}");
|
// 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
|
||||||
if ($resp['code'] !== 250) {
|
if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
|
||||||
throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}");
|
return $host;
|
||||||
}
|
}
|
||||||
}
|
// 2. SERVER_NAME mits het niet het bare hoofddomein is
|
||||||
|
if (!empty($_SERVER['SERVER_NAME'])) {
|
||||||
|
$sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME']));
|
||||||
|
if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) {
|
||||||
|
return $sn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Systeemhostnaam
|
||||||
|
if (function_exists('gethostname')) {
|
||||||
|
$gh = gethostname();
|
||||||
|
if (!empty($gh) && strtolower($gh) !== 'fociz.nl') {
|
||||||
|
return $gh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'mail.fociz.nl';
|
||||||
|
};
|
||||||
|
|
||||||
|
$performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string {
|
||||||
|
$candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']);
|
||||||
|
$lastResp = null;
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$resp = $sendCommand("EHLO {$candidate}");
|
||||||
|
if ($resp['code'] === 250) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
$lastResp = $resp;
|
||||||
|
$log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback naar HELO indien EHLO op alle kandidaten niet werkt
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$resp = $sendCommand("HELO {$candidate}");
|
||||||
|
if ($resp['code'] === 250) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
$lastResp = $resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend'));
|
||||||
|
};
|
||||||
|
|
||||||
|
$preferredHost = $resolveHeloHost();
|
||||||
|
$clientHost = $performHeloHandshake($preferredHost);
|
||||||
|
|
||||||
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
|
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
|
||||||
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
|
||||||
@@ -165,10 +208,7 @@ class FocizMailer {
|
|||||||
$log("TLS encryptie succesvol geactiveerd.");
|
$log("TLS encryptie succesvol geactiveerd.");
|
||||||
|
|
||||||
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
|
||||||
$resp = $sendCommand("EHLO {$clientHost}");
|
$clientHost = $performHeloHandshake($clientHost);
|
||||||
if ($resp['code'] !== 250) {
|
|
||||||
throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. SMTP Authenticatie
|
// 6. SMTP Authenticatie
|
||||||
|
|||||||
@@ -191,9 +191,9 @@ function get_ai_settings(?int $userId = null): array {
|
|||||||
$provider = 'gemini';
|
$provider = 'gemini';
|
||||||
}
|
}
|
||||||
|
|
||||||
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-1.5-pro');
|
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
|
||||||
if (empty($model)) {
|
if (empty($model)) {
|
||||||
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-1.5-pro';
|
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
|
||||||
}
|
}
|
||||||
|
|
||||||
$apiKey = trim((string)$apiKey);
|
$apiKey = trim((string)$apiKey);
|
||||||
|
|||||||
+919
-16
@@ -244,6 +244,32 @@ try {
|
|||||||
error_log("Fout bij ophalen planned external IDs: " . $e->getMessage());
|
error_log("Fout bij ophalen planned external IDs: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3c. Haal snelle bullet notities / todo's op voor de geselecteerde dag
|
||||||
|
$dashboardBullets = [];
|
||||||
|
$pastOpenBulletsCount = 0;
|
||||||
|
try {
|
||||||
|
$stmtBullets = $db->prepare("
|
||||||
|
SELECT b.*, t.time_block_start as live_start, t.time_block_end as live_end, t.status as task_status
|
||||||
|
FROM dashboard_bullets b
|
||||||
|
LEFT JOIN tasks t ON b.scheduled_task_id = t.id
|
||||||
|
WHERE b.user_id = ? AND b.plan_date = ?
|
||||||
|
ORDER BY b.position ASC, b.id ASC
|
||||||
|
");
|
||||||
|
$stmtBullets->execute([$userId, $selectedDate]);
|
||||||
|
$dashboardBullets = $stmtBullets->fetchAll() ?: [];
|
||||||
|
|
||||||
|
// Tel openstaande items van eerdere dagen
|
||||||
|
$stmtPastBullets = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM dashboard_bullets
|
||||||
|
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
|
||||||
|
");
|
||||||
|
$stmtPastBullets->execute([$userId, $selectedDate]);
|
||||||
|
$pastOpenBulletsCount = (int)$stmtPastBullets->fetchColumn();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Fout bij ophalen dashboard bullets: " . $e->getMessage());
|
||||||
|
ensure_tables_exist($db);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Haal weekstatistieken voor taken op (aantal per dag)
|
// 4. Haal weekstatistieken voor taken op (aantal per dag)
|
||||||
$weekTaskStats = [];
|
$weekTaskStats = [];
|
||||||
try {
|
try {
|
||||||
@@ -311,7 +337,7 @@ $availableWidgets = [
|
|||||||
'outlook_mail' => ['id' => 'outlook_mail', 'title' => 'Outlook E-mail Widget', 'category' => 'Communicatie', 'icon' => 'mail', 'enabled' => true, 'col_span' => 6, 'order' => 4, 'desc' => 'Inkomende e-mails direct inzien en met 1 klik inplannen als taak'],
|
'outlook_mail' => ['id' => 'outlook_mail', 'title' => 'Outlook E-mail Widget', 'category' => 'Communicatie', 'icon' => 'mail', 'enabled' => true, 'col_span' => 6, 'order' => 4, 'desc' => 'Inkomende e-mails direct inzien en met 1 klik inplannen als taak'],
|
||||||
'jira_queue' => ['id' => 'jira_queue', 'title' => 'Jira Tickets', 'category' => 'Ontwikkeling', 'icon' => 'layers', 'enabled' => true, 'col_span' => 6, 'order' => 5, 'desc' => 'Openstaande Jira tickets met directe importknop'],
|
'jira_queue' => ['id' => 'jira_queue', 'title' => 'Jira Tickets', 'category' => 'Ontwikkeling', 'icon' => 'layers', 'enabled' => true, 'col_span' => 6, 'order' => 5, 'desc' => 'Openstaande Jira tickets met directe importknop'],
|
||||||
'calendar_meetings' => ['id' => 'calendar_meetings', 'title' => 'Agenda & Teams Meetings', 'category' => 'Agenda', 'icon' => 'video', 'enabled' => true, 'col_span' => 6, 'order' => 6, 'desc' => 'Vandaag geplande afspraken met 1-klik Teams deelnemen knop'],
|
'calendar_meetings' => ['id' => 'calendar_meetings', 'title' => 'Agenda & Teams Meetings', 'category' => 'Agenda', 'icon' => 'video', 'enabled' => true, 'col_span' => 6, 'order' => 6, 'desc' => 'Vandaag geplande afspraken met 1-klik Teams deelnemen knop'],
|
||||||
'scratchpad' => ['id' => 'scratchpad', 'title' => 'Dagnotities & Kladblok', 'category' => 'Productiviteit', 'icon' => 'edit-3', 'enabled' => true, 'col_span' => 6, 'order' => 7, 'desc' => 'Vrij notitieblok met automatische achtergrond-opslag'],
|
'scratchpad' => ['id' => 'scratchpad', 'title' => 'Todo\'s & Notities', 'category' => 'Productiviteit', 'icon' => 'check-square', 'enabled' => true, 'col_span' => 6, 'order' => 7, 'desc' => 'Snel actiepunten en notities vastleggen en inplannen in een agendablok'],
|
||||||
'stats' => ['id' => 'stats', 'title' => 'Productiviteit & KPI\'s', 'category' => 'Inzicht', 'icon' => 'bar-chart-2', 'enabled' => true, 'col_span' => 12, 'order' => 8, 'desc' => 'Voortgangsbalk, voltooide taken, focus uren en meeting totalen'],
|
'stats' => ['id' => 'stats', 'title' => 'Productiviteit & KPI\'s', 'category' => 'Inzicht', 'icon' => 'bar-chart-2', 'enabled' => true, 'col_span' => 12, 'order' => 8, 'desc' => 'Voortgangsbalk, voltooide taken, focus uren en meeting totalen'],
|
||||||
'quick_links' => ['id' => 'quick_links', 'title' => 'Snelle Koppelingen & Tools', 'category' => 'Productiviteit', 'icon' => 'external-link', 'enabled' => false, 'col_span' => 6, 'order' => 9, 'desc' => '1-klik snelkoppelingen naar Jira, Teams, Outlook, Azure en documentatie'],
|
'quick_links' => ['id' => 'quick_links', 'title' => 'Snelle Koppelingen & Tools', 'category' => 'Productiviteit', 'icon' => 'external-link', 'enabled' => false, 'col_span' => 6, 'order' => 9, 'desc' => '1-klik snelkoppelingen naar Jira, Teams, Outlook, Azure en documentatie'],
|
||||||
'habit_tracker' => ['id' => 'habit_tracker', 'title' => 'Dagelijkse Focus Gewoontes', 'category' => 'Productiviteit', 'icon' => 'sparkles', 'enabled' => false, 'col_span' => 6, 'order' => 10, 'desc' => 'Houd gezonde werkgewoontes bij (water drinken, 15m bewegen, inbox zero)'],
|
'habit_tracker' => ['id' => 'habit_tracker', 'title' => 'Dagelijkse Focus Gewoontes', 'category' => 'Productiviteit', 'icon' => 'sparkles', 'enabled' => false, 'col_span' => 6, 'order' => 10, 'desc' => 'Houd gezonde werkgewoontes bij (water drinken, 15m bewegen, inbox zero)'],
|
||||||
@@ -1616,27 +1642,195 @@ for ($h = $startHour; $h <= $endHour; $h++) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php elseif ($wId === 'scratchpad'): ?>
|
<?php elseif ($wId === 'scratchpad'): ?>
|
||||||
<!-- Widget 7: Dagnotities & Kladblok -->
|
<!-- Widget 7: Todo's & Notities -->
|
||||||
<div class="fociz-card p-6">
|
<div class="fociz-card p-6" id="dashboardBulletWidget">
|
||||||
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800 mb-3">
|
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800 mb-4">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<div class="w-7 h-7 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center font-bold">
|
<div class="w-7 h-7 rounded-lg bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 flex items-center justify-center font-bold">
|
||||||
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
<i data-lucide="check-square" class="w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider">
|
<h3 class="text-xs font-bold text-slate-900 dark:text-white uppercase tracking-wider">
|
||||||
Dagnotities & Kladblok
|
<?= htmlspecialchars($wConfig['custom_title'] ?? 'Todo\'s & Notities') ?>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span id="scratchpadSaveStatus" class="text-[10px] text-slate-400 font-mono">Automatisch opgeslagen</span>
|
|
||||||
|
<!-- Tab Switcher: Bullet Todo's vs Vrij Kladblok -->
|
||||||
|
<div class="flex items-center space-x-1 bg-slate-100 dark:bg-slate-800/80 p-0.5 rounded-xl text-[11px] font-semibold">
|
||||||
|
<button type="button" onclick="switchBulletView('bullets')" id="btnTabBullets" class="px-2.5 py-1 rounded-lg transition flex items-center space-x-1.5 bg-white dark:bg-[#111624] text-indigo-600 dark:text-indigo-400 shadow-2xs font-bold cursor-pointer">
|
||||||
|
<i data-lucide="list-todo" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Bullets</span>
|
||||||
|
<span id="bulletCountBadge" class="ml-1 px-1.5 py-0.2 rounded-full text-[9px] <?= count($dashboardBullets) > 0 ? 'bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300' : 'hidden' ?>">
|
||||||
|
<?= count($dashboardBullets) ?>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="switchBulletView('notes')" id="btnTabNotes" class="px-2.5 py-1 rounded-lg transition flex items-center space-x-1.5 text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 cursor-pointer">
|
||||||
|
<i data-lucide="file-text" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Kladblok</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. Bullet Todo Lijst Weergave -->
|
||||||
|
<div id="bulletListViewArea" class="space-y-3">
|
||||||
|
<!-- Snelle Toevoegen Regel -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||||
|
<i data-lucide="circle-dot" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="newBulletInput"
|
||||||
|
placeholder="Snel item toevoegen... (bijv. Bespreken met collega)"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();addBulletItem();}"
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl pl-9 pr-3 py-2 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs transition"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="addBulletItem()"
|
||||||
|
id="btnAddBullet"
|
||||||
|
class="px-3.5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition flex items-center space-x-1 flex-shrink-0 cursor-pointer shadow-xs active:scale-95"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
|
||||||
|
<span class="hidden sm:inline">Toevoegen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carry-over Banner (als er openstaande notities van eerdere dagen zijn) -->
|
||||||
|
<div id="bulletCarryoverBanner" class="<?= $pastOpenBulletsCount > 0 ? '' : 'hidden' ?> p-2.5 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-between text-xs text-indigo-900 dark:text-indigo-200">
|
||||||
|
<div class="flex items-center space-x-2 min-w-0">
|
||||||
|
<i data-lucide="inbox" class="w-4 h-4 text-indigo-500 flex-shrink-0"></i>
|
||||||
|
<span class="truncate">
|
||||||
|
<strong id="pastOpenBulletCountText"><?= $pastOpenBulletsCount ?></strong> openstaande memo's van eerdere dagen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="carryOverOpenBullets()"
|
||||||
|
class="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-[11px] font-bold flex-shrink-0 transition cursor-pointer shadow-2xs"
|
||||||
|
>
|
||||||
|
Naar vandaag verplaatsen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- De Bullet Items Lijst -->
|
||||||
|
<div class="space-y-1.5 max-h-72 overflow-y-auto pr-0.5" id="bulletItemsContainer">
|
||||||
|
<?php if (empty($dashboardBullets)): ?>
|
||||||
|
<div id="bulletEmptyState" class="py-8 text-center text-slate-400 dark:text-slate-500">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800/60 flex items-center justify-center mx-auto mb-2 text-slate-400">
|
||||||
|
<i data-lucide="check-circle" class="w-5 h-5 opacity-60"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-slate-600 dark:text-slate-400">Geen notities of actiepunten voor deze dag.</p>
|
||||||
|
<p class="text-[11px] opacity-75 mt-0.5">Typ hierboven een punt, bijv. "Bespreken met collega".</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div id="bulletEmptyState" class="hidden py-8 text-center text-slate-400 dark:text-slate-500">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800/60 flex items-center justify-center mx-auto mb-2 text-slate-400">
|
||||||
|
<i data-lucide="check-circle" class="w-5 h-5 opacity-60"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-slate-600 dark:text-slate-400">Geen notities of actiepunten voor deze dag.</p>
|
||||||
|
</div>
|
||||||
|
<?php foreach ($dashboardBullets as $b):
|
||||||
|
$bId = (int)$b['id'];
|
||||||
|
$isComp = !empty($b['is_completed']);
|
||||||
|
$isSched = !empty($b['scheduled_task_id']) || !empty($b['scheduled_start_time']);
|
||||||
|
$schedTime = !empty($b['scheduled_start_time']) ? htmlspecialchars($b['scheduled_start_time']) : (!empty($b['live_start']) ? htmlspecialchars($b['live_start']) : '');
|
||||||
|
$schedEndTime = !empty($b['scheduled_end_time']) ? htmlspecialchars($b['scheduled_end_time']) : (!empty($b['live_end']) ? htmlspecialchars($b['live_end']) : '');
|
||||||
|
$schedDate = !empty($b['scheduled_date']) ? $b['scheduled_date'] : $selectedDate;
|
||||||
|
?>
|
||||||
|
<div
|
||||||
|
id="bullet-row-<?= $bId ?>"
|
||||||
|
class="group/bullet flex items-center justify-between p-2.5 rounded-xl border <?= $isComp ? 'bg-slate-50/60 dark:bg-slate-900/30 border-slate-200/60 dark:border-slate-800/50' : 'bg-white/80 dark:bg-[#0c0f17]/70 border-slate-200/90 dark:border-slate-800' ?> transition shadow-2xs hover:border-indigo-300 dark:hover:border-indigo-500/40"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2.5 min-w-0 flex-1 mr-2">
|
||||||
|
<!-- Checkbox / Toggle -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="toggleBulletItem(<?= $bId ?>, <?= $isComp ? 0 : 1 ?>)"
|
||||||
|
title="<?= $isComp ? 'Markeren als niet afgerond' : 'Afvinken' ?>"
|
||||||
|
class="w-5 h-5 rounded-lg flex items-center justify-center flex-shrink-0 transition cursor-pointer <?= $isComp ? 'bg-emerald-500 text-white' : 'border border-slate-300 dark:border-slate-700 hover:border-indigo-500 text-transparent hover:text-indigo-400' ?>"
|
||||||
|
>
|
||||||
|
<i data-lucide="check" class="w-3.5 h-3.5 stroke-[3]"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Tekst & Agenda Badge -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
id="bullet-text-<?= $bId ?>"
|
||||||
|
ondblclick="startEditBullet(<?= $bId ?>)"
|
||||||
|
class="text-xs font-medium select-text break-words <?= $isComp ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-900 dark:text-slate-100' ?>"
|
||||||
|
><?= htmlspecialchars($b['content']) ?></span>
|
||||||
|
|
||||||
|
<?php if ($isSched): ?>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="openBulletScheduleModal(<?= $bId ?>, '<?= htmlspecialchars(addslashes($b['content'])) ?>', '<?= $schedDate ?>', '<?= $schedTime ?>', '<?= $schedEndTime ?>')"
|
||||||
|
title="Ingepland op agenda: <?= $schedDate ?> <?= $schedTime ?><?= $schedEndTime ? ' - ' . $schedEndTime : '' ?> (klik om te wijzigen)"
|
||||||
|
class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md bg-indigo-50 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/30 text-[10px] font-mono font-bold hover:bg-indigo-100 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="calendar" class="w-2.5 h-2.5"></i>
|
||||||
|
<span><?= ($schedDate === $today ? 'Vandaag' : $schedDate) ?> <?= $schedTime ?><?= $schedEndTime ? '-' . $schedEndTime : '' ?></span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Knoppen: Inplannen, Bewerken, Verwijderen -->
|
||||||
|
<div class="flex items-center space-x-1 opacity-80 sm:opacity-0 sm:group-hover/bullet:opacity-100 transition-opacity flex-shrink-0">
|
||||||
|
<!-- Knop: In agenda plannen (Vrij blok) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="openBulletScheduleModal(<?= $bId ?>, '<?= htmlspecialchars(addslashes($b['content'])) ?>', '<?= $schedDate ?>', '<?= $schedTime ?>', '<?= $schedEndTime ?>')"
|
||||||
|
title="In agenda inplannen (kies een vrij blok)"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-500/20 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="calendar-plus" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Knop: Bewerken -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="startEditBullet(<?= $bId ?>)"
|
||||||
|
title="Tekst bewerken"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Knop: Verwijderen -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="deleteBulletItem(<?= $bId ?>)"
|
||||||
|
title="Verwijderen"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/20 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Vrij Kladblok Weergave (Oorspronkelijke textarea, volledig bewaard) -->
|
||||||
|
<div id="bulletNotesViewArea" class="hidden space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-[11px] text-slate-400">
|
||||||
|
<span>Vrije notities & memo's voor deze dag</span>
|
||||||
|
<span id="scratchpadSaveStatus" class="font-mono">Automatisch opgeslagen</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="dailyNotesInput"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Belangrijke aantekeningen, reflecties of snelle memo's voor vandaag..."
|
||||||
|
oninput="autoSaveScratchpad()"
|
||||||
|
onblur="saveGoal()"
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none transition shadow-2xs leading-relaxed"
|
||||||
|
><?= htmlspecialchars($dailyPlan['notes'] ?? '') ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
|
||||||
id="dailyNotesInput"
|
|
||||||
rows="5"
|
|
||||||
placeholder="Belangrijke aantekeningen, reflecties of snelle memo's voor vandaag..."
|
|
||||||
oninput="autoSaveScratchpad()"
|
|
||||||
onblur="saveGoal()"
|
|
||||||
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl p-3 text-slate-900 dark:text-white text-xs placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none transition shadow-2xs leading-relaxed"
|
|
||||||
><?= htmlspecialchars($dailyPlan['notes'] ?? '') ?></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php elseif ($wId === 'stats'): ?>
|
<?php elseif ($wId === 'stats'): ?>
|
||||||
@@ -1934,6 +2128,151 @@ if ($isAjaxRequest) {
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Modal: Snelle Bullet Notitie Inplannen in Agenda (Vrij Blok Kiezen) -->
|
||||||
|
<div id="bulletScheduleModal" onclick="if(event.target === this) closeBulletScheduleModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||||
|
<div class="fociz-card p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<div class="flex items-center space-x-2.5">
|
||||||
|
<div class="w-8 h-8 rounded-xl bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 flex items-center justify-center font-bold">
|
||||||
|
<i data-lucide="calendar-plus" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Notitie Inplannen in Agenda</h3>
|
||||||
|
<p class="text-[10px] text-slate-500 dark:text-slate-400">Kies een slim vrij blok of stel zelf een tijdstip in</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeBulletScheduleModal()" class="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="schedBulletId" value="">
|
||||||
|
|
||||||
|
<!-- Titel / Notitie Inhoud -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">Omschrijving / Titel van afspraak</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="schedBulletTitle"
|
||||||
|
placeholder="Bijv. Bespreken met collega..."
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs font-semibold focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datum & Snelle knoppen -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300">Datum</label>
|
||||||
|
<div class="flex items-center space-x-1.5">
|
||||||
|
<button type="button" onclick="setSchedBulletDate('today')" class="text-[10px] px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 hover:bg-indigo-50 dark:hover:bg-indigo-500/20 text-slate-600 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 font-semibold transition cursor-pointer">Vandaag</button>
|
||||||
|
<button type="button" onclick="setSchedBulletDate('tomorrow')" class="text-[10px] px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 hover:bg-indigo-50 dark:hover:bg-indigo-500/20 text-slate-600 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 font-semibold transition cursor-pointer">Morgen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="schedBulletDate"
|
||||||
|
onchange="refreshBulletFreeSlots()"
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tijdsduur Kwartier/Halfuur Selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">Geschatte Tijdsduur</label>
|
||||||
|
<input type="hidden" id="schedBulletDuration" value="30">
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
<button type="button" onclick="setSchedBulletDuration(15, this)" class="btn-sched-dur py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:border-indigo-500 transition cursor-pointer text-center">15m</button>
|
||||||
|
<button type="button" onclick="setSchedBulletDuration(30, this)" class="btn-sched-dur py-1.5 rounded-xl border border-indigo-500 bg-indigo-50 dark:bg-indigo-500/20 text-xs font-bold text-indigo-700 dark:text-indigo-300 transition cursor-pointer text-center">30m</button>
|
||||||
|
<button type="button" onclick="setSchedBulletDuration(45, this)" class="btn-sched-dur py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:border-indigo-500 transition cursor-pointer text-center">45m</button>
|
||||||
|
<button type="button" onclick="setSchedBulletDuration(60, this)" class="btn-sched-dur py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:border-indigo-500 transition cursor-pointer text-center">60m</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vrije Agendablokken Container (Gedetecteerd op basis van meetings en taken) -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<label class="block text-xs font-bold text-slate-700 dark:text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||||
|
<span>Kies een vrij agendablok:</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" onclick="refreshBulletFreeSlots()" class="text-[10px] text-indigo-600 dark:text-indigo-400 hover:underline flex items-center space-x-1 cursor-pointer">
|
||||||
|
<i data-lucide="refresh-cw" class="w-2.5 h-2.5"></i>
|
||||||
|
<span>Vernieuwen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedBulletFreeSlotsArea" class="bg-slate-50 dark:bg-[#0c0f17]/50 rounded-xl p-3 border border-slate-200 dark:border-slate-800">
|
||||||
|
<div id="schedBulletSlotsList" class="flex flex-wrap gap-2 max-h-36 overflow-y-auto">
|
||||||
|
<!-- Dynamisch geladen vrije blokken -->
|
||||||
|
<span class="text-xs text-slate-400 italic">Vrije blokken laden...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tijdblok Handmatig Instellen -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Starttijd</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="schedBulletStart"
|
||||||
|
placeholder="Bijv. 14:00"
|
||||||
|
oninput="onSchedBulletTimeChange()"
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1">Eindtijd</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="schedBulletEnd"
|
||||||
|
placeholder="Bijv. 14:30"
|
||||||
|
class="w-full bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs font-mono focus:outline-none focus:ring-1 focus:ring-indigo-500 shadow-2xs"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prioriteit & Outlook Synchronisatie -->
|
||||||
|
<div class="space-y-2 pt-1 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-semibold text-slate-700 dark:text-slate-300">Prioriteit op planning</label>
|
||||||
|
<select id="schedBulletPriority" class="bg-slate-50/70 dark:bg-[#0c0f17]/60 border border-slate-200 dark:border-slate-800 rounded-xl px-2.5 py-1 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
|
<option value="P1">P1 - Hoge prioriteit</option>
|
||||||
|
<option value="P2" selected>P2 - Normaal</option>
|
||||||
|
<option value="P3">P3 - Lage prioriteit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center space-x-2 text-xs text-slate-700 dark:text-slate-300 cursor-pointer pt-1">
|
||||||
|
<input type="checkbox" id="schedBulletSyncOutlook" <?= $isOutlookConnected ? 'checked' : '' ?> class="rounded text-indigo-600 focus:ring-indigo-500 w-4 h-4">
|
||||||
|
<span>Direct synchroniseren naar Microsoft Outlook agenda</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Actieknoppen -->
|
||||||
|
<div class="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="btnSchedBulletUnschedule"
|
||||||
|
onclick="unscheduleCurrentBullet()"
|
||||||
|
class="hidden text-rose-600 hover:text-rose-700 dark:text-rose-400 hover:underline text-xs font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Uit agenda verwijderen
|
||||||
|
</button>
|
||||||
|
<div class="flex space-x-2 ml-auto">
|
||||||
|
<button type="button" onclick="closeBulletScheduleModal()" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold text-xs transition cursor-pointer">
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btnSubmitSchedBullet" onclick="submitBulletSchedule()" class="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs transition shadow-xs flex items-center space-x-1.5 cursor-pointer">
|
||||||
|
<i data-lucide="calendar-check" class="w-4 h-4"></i>
|
||||||
|
<span>In agenda plannen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Jira Ticket Inplannen (met datum, tijdsuggestie & aanpasbare titel) -->
|
<!-- Modal: Jira Ticket Inplannen (met datum, tijdsuggestie & aanpasbare titel) -->
|
||||||
<div id="jiraPlanModal" onclick="if(event.target === this) closeJiraPlanModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
<div id="jiraPlanModal" onclick="if(event.target === this) closeJiraPlanModal()" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm hidden items-center justify-center p-4">
|
||||||
<div class="fociz-card p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl space-y-4">
|
<div class="fociz-card p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl space-y-4">
|
||||||
@@ -5894,6 +6233,567 @@ function autoSaveScratchpad() {
|
|||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bullet Notes & Quick Todo's Logica ---
|
||||||
|
function switchBulletView(view) {
|
||||||
|
const bulletsArea = document.getElementById('bulletListViewArea');
|
||||||
|
const notesArea = document.getElementById('bulletNotesViewArea');
|
||||||
|
const btnBullets = document.getElementById('btnTabBullets');
|
||||||
|
const btnNotes = document.getElementById('btnTabNotes');
|
||||||
|
|
||||||
|
if (!bulletsArea || !notesArea) return;
|
||||||
|
|
||||||
|
if (view === 'bullets') {
|
||||||
|
bulletsArea.classList.remove('hidden');
|
||||||
|
notesArea.classList.add('hidden');
|
||||||
|
if (btnBullets) {
|
||||||
|
btnBullets.classList.add('bg-white', 'dark:bg-[#111624]', 'text-indigo-600', 'dark:text-indigo-400', 'shadow-2xs', 'font-bold');
|
||||||
|
btnBullets.classList.remove('text-slate-500', 'dark:text-slate-400');
|
||||||
|
}
|
||||||
|
if (btnNotes) {
|
||||||
|
btnNotes.classList.remove('bg-white', 'dark:bg-[#111624]', 'text-indigo-600', 'dark:text-indigo-400', 'shadow-2xs', 'font-bold');
|
||||||
|
btnNotes.classList.add('text-slate-500', 'dark:text-slate-400');
|
||||||
|
}
|
||||||
|
localStorage.setItem('fociz_bullet_tab', 'bullets');
|
||||||
|
} else {
|
||||||
|
bulletsArea.classList.add('hidden');
|
||||||
|
notesArea.classList.remove('hidden');
|
||||||
|
if (btnNotes) {
|
||||||
|
btnNotes.classList.add('bg-white', 'dark:bg-[#111624]', 'text-indigo-600', 'dark:text-indigo-400', 'shadow-2xs', 'font-bold');
|
||||||
|
btnNotes.classList.remove('text-slate-500', 'dark:text-slate-400');
|
||||||
|
}
|
||||||
|
if (btnBullets) {
|
||||||
|
btnBullets.classList.remove('bg-white', 'dark:bg-[#111624]', 'text-indigo-600', 'dark:text-indigo-400', 'shadow-2xs', 'font-bold');
|
||||||
|
btnBullets.classList.add('text-slate-500', 'dark:text-slate-400');
|
||||||
|
}
|
||||||
|
localStorage.setItem('fociz_bullet_tab', 'notes');
|
||||||
|
}
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addBulletItem() {
|
||||||
|
const input = document.getElementById('newBulletInput');
|
||||||
|
if (!input) return;
|
||||||
|
const content = input.value.trim();
|
||||||
|
if (!content) {
|
||||||
|
input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('btnAddBullet');
|
||||||
|
const origHtml = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/planner.php?action=add_bullet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
date: typeof activePlannerDate !== 'undefined' ? activePlannerDate : '',
|
||||||
|
content: content
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.bullet) {
|
||||||
|
input.value = '';
|
||||||
|
await reloadBulletList();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Kon notitie niet toevoegen');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Netwerkfout bij toevoegen van notitie');
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBulletItem(bulletId, newStatus) {
|
||||||
|
const textEl = document.getElementById(`bullet-text-${bulletId}`);
|
||||||
|
if (textEl) {
|
||||||
|
if (newStatus === 1) {
|
||||||
|
textEl.classList.add('line-through', 'text-slate-400', 'dark:text-slate-500');
|
||||||
|
textEl.classList.remove('text-slate-900', 'dark:text-slate-100');
|
||||||
|
} else {
|
||||||
|
textEl.classList.remove('line-through', 'text-slate-400', 'dark:text-slate-500');
|
||||||
|
textEl.classList.add('text-slate-900', 'dark:text-slate-100');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/planner.php?action=toggle_bullet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bullet_id: bulletId, is_completed: newStatus })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
await reloadBulletList();
|
||||||
|
} else {
|
||||||
|
await reloadBulletList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await reloadBulletList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditBullet(bulletId) {
|
||||||
|
const textEl = document.getElementById(`bullet-text-${bulletId}`);
|
||||||
|
if (!textEl) return;
|
||||||
|
const currentVal = textEl.innerText.trim();
|
||||||
|
|
||||||
|
const parent = textEl.parentElement;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = currentVal;
|
||||||
|
input.className = 'w-full bg-slate-100 dark:bg-slate-800 border border-indigo-500 rounded-lg px-2 py-1 text-xs text-slate-900 dark:text-white focus:outline-none';
|
||||||
|
|
||||||
|
let isSaving = false;
|
||||||
|
const save = async () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
isSaving = true;
|
||||||
|
const newVal = input.value.trim();
|
||||||
|
if (!newVal || newVal === currentVal) {
|
||||||
|
textEl.style.display = '';
|
||||||
|
input.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetch('/api/planner.php?action=update_bullet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bullet_id: bulletId, content: newVal })
|
||||||
|
});
|
||||||
|
textEl.innerText = newVal;
|
||||||
|
} catch(e) {}
|
||||||
|
textEl.style.display = '';
|
||||||
|
input.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
textEl.style.display = '';
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.onblur = save;
|
||||||
|
|
||||||
|
textEl.style.display = 'none';
|
||||||
|
parent.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBulletItem(bulletId) {
|
||||||
|
if (!confirm('Weet je zeker dat je deze notitie wilt verwijderen? (Indien ingepland in de agenda wordt ook het agendablok verwijderd)')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.getElementById(`bullet-row-${bulletId}`);
|
||||||
|
if (row) row.style.opacity = '0.4';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/planner.php?action=delete_bullet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bullet_id: bulletId, delete_task: true })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
await reloadBulletList();
|
||||||
|
if (typeof switchPlannerDate === 'function') {
|
||||||
|
switchPlannerDate(activePlannerDate, null, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Kon notitie niet verwijderen');
|
||||||
|
if (row) row.style.opacity = '1';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Netwerkfout bij verwijderen');
|
||||||
|
if (row) row.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carryOverOpenBullets() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/planner.php?action=carryover_bullets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_date: activePlannerDate })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (typeof showWidgetToast === 'function') {
|
||||||
|
showWidgetToast(`${data.moved_count} openstaande memo's overgezet naar vandaag!`, 'success');
|
||||||
|
}
|
||||||
|
await reloadBulletList();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Netwerkfout bij overzetten van openstaande memo\'s');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadBulletList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/planner.php?action=get_bullets&date=${encodeURIComponent(activePlannerDate)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
const container = document.getElementById('bulletItemsContainer');
|
||||||
|
const badge = document.getElementById('bulletCountBadge');
|
||||||
|
const carryBanner = document.getElementById('bulletCarryoverBanner');
|
||||||
|
const pastCountEl = document.getElementById('pastOpenBulletCountText');
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
const count = data.bullets.length;
|
||||||
|
badge.innerText = count;
|
||||||
|
badge.className = `ml-1 px-1.5 py-0.2 rounded-full text-[9px] ${count > 0 ? 'bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300' : 'hidden'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carryBanner && pastCountEl) {
|
||||||
|
if (data.past_open_count > 0) {
|
||||||
|
pastCountEl.innerText = data.past_open_count;
|
||||||
|
carryBanner.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
carryBanner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
if (!data.bullets || data.bullets.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div id="bulletEmptyState" class="py-8 text-center text-slate-400 dark:text-slate-500">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800/60 flex items-center justify-center mx-auto mb-2 text-slate-400">
|
||||||
|
<i data-lucide="check-circle" class="w-5 h-5 opacity-60"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-slate-600 dark:text-slate-400">Geen notities of actiepunten voor deze dag.</p>
|
||||||
|
<p class="text-[11px] opacity-75 mt-0.5">Typ hierboven een punt, bijv. "Bespreken met collega".</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
let html = '';
|
||||||
|
data.bullets.forEach(b => {
|
||||||
|
const bId = parseInt(b.id, 10);
|
||||||
|
const isComp = parseInt(b.is_completed, 10) === 1;
|
||||||
|
const isSched = !!b.scheduled_task_id || !!b.scheduled_start_time;
|
||||||
|
const schedTime = b.scheduled_start_time || b.live_start || '';
|
||||||
|
const schedEndTime = b.scheduled_end_time || b.live_end || '';
|
||||||
|
const schedDate = b.scheduled_date || activePlannerDate;
|
||||||
|
const contentEsc = typeof escapeHtml === 'function' ? escapeHtml(b.content) : b.content;
|
||||||
|
const contentAttr = (typeof escapeHtml === 'function' ? escapeHtml(b.content) : b.content).replace(/"/g, '"');
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div id="bullet-row-${bId}" class="group/bullet flex items-center justify-between p-2.5 rounded-xl border ${isComp ? 'bg-slate-50/60 dark:bg-slate-900/30 border-slate-200/60 dark:border-slate-800/50' : 'bg-white/80 dark:bg-[#0c0f17]/70 border-slate-200/90 dark:border-slate-800'} transition shadow-2xs hover:border-indigo-300 dark:hover:border-indigo-500/40">
|
||||||
|
<div class="flex items-center space-x-2.5 min-w-0 flex-1 mr-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="toggleBulletItem(${bId}, ${isComp ? 0 : 1})"
|
||||||
|
title="${isComp ? 'Markeren als niet afgerond' : 'Afvinken'}"
|
||||||
|
class="w-5 h-5 rounded-lg flex items-center justify-center flex-shrink-0 transition cursor-pointer ${isComp ? 'bg-emerald-500 text-white' : 'border border-slate-300 dark:border-slate-700 hover:border-indigo-500 text-transparent hover:text-indigo-400'}"
|
||||||
|
>
|
||||||
|
<i data-lucide="check" class="w-3.5 h-3.5 stroke-[3]"></i>
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
id="bullet-text-${bId}"
|
||||||
|
ondblclick="startEditBullet(${bId})"
|
||||||
|
class="text-xs font-medium select-text break-words ${isComp ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-900 dark:text-slate-100'}"
|
||||||
|
>${contentEsc}</span>
|
||||||
|
|
||||||
|
${isSched ? `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="openBulletScheduleModal(${bId}, '${contentAttr}', '${schedDate}', '${schedTime}', '${schedEndTime}')"
|
||||||
|
title="Ingepland op agenda: ${schedDate} ${schedTime}${schedEndTime ? ' - ' + schedEndTime : ''} (klik om te wijzigen)"
|
||||||
|
class="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md bg-indigo-50 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/30 text-[10px] font-mono font-bold hover:bg-indigo-100 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="calendar" class="w-2.5 h-2.5"></i>
|
||||||
|
<span>${schedDate === activePlannerDate ? 'Vandaag' : schedDate} ${schedTime}${schedEndTime ? '-' + schedEndTime : ''}</span>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1 opacity-80 sm:opacity-0 sm:group-hover/bullet:opacity-100 transition-opacity flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="openBulletScheduleModal(${bId}, '${contentAttr}', '${schedDate}', '${schedTime}', '${schedEndTime}')"
|
||||||
|
title="In agenda inplannen (kies een vrij blok)"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-500/20 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="calendar-plus" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="startEditBullet(${bId})"
|
||||||
|
title="Tekst bewerken"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="deleteBulletItem(${bId})"
|
||||||
|
title="Verwijderen"
|
||||||
|
class="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/20 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Fout bij herladen bullet list:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bullet Agenda Modal Logica ---
|
||||||
|
function openBulletScheduleModal(bulletId, content, scheduledDate, startTime, endTime) {
|
||||||
|
const modal = document.getElementById('bulletScheduleModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
document.getElementById('schedBulletId').value = bulletId;
|
||||||
|
document.getElementById('schedBulletTitle').value = content || '';
|
||||||
|
|
||||||
|
const dateInput = document.getElementById('schedBulletDate');
|
||||||
|
const targetDate = scheduledDate || activePlannerDate;
|
||||||
|
if (dateInput) dateInput.value = targetDate;
|
||||||
|
|
||||||
|
const startInput = document.getElementById('schedBulletStart');
|
||||||
|
const endInput = document.getElementById('schedBulletEnd');
|
||||||
|
if (startInput) startInput.value = startTime || '';
|
||||||
|
if (endInput) endInput.value = endTime || '';
|
||||||
|
|
||||||
|
const unscheduleBtn = document.getElementById('btnSchedBulletUnschedule');
|
||||||
|
if (unscheduleBtn) {
|
||||||
|
if (startTime) {
|
||||||
|
unscheduleBtn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
unscheduleBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
refreshBulletFreeSlots();
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBulletScheduleModal() {
|
||||||
|
const modal = document.getElementById('bulletScheduleModal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSchedBulletDate(preset) {
|
||||||
|
const dateInput = document.getElementById('schedBulletDate');
|
||||||
|
if (!dateInput) return;
|
||||||
|
const now = new Date();
|
||||||
|
if (preset === 'tomorrow') {
|
||||||
|
now.setDate(now.getDate() + 1);
|
||||||
|
}
|
||||||
|
dateInput.value = now.toISOString().slice(0, 10);
|
||||||
|
refreshBulletFreeSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSchedBulletDuration(minutes, btn) {
|
||||||
|
document.getElementById('schedBulletDuration').value = minutes;
|
||||||
|
document.querySelectorAll('.btn-sched-dur').forEach(b => {
|
||||||
|
b.className = 'btn-sched-dur py-1.5 rounded-xl border border-slate-200 dark:border-slate-800 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:border-indigo-500 transition cursor-pointer text-center';
|
||||||
|
});
|
||||||
|
if (btn) {
|
||||||
|
btn.className = 'btn-sched-dur py-1.5 rounded-xl border border-indigo-500 bg-indigo-50 dark:bg-indigo-500/20 text-xs font-bold text-indigo-700 dark:text-indigo-300 transition cursor-pointer text-center';
|
||||||
|
}
|
||||||
|
onSchedBulletTimeChange();
|
||||||
|
refreshBulletFreeSlots();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSchedBulletTimeChange() {
|
||||||
|
const startVal = document.getElementById('schedBulletStart')?.value.trim();
|
||||||
|
const duration = parseInt(document.getElementById('schedBulletDuration')?.value || 30, 10);
|
||||||
|
const endInput = document.getElementById('schedBulletEnd');
|
||||||
|
if (!startVal || !endInput) return;
|
||||||
|
const parts = startVal.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const h = parseInt(parts[0], 10);
|
||||||
|
const m = parseInt(parts[1], 10);
|
||||||
|
if (!isNaN(h) && !isNaN(m)) {
|
||||||
|
const totalM = h * 60 + m + duration;
|
||||||
|
const endH = Math.floor(totalM / 60) % 24;
|
||||||
|
const endM = totalM % 60;
|
||||||
|
endInput.value = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBulletFreeSlots() {
|
||||||
|
const container = document.getElementById('schedBulletSlotsList');
|
||||||
|
if (!container) return;
|
||||||
|
const date = document.getElementById('schedBulletDate')?.value || activePlannerDate;
|
||||||
|
const duration = parseInt(document.getElementById('schedBulletDuration')?.value || 30, 10);
|
||||||
|
|
||||||
|
container.innerHTML = '<span class="text-xs text-slate-400 italic">Vrije blokken zoeken...</span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/planner.php?action=get_day_free_slots&date=${encodeURIComponent(date)}&duration_minutes=${duration}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.free_slots && data.free_slots.length > 0) {
|
||||||
|
let html = '';
|
||||||
|
data.free_slots.forEach((slot, idx) => {
|
||||||
|
const isFirst = (idx === 0);
|
||||||
|
html += `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="selectSchedBulletSlot('${slot.start}', '${slot.end}', this)"
|
||||||
|
class="slot-btn px-2.5 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 hover:border-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-500/20 text-xs font-semibold text-slate-700 dark:text-slate-300 transition cursor-pointer flex items-center space-x-1.5"
|
||||||
|
>
|
||||||
|
${isFirst ? '<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>' : '<i data-lucide="clock" class="w-3 h-3 text-indigo-400"></i>'}
|
||||||
|
<span>${slot.start} - ${slot.end}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<span class="text-xs text-slate-400 italic">Geen volledig vrije blokken binnen werktijden op deze dag. Kies hieronder handmatig een tijdstip.</span>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
container.innerHTML = '<span class="text-xs text-slate-400 italic">Kon vrije blokken niet laden.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSchedBulletSlot(start, end, btn) {
|
||||||
|
const startInput = document.getElementById('schedBulletStart');
|
||||||
|
const endInput = document.getElementById('schedBulletEnd');
|
||||||
|
if (startInput) startInput.value = start;
|
||||||
|
if (endInput) endInput.value = end;
|
||||||
|
|
||||||
|
document.querySelectorAll('.slot-btn').forEach(b => {
|
||||||
|
b.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-600');
|
||||||
|
b.classList.add('text-slate-700', 'dark:text-slate-300');
|
||||||
|
});
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.remove('text-slate-700', 'dark:text-slate-300');
|
||||||
|
btn.classList.add('bg-indigo-600', 'text-white', 'border-indigo-600');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBulletSchedule() {
|
||||||
|
const bulletId = parseInt(document.getElementById('schedBulletId')?.value || 0, 10);
|
||||||
|
const title = document.getElementById('schedBulletTitle')?.value.trim();
|
||||||
|
const date = document.getElementById('schedBulletDate')?.value;
|
||||||
|
const startTime = document.getElementById('schedBulletStart')?.value.trim();
|
||||||
|
const endTime = document.getElementById('schedBulletEnd')?.value.trim();
|
||||||
|
const duration = parseInt(document.getElementById('schedBulletDuration')?.value || 30, 10);
|
||||||
|
const priority = document.getElementById('schedBulletPriority')?.value || 'P2';
|
||||||
|
const syncOutlook = document.getElementById('schedBulletSyncOutlook')?.checked || false;
|
||||||
|
|
||||||
|
if (!bulletId) {
|
||||||
|
alert('Geen notitie geselecteerd');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!startTime) {
|
||||||
|
alert('Kies of vul een starttijd in voor het agendablok');
|
||||||
|
document.getElementById('schedBulletStart')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('btnSubmitSchedBullet');
|
||||||
|
const origHtml = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = '<span>Bezig met inplannen...</span>';
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/planner.php?action=schedule_bullet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
bullet_id: bulletId,
|
||||||
|
title: title,
|
||||||
|
date: date,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
duration_minutes: duration,
|
||||||
|
priority: priority,
|
||||||
|
sync_to_outlook: syncOutlook
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
closeBulletScheduleModal();
|
||||||
|
if (typeof showWidgetToast === 'function') {
|
||||||
|
showWidgetToast(`Notitie succesvol ingepland op ${startTime} - ${endTime}!`, 'success');
|
||||||
|
}
|
||||||
|
if (typeof switchPlannerDate === 'function') {
|
||||||
|
switchPlannerDate(activePlannerDate, null, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Kon notitie niet inplannen in agenda');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Netwerkfout bij inplannen van agendablok');
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unscheduleCurrentBullet() {
|
||||||
|
const bulletId = parseInt(document.getElementById('schedBulletId')?.value || 0, 10);
|
||||||
|
if (!bulletId) return;
|
||||||
|
|
||||||
|
if (!confirm('Weet je zeker dat je dit agendablok wilt verwijderen uit je agenda? De notitie blijft bewaard.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/planner.php?action=unschedule_bullet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bullet_id: bulletId })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
closeBulletScheduleModal();
|
||||||
|
if (typeof showWidgetToast === 'function') {
|
||||||
|
showWidgetToast('Agendablok verwijderd uit planning', 'info');
|
||||||
|
}
|
||||||
|
if (typeof switchPlannerDate === 'function') {
|
||||||
|
switchPlannerDate(activePlannerDate, null, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Kon agendablok niet verwijderen');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Netwerkfout bij verwijderen uit agenda');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiseer tab weergave
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
try {
|
||||||
|
const savedTab = localStorage.getItem('fociz_bullet_tab');
|
||||||
|
if (savedTab === 'notes') {
|
||||||
|
switchBulletView('notes');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Habit Tracker Logica ---
|
// --- Habit Tracker Logica ---
|
||||||
function updateHabitCount() {
|
function updateHabitCount() {
|
||||||
const checkboxes = document.querySelectorAll('#habitTrackerWidget input[type="checkbox"]');
|
const checkboxes = document.querySelectorAll('#habitTrackerWidget input[type="checkbox"]');
|
||||||
@@ -6237,6 +7137,9 @@ async function switchPlannerDate(newDate, event = null, pushState = true) {
|
|||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
initPlannerWidgets();
|
initPlannerWidgets();
|
||||||
|
if (localStorage.getItem('fociz_bullet_tab') === 'notes') {
|
||||||
|
switchBulletView('notes');
|
||||||
|
}
|
||||||
if (document.getElementById('plannerJiraListContainer')) {
|
if (document.getElementById('plannerJiraListContainer')) {
|
||||||
filterPlannerJira();
|
filterPlannerJira();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -136,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
elseif ($action === 'save_personal_ai') {
|
elseif ($action === 'save_personal_ai') {
|
||||||
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
|
||||||
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
|
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
|
||||||
$aiModel = trim($_POST['ai_model'] ?? 'gemini-1.5-pro');
|
$aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
|
||||||
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
|
$apiKeyIn = trim($_POST['ai_api_key'] ?? '');
|
||||||
|
|
||||||
// Haal eventueel bestaande integratie op
|
// Haal eventueel bestaande integratie op
|
||||||
@@ -977,9 +977,9 @@ require_once __DIR__ . '/includes/header.php';
|
|||||||
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
|
||||||
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||||
<optgroup label="Google Gemini (Aanbevolen)">
|
<optgroup label="Google Gemini (Aanbevolen)">
|
||||||
<option value="gemini-1.5-pro" <?= ($userAiSettings['ai_model'] === 'gemini-1.5-pro' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Aanbevolen)</option>
|
<option value="gemini-2.0-flash" <?= ($userAiSettings['ai_model'] === 'gemini-2.0-flash' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
|
||||||
<option value="gemini-2.0-flash" <?= $userAiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Razendsnel)</option>
|
|
||||||
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
|
||||||
|
<option value="gemini-1.5-pro" <?= $userAiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="OpenAI">
|
<optgroup label="OpenAI">
|
||||||
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
|
||||||
|
|||||||
Reference in New Issue
Block a user