diff --git a/public/account.php b/public/account.php
index ee2fd47..2794e89 100644
--- a/public/account.php
+++ b/public/account.php
@@ -28,6 +28,9 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
.team-table th,.team-table td{padding:.35rem .5rem;border-bottom:1px solid #e2e8f0;text-align:left}
.badge{display:inline-flex;align-items:center;padding:.1rem .5rem;border-radius:999px;font-size:.75rem;background:#e2e8f0;color:#0f172a}
.chip{display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:999px;background:#f1f5f9;color:#0f172a;border:1px solid #e2e8f0;font-size:.8rem}
+ .user-menu{position:absolute;top:calc(100% + .5rem);right:0;min-width:180px;background:#fff;border:1px solid #e2e8f0;border-radius:.75rem;box-shadow:0 20px 35px rgba(15,23,42,.15);padding:.35rem;z-index:40}
+ .user-menu-item{display:block;width:100%;text-align:left;padding:.45rem .75rem;border-radius:.6rem;font-size:.9rem;color:#0f172a}
+ .user-menu-item:hover{background:#f1f5f9}
@@ -36,8 +39,17 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
← Übersicht
Mein Konto
@@ -46,9 +58,6 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
-
-
-
@@ -81,126 +90,8 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
-
-
-
Team
-
-
-
-
-
- | Name | E-Mail | Rolle | Status | Aktionen |
-
-
-
-
-
-
-
-
-
-
Absender für Testmails
-
-
-
-
-
- | Bezeichnung | From-Name | E-Mail | Reply-To | Aktionen |
-
-
-
-
-
-
-
-
- Integrationen & Tokens
-
+
+ Teammitglieder, Absender und Integrationen verwaltest du jetzt im neuen Bereich Administration. Öffne ihn über das Avatar-Menü oben rechts.
diff --git a/public/admin.php b/public/admin.php
new file mode 100644
index 0000000..6770689
--- /dev/null
+++ b/public/admin.php
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+ Email Template System – Administration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Integrationen, Downloads & Tokens
+ Die Dateien enthalten automatisch deine aktuellen Tokens. Nach dem Speichern neuer Tokens bitte die Dateien erneut herunterladen.
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/js/account.js b/public/assets/js/account.js
index 9d108ab..d58ce05 100644
--- a/public/assets/js/account.js
+++ b/public/assets/js/account.js
@@ -2,6 +2,8 @@ import { apiAction } from './api.js';
import { initUserPanel, initAccountPage } from './ui-user.js';
import { mountLogoutButton, ensureFloatingLogout } from './ui-auth.js';
+const pageType = document.body?.dataset?.page || 'account';
+
async function ensureAuthenticated() {
try {
const me = await apiAction('auth.me', { method: 'GET' });
@@ -17,9 +19,19 @@ async function ensureAuthenticated() {
}
}
+function ensureAccess() {
+ const role = (window.__currentUser?.role || '').toLowerCase();
+ if (pageType === 'admin' && role !== 'owner' && role !== 'admin') {
+ window.location.href = '/account.php';
+ return false;
+ }
+ return true;
+}
+
document.addEventListener('DOMContentLoaded', async () => {
const ok = await ensureAuthenticated();
if (!ok) return;
+ if (!ensureAccess()) return;
initUserPanel();
initAccountPage();
mountLogoutButton('#btn-logout', { redirect: '/login.php' });
diff --git a/public/assets/js/dashboard.js b/public/assets/js/dashboard.js
new file mode 100644
index 0000000..39197aa
--- /dev/null
+++ b/public/assets/js/dashboard.js
@@ -0,0 +1,140 @@
+import { apiAction, toast } from './api.js';
+import { initUserPanel } from './ui-user.js';
+import { mountLogoutButton, ensureFloatingLogout } from './ui-auth.js';
+
+const state = {
+ counts: { templates: 0, sections: 0, blocks: 0, snippets: 0, renders_total: 0 },
+ usage: [],
+};
+
+async function ensureAuthenticated() {
+ try {
+ const me = await apiAction('auth.me', { method: 'GET' });
+ if (!me?.ok || !me?.user) {
+ window.location.href = '/login.php';
+ return false;
+ }
+ window.__currentUser = me.user;
+ document.documentElement.classList.remove('auth-pending');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function ensureAccess() {
+ const role = (window.__currentUser?.role || '').toLowerCase();
+ if (role !== 'owner' && role !== 'admin') {
+ toast('Kein Zugriff auf das Dashboard', false);
+ window.location.href = '/account.php';
+ return false;
+ }
+ return true;
+}
+
+function renderCounts(counts) {
+ const mapping = {
+ templates: 'count-templates',
+ sections: 'count-sections',
+ blocks: 'count-blocks',
+ snippets: 'count-snippets',
+ renders_total: 'count-usage',
+ };
+ Object.entries(mapping).forEach(([key, id]) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ const value = counts[key] ?? 0;
+ el.textContent = typeof value === 'number' ? value.toLocaleString('de-DE') : value;
+ });
+}
+
+function formatDate(value) {
+ if (!value) return '–';
+ try {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString('de-DE');
+ } catch {
+ return value;
+ }
+}
+
+function renderUsage(list) {
+ const table = document.getElementById('usageTable');
+ if (!table) return;
+ const tbody = table.querySelector('tbody');
+ if (!tbody) return;
+ if (!list.length) {
+ tbody.innerHTML = '| Noch keine Daten vorhanden. |
';
+ return;
+ }
+ tbody.innerHTML = list.map(item => `
+
+ | ${escapeHtml(item.name)} |
+ ${item.render_count.toLocaleString('de-DE')} |
+ ${escapeHtml(formatDate(item.last_rendered_at || item.updated_at))} |
+
+
+ |
+
+ `).join('');
+}
+
+function escapeHtml(str) {
+ return String(str ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+async function loadMetrics() {
+ try {
+ const res = await apiAction('dashboard.metrics', { method: 'GET' });
+ if (!res?.ok) throw new Error(res?.error || 'Dashboard konnte nicht geladen werden');
+ state.counts = res.counts || state.counts;
+ state.usage = Array.isArray(res.usage) ? res.usage : [];
+ renderCounts(state.counts);
+ renderUsage(state.usage);
+ } catch (err) {
+ toast(err.message || 'Fehler beim Laden', false);
+ }
+}
+
+async function resetUsage(templateId) {
+ try {
+ await apiAction('dashboard.reset_usage', { method: 'POST', data: { template_id: templateId } });
+ toast('Zähler zurückgesetzt', true);
+ await loadMetrics();
+ } catch (err) {
+ toast(err.message || 'Zurücksetzen fehlgeschlagen', false);
+ }
+}
+
+function bindEvents() {
+ const refresh = document.getElementById('btn-refresh-dashboard');
+ refresh?.addEventListener('click', () => loadMetrics());
+
+ const table = document.getElementById('usageTable');
+ table?.addEventListener('click', ev => {
+ const btn = ev.target.closest('button[data-reset]');
+ if (!btn) return;
+ const id = Number(btn.getAttribute('data-reset'));
+ if (!id) return;
+ if (confirm('Zähler für dieses Template wirklich löschen?')) {
+ resetUsage(id);
+ }
+ });
+}
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const ok = await ensureAuthenticated();
+ if (!ok) return;
+ if (!ensureAccess()) return;
+ initUserPanel();
+ bindEvents();
+ await loadMetrics();
+ mountLogoutButton('#btn-logout', { redirect: '/login.php' });
+ ensureFloatingLogout({ redirect: '/login.php' });
+});
diff --git a/public/assets/js/ui-user.js b/public/assets/js/ui-user.js
index ad4c6a1..cb21ae6 100644
--- a/public/assets/js/ui-user.js
+++ b/public/assets/js/ui-user.js
@@ -13,6 +13,7 @@ const state = {
};
let avatarBtn;
+let userMenuPanel;
let profileForm;
let passwordForm;
let settingsForm;
@@ -22,10 +23,21 @@ let senderTable;
let senderForm;
let bridgePreview;
let validateBridgeBtn;
+let menuInitialized = false;
+let menuOpen = false;
export function initUserPanel() {
avatarBtn = document.getElementById('btn-user');
+ userMenuPanel = document.getElementById('userMenuPanel');
updateAvatar();
+ updateRoleVisibility();
+ if (!menuInitialized && avatarBtn && userMenuPanel) {
+ avatarBtn.addEventListener('click', toggleUserMenu);
+ document.addEventListener('click', handleDocumentClick, true);
+ document.addEventListener('keydown', handleMenuKeydown);
+ userMenuPanel.addEventListener('click', handleMenuItemClick);
+ menuInitialized = true;
+ }
}
export function initAccountPage() {
@@ -58,22 +70,24 @@ export function initAccountPage() {
btn.addEventListener('click', () => switchTab(btn.getAttribute('data-user-tab')));
});
- settingsForm?.querySelectorAll('button[data-rotate]').forEach(btn => {
- btn.addEventListener('click', () => {
- const type = btn.getAttribute('data-rotate');
- if (type && state.rotate[type] !== undefined) {
- state.rotate[type] = true;
- toast('Token wird nach dem Speichern erneuert.', true, { duration: 2000 });
- }
+ if (settingsForm) {
+ settingsForm.querySelectorAll('button[data-rotate]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const type = btn.getAttribute('data-rotate');
+ if (type && state.rotate[type] !== undefined) {
+ state.rotate[type] = true;
+ toast('Token wird nach dem Speichern erneuert.', true, { duration: 2000 });
+ }
+ });
});
- });
- settingsForm?.querySelectorAll('button[data-download]').forEach(btn => {
- btn.addEventListener('click', () => {
- const type = btn.getAttribute('data-download');
- if (type) downloadFile(type);
+ settingsForm.querySelectorAll('button[data-download]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const type = btn.getAttribute('data-download');
+ if (type) downloadFile(type);
+ });
});
- });
+ }
switchTab(state.currentTab);
loadAccountData();
@@ -96,6 +110,40 @@ function updateAvatar() {
target.textContent = name ? name.trim().charAt(0).toUpperCase() : 'U';
}
+function toggleUserMenu(ev) {
+ ev?.preventDefault();
+ if (!userMenuPanel || !avatarBtn) return;
+ menuOpen = !menuOpen;
+ userMenuPanel.classList.toggle('hidden', !menuOpen);
+ avatarBtn.setAttribute('aria-expanded', menuOpen ? 'true' : 'false');
+}
+
+function closeUserMenu() {
+ if (!menuOpen) return;
+ menuOpen = false;
+ if (userMenuPanel) userMenuPanel.classList.add('hidden');
+ if (avatarBtn) avatarBtn.setAttribute('aria-expanded', 'false');
+}
+
+function handleDocumentClick(ev) {
+ if (!userMenuPanel || !avatarBtn || !menuOpen) return;
+ const target = ev.target;
+ if (avatarBtn.contains(target) || userMenuPanel.contains(target)) return;
+ closeUserMenu();
+}
+
+function handleMenuKeydown(ev) {
+ if (ev.key === 'Escape') {
+ closeUserMenu();
+ }
+}
+
+function handleMenuItemClick(ev) {
+ const item = ev.target.closest('.user-menu-item');
+ if (!item) return;
+ closeUserMenu();
+}
+
function updateRoleVisibility() {
const role = (window.__currentUser?.role || '').toLowerCase();
document.querySelectorAll('[data-role]').forEach(el => {
@@ -139,15 +187,17 @@ async function loadAccountData() {
}
fillProfileForm(res.user);
fillSettingsForm(res.settings || {});
- if (isOwner()) {
+ if (teamTable && isOwner()) {
await loadUsers();
}
- if (isAdmin()) {
- await loadSenders();
- } else {
- state.senders = [];
- state.senderMap = new Map();
- renderSenderList();
+ if (senderTable) {
+ if (isAdmin()) {
+ await loadSenders();
+ } else {
+ state.senders = [];
+ state.senderMap = new Map();
+ renderSenderList();
+ }
}
} catch (err) {
console.error(err);
diff --git a/public/dashboard.php b/public/dashboard.php
new file mode 100644
index 0000000..2405aad
--- /dev/null
+++ b/public/dashboard.php
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+ Email Template System – Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Templates
+ –
+
+
+
Sections
+ –
+
+
+
Blocks
+ –
+
+
+
Snippets
+ –
+
+
+
Aufrufe gesamt
+ –
+
+
+
+
+
+
+
Template-Nutzung
+
Wie oft wurden Templates über die API geladen? Setze einzelne Zähler bei Bedarf zurück.
+
+
+
+
+
+
+
+ | Template |
+ Aufrufe |
+ Zuletzt verwendet |
+ Aktionen |
+
+
+
+ | Lade Daten… |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/index.php b/public/index.php
index e5a88bc..6609cbf 100644
--- a/public/index.php
+++ b/public/index.php
@@ -40,6 +40,9 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
.section-card{background:#fff;border:1px solid #e2e8f0;border-radius:1rem;padding:1rem;margin-bottom:1.25rem}
.section-card h4{margin:0 0 .75rem;font-size:1rem;font-weight:600;color:#0f172a}
.input{width:100%;border:1px solid #cbd5f5;border-radius:.5rem;padding:.5rem .75rem}
+ .user-menu{position:absolute;top:calc(100% + .5rem);right:0;min-width:180px;background:#fff;border:1px solid #e2e8f0;border-radius:.75rem;box-shadow:0 20px 35px rgba(15,23,42,.15);padding:.35rem;z-index:50}
+ .user-menu-item{display:block;width:100%;text-align:left;padding:.45rem .75rem;border-radius:.6rem;font-size:.9rem;color:#0f172a}
+ .user-menu-item:hover{background:#f1f5f9}
@@ -52,11 +55,19 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
-
diff --git a/src/ApiKernel.php b/src/ApiKernel.php
index 6093824..e399100 100644
--- a/src/ApiKernel.php
+++ b/src/ApiKernel.php
@@ -26,6 +26,7 @@ class ApiKernel
private string $action;
private array $tableMap;
private AuthService $authService;
+ private array $tableExistsCache = [];
// --- Initialisierung & Konstruktor (Optimiert) ---
@@ -184,6 +185,21 @@ class ApiKernel
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
return $cols;
}
+
+ private function tableExists(string $table): bool
+ {
+ if ($table === '') return false;
+ if (array_key_exists($table, $this->tableExistsCache)) {
+ return $this->tableExistsCache[$table];
+ }
+ try {
+ $this->pdo->query("SELECT 1 FROM `$table` LIMIT 1");
+ $this->tableExistsCache[$table] = true;
+ } catch (Throwable $e) {
+ $this->tableExistsCache[$table] = false;
+ }
+ return $this->tableExistsCache[$table];
+ }
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
$stmt->execute();
@@ -907,6 +923,114 @@ class ApiKernel
}
}
+ private function fetchResourceCounts(int $customerId): array
+ {
+ $counts = [
+ 'templates' => 0,
+ 'sections' => 0,
+ 'blocks' => 0,
+ 'snippets' => 0,
+ 'renders_total' => 0,
+ ];
+
+ $map = $this->tableMap ?? [];
+ foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) {
+ $table = $map[$kind] ?? null;
+ if (!$table || !$this->tableExists($table)) continue;
+ $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid");
+ $stmt->execute([':cid' => $customerId]);
+ $counts[$kind] = (int)($stmt->fetchColumn() ?: 0);
+ }
+
+ $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
+ if ($this->tableExists($usageTable)) {
+ $stmt = $this->pdo->prepare("SELECT SUM(`render_count`) FROM `$usageTable` WHERE `customer_id` = :cid");
+ $stmt->execute([':cid' => $customerId]);
+ $counts['renders_total'] = (int)($stmt->fetchColumn() ?: 0);
+ }
+
+ return $counts;
+ }
+
+ private function listTemplateUsage(int $customerId): array
+ {
+ $table = $this->tableMap['templates'] ?? null;
+ if (!$table || !$this->tableExists($table)) {
+ return [];
+ }
+
+ $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
+ if ($this->tableExists($usageTable)) {
+ $sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
+ FROM `$table` t
+ LEFT JOIN `$usageTable` u ON u.template_id = t.id
+ WHERE t.customer_id = :cid
+ ORDER BY render_count DESC, t.updated_at DESC";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([':cid' => $customerId]);
+ $rows = $stmt->fetchAll() ?: [];
+ } else {
+ $sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([':cid' => $customerId]);
+ $rows = $stmt->fetchAll() ?: [];
+ foreach ($rows as &$row) {
+ $row['render_count'] = 0;
+ $row['last_rendered_at'] = null;
+ }
+ }
+
+ return array_map(static function ($row) {
+ return [
+ 'template_id' => (int)($row['id'] ?? 0),
+ 'name' => $row['name'] ?? '',
+ 'render_count' => (int)($row['render_count'] ?? 0),
+ 'last_rendered_at' => $row['last_rendered_at'] ?? null,
+ 'updated_at' => $row['updated_at'] ?? null,
+ ];
+ }, $rows);
+ }
+
+ private function resetTemplateUsage(int $customerId, array $templateIds): void
+ {
+ $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
+ if (!$templateIds || !$this->tableExists($usageTable)) {
+ return;
+ }
+ $templateIds = array_values(array_unique(array_filter(array_map('intval', $templateIds), static fn ($v) => $v > 0)));
+ if (!$templateIds) return;
+
+ $placeholders = implode(',', array_fill(0, count($templateIds), '?'));
+ $sql = "DELETE FROM `$usageTable` WHERE `customer_id` = ? AND `template_id` IN ($placeholders)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(array_merge([$customerId], $templateIds));
+ }
+
+ private function extractIdList($raw): array
+ {
+ if ($raw === null) return [];
+ if (is_numeric($raw)) {
+ $raw = [(int)$raw];
+ } elseif (is_string($raw)) {
+ $raw = preg_split('/[\s,]+/', $raw);
+ } elseif (!is_array($raw)) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($raw as $value) {
+ if (is_array($value)) {
+ $ids = array_merge($ids, $this->extractIdList($value));
+ continue;
+ }
+ if ($value === '' || $value === null) continue;
+ $ids[] = (int)$value;
+ }
+
+ $ids = array_values(array_unique(array_filter($ids, static fn ($v) => $v > 0)));
+ return $ids;
+ }
+
private function calculateUsage(string $kind, int $id, array $auth): array
{
if ($id <= 0) return ['total' => 0];