Upload via GUI (335 Dateien)

This commit is contained in:
2026-10-24 20:14:39 +00:00
parent b347bd8405
commit f189918dc4
8 changed files with 239 additions and 27 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ plugins/mein-plugin/
}
```
`permissions` nennt die Erweiterungspunkte, die das Plugin nutzt (`hooks.tickets`, `hooks.cron`, `ui.menu`, `ui.sidebar`, `api.endpoints`, `db.migrations`). Der Admin bestätigt sie bei der Aktivierung; nicht deklarierte Erweiterungspunkte werden abgelehnt. Das ist keine Sandbox. Vollständige Dokumentation inklusive Signieren: README.md im Hauptverzeichnis.
`permissions` nennt die Erweiterungspunkte, die das Plugin nutzt (`hooks.tickets`, `hooks.cron`, `ui.menu`, `ui.sidebar`, `api.endpoints`, `api.public`, `db.migrations`). Der Admin bestätigt sie bei der Aktivierung; nicht deklarierte Erweiterungspunkte werden abgelehnt. Das ist keine Sandbox. Die Hooks `cron.minute` (etwa jede Minute, für kurze Prüfabstände) und `cron.run` (höchstens alle 5 Minuten), beide mit der Berechtigung `hooks.cron`, werden ausgelöst vom echten Server-Cronjob (`cron/run_plugins.php`, wird unter Einstellungen → Cron-Jobs eingetragen) und vom eingebauten Hintergrund-Betrieb, nie doppelt. Der Filter `staff.page.top` (Berechtigung `ui.sidebar`) ergänzt einen Hinweis oben auf jeder Seite des Mitarbeiterbereichs (fertiges HTML; Kontext: `staff`, `activePage`). Vollständige Dokumentation inklusive Signieren: README.md im Hauptverzeichnis.
## Erweiterungspunkte
@@ -45,6 +45,7 @@ $pageTitle = 'Passwort vergessen';
<?= site_icon_links() ?>
</head>
<body>
<?= i18n_switcher_html('floating') ?>
<div class="auth-wrap">
<div class="auth-card">
<?php require __DIR__ . '/../includes/brand_block.php'; ?>
+85
View File
@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin();
$pdo = get_db();
$langs = i18n_languages(true);
// Vorlage für Übersetzer: alle gesammelten deutschen Texte, bereits Übersetztes ist eingetragen, der Rest leer
if (isset($_GET['download'])) {
$code = strtolower((string) $_GET['download']);
$dictFile = $langs[$code]['strings'] ?? '';
$existing = $dictFile !== '' && is_file($dictFile) ? (json_decode((string) file_get_contents($dictFile), true) ?: []) : [];
$out = [];
foreach ($pdo->query('SELECT text FROM i18n_missing ORDER BY hits DESC, text')->fetchAll(PDO::FETCH_COLUMN) as $t) $out[$t] = '';
foreach ($existing as $k => $v) if (is_string($v) && ($k === '' || $k[0] !== '_')) $out[$k] = $v;
$out = ['_info' => 'Schlüssel = deutscher Text, Wert = Übersetzung. {} steht für wechselnde Teile. Leere Werte werden ignoriert.'] + $out;
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="strings-' . preg_replace('/[^a-z\-]/', '', $code) . '.json"');
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'save') {
$def = strtolower((string) ($_POST['default_language'] ?? 'de'));
set_setting('default_language', isset($langs[$def]) ? $def : 'de');
set_setting('i18n_collect', !empty($_POST['i18n_collect']) ? '1' : '0');
audit_log($admin['id'], 'Sprach-Einstellungen geändert');
flash('success', 'Gespeichert.');
} elseif ($action === 'clear') {
$pdo->exec('DELETE FROM i18n_missing');
flash('success', 'Gesammelte Texte gelöscht.');
}
redirect('/staff/languages.php');
}
$collected = (int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn();
$collect = get_setting('i18n_collect', '0') === '1';
$pageTitle = 'Sprachen';
$activePage = 'languages';
require __DIR__ . '/_header.php';
?>
<h1>Sprachen</h1>
<p class="muted" style="max-width:860px">Jede Sprache ist ein Plugin („Sprachpaket“): Deutsch ist eingebaut, weitere Sprachen installieren Sie unter Plugins. Besucher und Mitarbeiter wechseln die Sprache über die Flaggen. Texte ohne Übersetzung bleiben deutsch.</p>
<div class="card" style="max-width:860px;margin-bottom:16px">
<h3>Installierte Sprachen</h3>
<?php foreach ($langs as $code => $l): $n = $code === 'de' ? null : i18n_dictionary($code)['count']; ?>
<div style="display:flex;gap:12px;align-items:center;padding:8px 0;border-top:1px solid var(--border,#e3e9ee)">
<?php if ($l['flag'] !== ''): ?><img src="<?= e($l['flag']) ?>" alt="" width="30" height="20" style="width:30px;height:20px;object-fit:cover;border-radius:3px;border:1px solid rgba(0,0,0,.15)"><?php endif; ?>
<div style="flex:1"><strong><?= e($l['name']) ?></strong> <span class="muted" style="font-size:.8rem"><?= e($code) ?><?= $l['slug'] !== '' ? ' · Plugin ' . e($l['slug']) : ' · eingebaut (Quellsprache)' ?></span></div>
<div class="muted" style="font-size:.85rem"><?= $n === null ? '' : $n . ' Übersetzungen' ?></div>
<a class="btn secondary" style="padding:3px 12px;font-size:.8rem" href="/staff/languages.php?download=<?= e($code) ?>"><?= $code === 'de' ? 'Leere Vorlage laden' : 'Vorlage laden' ?></a>
</div>
<?php endforeach; ?>
<?php if (count($langs) < 2): ?><p class="muted" style="margin-top:8px">Noch kein Sprachpaket aktiv. Installieren Sie zum Beispiel „English“ unter <a href="/staff/plugins.php">Plugins</a>; dann erscheinen die Flaggen.</p><?php endif; ?>
</div>
<form method="post" class="card" style="max-width:860px;margin-bottom:16px"><?= csrf_field() ?><input type="hidden" name="action" value="save">
<h3>Einstellungen</h3>
<label for="default_language">Standardsprache (für alle, die noch keine Flagge gewählt haben)</label>
<select id="default_language" name="default_language" style="max-width:260px"><?php foreach ($langs as $code => $l): ?><option value="<?= e($code) ?>" <?= i18n_default() === $code ? 'selected' : '' ?>><?= e($l['name']) ?></option><?php endforeach; ?></select>
<label style="display:flex;gap:8px;align-items:flex-start;margin-top:12px;font-weight:400"><input type="checkbox" name="i18n_collect" value="1" style="width:auto;margin-top:3px" <?= $collect ? 'checked' : '' ?>>
<span><strong>Sammelmodus für Übersetzer:</strong> merkt sich die Texte aller besuchten Seiten (auch Kundenportal und Mitarbeiterbereich). Klicken Sie die Seiten durch, laden Sie danach die Vorlage. Bitte nach dem Sammeln wieder ausschalten, er verlangsamt die Seiten etwas.</span></label>
<div class="btn-row"><button class="btn" type="submit">Speichern</button></div>
</form>
<div class="card" style="max-width:860px;margin-bottom:16px">
<h3>Gesammelte Texte: <?= $collected ?></h3>
<p class="muted" style="font-size:.88rem">Das sind deutsche Texte, die der Sammelmodus gesehen hat. Sie bilden die Vorlage für ein neues Sprachpaket.</p>
<?php if ($collected): ?><form method="post" onsubmit="return confirm('Gesammelte Texte löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="clear"><button class="btn secondary" type="submit">Alle löschen</button></form><?php endif; ?>
</div>
<div class="card" style="max-width:860px">
<h3>Eigene Sprache als Plugin erstellen</h3>
<ol style="line-height:1.7;padding-left:20px;font-size:.92rem">
<li>Sammelmodus einschalten, die Seiten durchklicken, dann bei Deutsch „Leere Vorlage laden“ (alle gesammelten Texte, noch ohne Übersetzung). Für Ergänzungen zu einer vorhandenen Sprache bei dieser „Vorlage laden“: bereits Übersetztes ist eingetragen.</li>
<li>In der Datei <code>strings.json</code> die Werte übersetzen. Der Schlüssel ist der deutsche Text, <code>{}</code> steht für wechselnde Teile („Willkommen, {}“ → „Welcome, {}“).</li>
<li>Ordner mit diesen Dateien packen: <code>plugin.json</code>, <code>plugin.php</code> (kann leer sein), <code>flag.svg</code> (Flagge, höchstens 40 KB), <code>strings.json</code>.</li>
<li>Die <code>plugin.json</code> sieht so aus: <code>{"name":"Français","version":"1.0.0","entry":"plugin.php","language":{"code":"fr","name":"Français","flag":"flag.svg","strings":"strings.json"}}</code></li>
<li>ZIP unter Plugins hochladen und aktivieren (das Sprachpaket braucht keine Berechtigungen). Die Flagge erscheint sofort.</li>
</ol>
</div>
<?php require __DIR__ . '/_footer.php'; ?>
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* staff/live_tickets.php
*
* Leichter Abruf für die Live-Anzeige im Mitarbeiterbereich (assets/js/live-tickets.js): aktuelle Kennzahlen und Tickets, die seit der
* zuletzt gesehenen Ticket-ID neu angelegt wurden. Jeder sieht nur Tickets seiner Abteilungen.
* ?since=0 liefert nur den Ausgangsstand (keine „neuen“ Tickets), damit beim ersten Aufruf nichts aufpoppt.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = current_staff();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
if (!$staff) {
http_response_code(401);
echo json_encode(['error' => 'login']);
exit;
}
// Abgelaufene Sitzung (Inaktivität) gilt auch hier als abgemeldet; dieser Abruf verlängert die Sitzung nie
$__timeout = (int) (get_setting('staff_session_timeout', '30') ?: 30);
$__last = (int) ($_SESSION['staff_last_activity'] ?? 0);
if ($__last && (time() - $__last) > $__timeout * 60) {
http_response_code(401);
echo json_encode(['error' => 'login', 'sessionExpired' => true]);
exit;
}
// Die Sitzungsdatei sofort freigeben: Der Abruf läuft alle paar Sekunden und soll andere Seitenaufrufe nicht ausbremsen
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
$pdo = get_db();
$since = max(0, (int) ($_GET['since'] ?? 0));
[$deptFilter, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$count = function (string $where, array $params = []) use ($pdo, $deptFilter, $deptParams): int {
$s = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE {$where}{$deptFilter}");
$s->execute(array_merge($params, $deptParams));
return (int) $s->fetchColumn();
};
$open = "t.status NOT IN ('resolved','closed')";
$counts = [
'open' => $count($open),
'mine' => $count("t.assigned_staff_id = ? AND {$open}", [(int) $staff['id']]),
'unassigned' => $count("t.assigned_staff_id IS NULL AND {$open}"),
'overdue' => $count("{$open} AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?", [db_now()]),
'unanswered' => $count("t.status = 'open'"),
];
// Neue Tickets: ohne Spam, ohne Live-Chats (die haben eigene Hinweise) und ohne zusammengeführte
$visible = "t.is_spam = 0 AND t.source <> 'chat' AND t.merged_into IS NULL";
$m = $pdo->prepare("SELECT COALESCE(MAX(t.id), 0) FROM tickets t WHERE {$visible}{$deptFilter}");
$m->execute($deptParams);
$latest = (int) $m->fetchColumn();
$tickets = [];
$more = 0;
if ($since > 0 && $latest > $since) {
$s = $pdo->prepare("SELECT t.id, t.ticket_number, t.subject, t.requester_name, d.name AS department FROM tickets t LEFT JOIN departments d ON d.id = t.department_id
WHERE t.id > ? AND {$visible}{$deptFilter} ORDER BY t.id ASC LIMIT 6");
$s->execute(array_merge([$since], $deptParams));
foreach ($s->fetchAll() as $t) {
$tickets[] = ['id' => (int) $t['id'], 'number' => (string) $t['ticket_number'], 'subject' => mb_substr((string) $t['subject'], 0, 120), 'name' => mb_substr((string) $t['requester_name'], 0, 60), 'department' => (string) ($t['department'] ?? '')];
}
$c = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE t.id > ? AND {$visible}{$deptFilter}");
$c->execute(array_merge([$since], $deptParams));
$more = max(0, (int) $c->fetchColumn() - count($tickets));
}
echo json_encode(['latest' => $latest, 'counts' => $counts, 'tickets' => $tickets, 'more' => $more], JSON_UNESCAPED_UNICODE);
+19 -11
View File
@@ -9,8 +9,10 @@ if (current_staff()) {
$errors = [];
$need2fa = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
// Die Anmeldeseite war so lange offen, dass die Sitzung auf dem Server inzwischen aufgeräumt wurde: neu anmelden statt Fehlerseite
$errors[] = 'Die Anmeldeseite war zu lange geöffnet und die Sitzung ist abgelaufen. Bitte melden Sie sich jetzt noch einmal an.';
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'login';
if ($action === 'verify_totp') {
@@ -82,25 +84,26 @@ $displayName = get_setting('display_name', config()['site']['name']);
<?= site_icon_links() ?>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
</head>
<body class="login-page staff-login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh; background:#16202a <?php if($banner): ?>; background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center<?php endif; ?>">
<body class="login-page staff-login-page"<?php if ($banner): ?> style="background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center"<?php endif; ?>>
<?= i18n_switcher_html('floating') ?>
<!-- Schlanker Header -->
<header style="background:rgba(14,26,34,.95); border-bottom:1px solid rgba(255,255,255,.08); padding:0 28px; height:56px; display:flex; align-items:center; justify-content:space-between; flex-shrink:0">
<div style="display:flex; align-items:center; gap:12px">
<header class="staff-login-header">
<div class="staff-login-brand">
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="" style="height:32px; width:auto">
<img src="<?= e($logo) ?>" alt="">
<?php endif; ?>
<?php if (!$logo || get_setting('logo_hide_name', '0') !== '1'): ?>
<span style="color:#fff; font-weight:700; font-size:.95rem"><?= e($displayName) ?></span>
<span class="staff-login-brand-name"><?= e($displayName) ?></span>
<?php endif; ?>
</div>
<a href="/login.php" style="color:rgba(255,255,255,.5); font-size:.8rem; text-decoration:none">← Zum Kundenportal</a>
<a href="/login.php" class="staff-login-back">← Zum Kundenportal</a>
</header>
<!-- Auth-Bereich -->
<div class="login-screen staff-login-screen">
<div class="auth-card login-card staff-login-card">
<h1 style="font-size:1.4rem; font-weight:700; margin-bottom:4px; color:#0f1923">Mitarbeiterbereich</h1>
<h1 class="staff-login-title">Mitarbeiterbereich</h1>
<div class="sub">Anmeldung für Support-Mitarbeiter und Administratoren.</div>
<?php foreach ($errors as $err): ?>
@@ -142,9 +145,14 @@ $displayName = get_setting('display_name', config()['site']['name']);
</div>
<!-- Schlanker Footer -->
<footer style="background:rgba(14,26,34,.95); border-top:1px solid rgba(255,255,255,.08); padding:12px 28px; text-align:center; flex-shrink:0">
<span style="color:rgba(255,255,255,.3); font-size:.75rem">Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
<footer class="staff-login-footer">
<span>Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
</footer>
<script>
// Hält die Sitzung der offenen Anmeldeseite am Leben: Der Server räumt ungenutzte Sitzungen nach etwa 24 Minuten auf, und dann wäre das
// Anmeldeformular ungültig. Ein kurzer Abruf alle 8 Minuten genügt.
setInterval(function () { fetch('/staff/session_ping.php?check=1', { credentials: 'same-origin', cache: 'no-store' }).catch(function () {}); }, 8 * 60 * 1000);
</script>
</body>
</html>
+4 -2
View File
@@ -19,10 +19,12 @@ if (!$loaded || !$registered || !is_file($file)) { http_response_code(404); exit
$menu = reset($registered);
if ($menu['permission'] !== null && !has_permission($staff, $menu['permission'])) { http_response_code(403); exit('Keine Berechtigung.'); }
$isAdminPage = ($menu['belongs_to'] ?? null) === 'admin'; // Adminportal-Seite eines Plugins: nur für Administratoren
if ($isAdminPage && ($staff['role'] ?? '') !== 'admin') { http_response_code(403); exit('Keine Berechtigung.'); }
$pageTitle = $menu['label'];
$activePage = !empty($menu['belongs_to']) ? (string) $menu['belongs_to'] : 'plugin_' . $slug; // Reiter-Seiten markieren die übergeordnete Seite (zum Beispiel „Backup“)
$activePage = (!empty($menu['belongs_to']) && !$isAdminPage) ? (string) $menu['belongs_to'] : 'plugin_' . $slug; // Reiter-Seiten markieren die übergeordnete Seite (zum Beispiel „Backup“)
require __DIR__ . '/_header.php';
if (!empty($menu['belongs_to'])) echo plugin_subpage_tabs((string) $menu['belongs_to'], $staff, $slug . ':' . $page);
if (!empty($menu['belongs_to']) && !$isAdminPage) echo plugin_subpage_tabs((string) $menu['belongs_to'], $staff, $slug . ':' . $page);
plugin_guard($slug, function () use ($file, $staff, $pdo) {
// Variablen für die Plugin-Seite: $staff, $pdo
include $file;
+16 -8
View File
@@ -44,8 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$errors[] = $res['error'];
} else {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aktualisiert: ' : 'Plugin installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', ($res['updated'] ? 'Plugin aktualisiert' : 'Plugin installiert') . ": {$res['name']} {$res['version']} (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.'
. ($res['needs_reapproval'] ? ' Die neue Version fordert zusätzliche Berechtigungen an und wurde angehalten.' : ''));
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
}
@@ -54,8 +53,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
$res = plugin_catalog_install($pdo, $slug);
if ($res['ok']) {
audit_log($staff['id'], "Plugin aus dem Katalog installiert: {$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', "{$res['name']} {$res['version']} installiert (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.');
audit_log($staff['id'], ($res['updated'] ? 'Plugin aus dem Katalog aktualisiert: ' : 'Plugin aus dem Katalog installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
@@ -68,7 +67,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if (!$errors) {
$manifest = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
$approve = !empty($_POST['confirm_permissions']) && $manifest ? $manifest['permissions'] : null;
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung; manuell hochgeladene schon
$row = plugins_installed($pdo)[$slug] ?? [];
$fromCatalog = ($row['source'] ?? '') === 'catalog' && !empty($row['signature_ok']);
$approve = (!empty($_POST['confirm_permissions']) || $fromCatalog) && $manifest ? $manifest['permissions'] : null;
$res = plugin_set_active($pdo, $slug, $action === 'activate', $approve);
if ($res['ok']) {
audit_log($staff['id'], ($action === 'activate' ? 'Plugin aktiviert: ' : 'Plugin deaktiviert: ') . $slug);
@@ -96,7 +98,10 @@ foreach ($installed as $slug => $row) $manifests[$slug] = plugin_read_manifest(p
$disabled = plugins_globally_disabled();
$strict = plugin_strict_mode();
$official = plugin_official_configured();
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh'])) : null;
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh']), 60) : null;
// Katalogeinträge je Plugin (für die Vorschaubilder der installierten Plugins)
$catBySlug = [];
if ($catalog && !empty($catalog['ok'])) foreach ($catalog['plugins'] as $__c) $catBySlug[$__c['slug']] = $__c;
$hasKey = plugin_publisher_key() !== '';
$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
@@ -121,6 +126,7 @@ require __DIR__ . '/_header.php';
<?php foreach ($installed as $slug => $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; ?>
<tr>
<td>
<div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $catBySlug[$slug] ?? ['slug' => $slug, 'name' => $p['name']]) ?><div style="min-width:0">
<strong><?= e($p['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($slug) ?></span>
<?php if ($m && $m['description'] !== ''): ?><div class="muted" style="font-size:.82rem"><?= e($m['description']) ?></div><?php endif; ?>
<?php if ($m && $m['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($m['author']) ?></div><?php endif; ?>
@@ -130,12 +136,14 @@ require __DIR__ . '/_header.php';
</details>
<?php elseif ($m): ?><div class="muted" style="font-size:.78rem">Fordert keine Berechtigungen an.</div><?php endif; ?>
<?php if (!$m): ?><div style="color:#dc2626;font-size:.82rem">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</div><?php endif; ?>
</div></div>
</td>
<td><?= e($p['version']) ?></td>
<td>
<?php if ($p['is_active']): ?><span style="color:#15803d;font-weight:600">Aktiv</span>
<?php elseif ((int) $p['fail_count'] >= PLUGIN_FAIL_LIMIT): ?><span style="color:#dc2626;font-weight:600">Wegen Fehlern deaktiviert</span>
<?php else: ?><span class="muted">Inaktiv</span><?php endif; ?>
<?php if (!empty($p['last_error'])): ?><div style="color:#b45309;font-size:.74rem;margin-top:4px;max-width:260px;white-space:normal;word-break:break-word"><?= e(mb_strimwidth((string) $p['last_error'], 0, 300, '…')) ?></div><?php endif; ?>
</td>
<td style="font-size:.82rem">
<?php if (!empty($p['signature_ok'])): ?><span style="color:#15803d;font-weight:600">✓ Signiert</span>
@@ -148,7 +156,7 @@ require __DIR__ . '/_header.php';
<?php else: ?>
<form method="post" style="display:inline-block;text-align:left" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
<?php if ($perms): ?><label style="font-size:.76rem;display:block"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<?php if ($perms && !(($p['source'] ?? '') === 'catalog' && !empty($p['signature_ok']))): ?><label style="font-size:.76rem;display:block"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
</form>
<?php endif; ?>
@@ -182,7 +190,7 @@ require __DIR__ . '/_header.php';
<?php foreach ($catalog['plugins'] as $c): $have = $installed[$c['slug']] ?? null; $newer = $have && version_compare($c['version'], $have['version'], '>'); ?>
<tr>
<td><div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $c) ?><div style="min-width:0">
<strong><?= e($c['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($c['slug']) ?></span>
<strong><?= e($c['name']) ?></strong> <?php if (!empty($c['language'])): ?><span title="Sprachpaket: nach der Aktivierung erscheint die Flagge zum Umschalten" style="background:#e8f1ff;color:#1d4ed8;border:1px solid #bcd3f7;border-radius:20px;padding:1px 9px;font-size:.72rem;font-weight:700">🌐 Sprache: <?= e($c['language']['name']) ?></span> <?php endif; ?><?php if (!empty($c['featured'])): ?><span title="Vom Herausgeber empfohlen" style="background:#fff4d6;color:#92600a;border:1px solid #f1d38a;border-radius:20px;padding:1px 9px;font-size:.72rem;font-weight:700">★ Empfohlen</span><?php endif; ?> <span class="muted" style="font-size:.78rem"><?= e($c['slug']) ?></span>
<div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div>
<?php if ($c['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($c['author']) ?></div><?php endif; ?>
<?php if ($c['permissions']): ?><div class="muted" style="font-size:.76rem">Fordert an: <?= e(implode(', ', array_map(fn($p) => $permLabels[$p] ?? $p, $c['permissions']))) ?></div><?php endif; ?>
+49 -5
View File
@@ -414,11 +414,11 @@ require __DIR__ . '/_header.php';
<?php endif; ?>
<div class="stat-grid">
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num"><?= $dashOpenTickets ?></div><div class="lbl">Offene Tickets</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=me'"><div class="num"><?= $dashMine ?></div><div class="lbl">Mir zugewiesen</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=unassigned'"><div class="num"><?= $dashUnassigned ?></div><div class="lbl">Nicht zugewiesen</div></div>
<div class="stat-card overdue" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashOverdue ?></div><div class="lbl">Überfällig</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num" data-live-count="open"><?= $dashOpenTickets ?></div><div class="lbl">Offene Tickets</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=me'"><div class="num" data-live-count="mine"><?= $dashMine ?></div><div class="lbl">Mir zugewiesen</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=unassigned'"><div class="num" data-live-count="unassigned"><?= $dashUnassigned ?></div><div class="lbl">Nicht zugewiesen</div></div>
<div class="stat-card overdue" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num" data-live-count="overdue"><?= $dashOverdue ?></div><div class="lbl">Überfällig</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num" data-live-count="unanswered"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
<?php if ($unreadMentions > 0): ?>
<div class="stat-card" style="cursor:pointer; border-color:#f59e0b" onclick="location.href='/staff/mentions.php'"><div class="num" style="color:#f59e0b"><?= $unreadMentions ?></div><div class="lbl">🔔 Neue Erwähnungen</div></div>
<?php endif; ?>
@@ -524,6 +524,50 @@ setInterval(refreshDashTeam, 20000);
<div class="stat-card"><div class="num"><?= $avgResolution !== null ? round($avgResolution, 1) . ' Std' : '–' ?></div><div class="lbl">Ø Lösungszeit</div></div>
</div>
<?php
// Zufriedenheit (CSAT): nur zeigen, wenn die Abfrage eingeschaltet ist oder es schon Antworten gibt
$csat = null;
try { $csat = csat_stats($pdo, $rangeFrom, $rangeTo, $myDeptIds); } catch (Throwable $e) { $csat = null; }
if ($csat && (csat_enabled() || $csat['sent'] > 0)):
?>
<div class="card" style="margin-bottom:16px">
<h3>Kundenzufriedenheit <span class="muted" style="font-weight:400; font-size:.8rem">(Bewertungen im gewählten Zeitraum)</span></h3>
<?php if (!$csat['sent']): ?>
<p class="muted">Im Zeitraum wurden noch keine Abfragen verschickt.</p>
<?php else: ?>
<div class="stat-grid" style="margin-bottom:12px">
<div class="stat-card"><div class="num"><?= $csat['avg'] !== null ? number_format($csat['avg'], 1, ',', '') . ' ★' : '–' ?></div><div class="lbl">Ø Bewertung</div></div>
<div class="stat-card"><div class="num"><?= $csat['answered'] ?></div><div class="lbl">Antworten von <?= $csat['sent'] ?> Abfragen</div></div>
<div class="stat-card"><div class="num"><?= $csat['rate'] !== null ? number_format($csat['rate'], 0, ',', '') . ' %' : '–' ?></div><div class="lbl">Rücklaufquote</div></div>
</div>
<?php $distMax = max(1, max($csat['dist'])); ?>
<table style="width:100%;max-width:520px">
<?php for ($i = 5; $i >= 1; $i--): ?>
<tr><td style="white-space:nowrap;width:1%;color:#f5b301"><?= e(csat_stars($i)) ?></td>
<td><div style="background:#e2e8f0;border-radius:6px;height:12px"><div style="background:#f5b301;height:12px;border-radius:6px;width:<?= (int) round($csat['dist'][$i] * 100 / $distMax) ?>%"></div></div></td>
<td style="width:1%;text-align:right"><?= $csat['dist'][$i] ?></td></tr>
<?php endfor; ?>
</table>
<?php if ($csat['byStaff']): ?>
<h4 style="margin:14px 0 6px">Nach Mitarbeiter</h4>
<table style="width:100%;max-width:520px"><?php foreach ($csat['byStaff'] as $row): ?>
<tr><td><?= e($row['name']) ?></td><td style="text-align:right"><?= number_format($row['avg'], 1, ',', '') ?> ★</td><td class="muted" style="text-align:right;width:1%;white-space:nowrap"><?= $row['count'] ?> Bewertung<?= $row['count'] === 1 ? '' : 'en' ?></td></tr>
<?php endforeach; ?></table>
<?php endif; ?>
<?php if ($csat['recent']): ?>
<h4 style="margin:14px 0 6px">Letzte Bewertungen</h4>
<?php foreach ($csat['recent'] as $row): ?>
<div style="padding:6px 0;border-top:1px solid var(--border,#e3e9ee);font-size:.88rem">
<span style="color:#f5b301"><?= e(csat_stars($row['rating'])) ?></span>
<a href="/staff/ticket_view.php?n=<?= e(rawurlencode($row['number'])) ?>">#<?= e($row['number']) ?></a> <?= e($row['subject']) ?>
<?php if ($row['comment'] !== ''): ?><div class="muted" style="margin-top:2px">„<?= e($row['comment']) ?>“</div><?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="get" class="filters card">
<div>
<label>Berichtszeitraum</label>