diff --git a/DeskHub-Webseite/assets/css/style.css b/DeskHub-Webseite/assets/css/style.css
index 3aba9d34..c58bda1f 100644
--- a/DeskHub-Webseite/assets/css/style.css
+++ b/DeskHub-Webseite/assets/css/style.css
@@ -1058,3 +1058,79 @@ body:has(.auth-wrap) .page-content > .auth-wrap {
details > summary { outline: none; }
details > summary::-webkit-details-marker { display: none; }
details[open] > summary span:last-child { transform: rotate(180deg); display:inline-block; }
+
+/* Anmeldeseite der Mitarbeiter: Kopf- und Fußzeile, Hintergrund. Themes setzen --staff-login-* oder überschreiben die Klassen. */
+body.staff-login-page {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background-color: var(--staff-login-bg, #16202a);
+}
+.staff-login-header {
+ display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
+ height: 56px; padding: 0 28px;
+ background: var(--staff-login-chrome, rgba(14,26,34,.95));
+ border-bottom: var(--staff-login-line, 1px solid rgba(255,255,255,.08));
+}
+.staff-login-brand { display: flex; align-items: center; gap: 12px; }
+.staff-login-brand img { height: 32px; width: auto; }
+.staff-login-brand-name { color: var(--staff-login-chrome-ink, #fff); font-weight: 700; font-size: .95rem; }
+.staff-login-back { color: var(--staff-login-chrome-muted, rgba(255,255,255,.5)); font-size: .8rem; text-decoration: none; }
+.staff-login-back:hover { color: var(--staff-login-chrome-ink, #fff); }
+.staff-login-footer {
+ flex-shrink: 0; padding: 12px 28px; text-align: center;
+ background: var(--staff-login-chrome, rgba(14,26,34,.95));
+ border-top: var(--staff-login-line, 1px solid rgba(255,255,255,.08));
+}
+.staff-login-footer span { color: var(--staff-login-chrome-faint, rgba(255,255,255,.3)); font-size: .75rem; }
+.staff-login-title { font-size: 1.4rem; font-weight: 700; margin: 0 0 4px; color: #0f1923; }
+
+/* ── Seitenleiste ein-/ausklappen (eingeklappt bleibt eine Iconleiste) ─────────── */
+.sidebar-toggle {
+ border: 0; cursor: pointer; font: inherit; line-height: 1;
+ color: #a8b8c8; background: rgba(255,255,255,.07);
+ width: 30px; height: 30px; border-radius: 8px; font-size: 1.1rem;
+ transition: background .12s, color .12s;
+}
+.sidebar-toggle:hover { background: rgba(255,255,255,.16); color: #fff; }
+.sidebar-toggle-close { position: absolute; top: 16px; right: 12px; z-index: 2; }
+.sidebar-toggle-open { display: none; }
+.sidebar .nav-ico { display: none; }
+.sidebar .nav-ico svg { display: block; }
+
+@media (min-width: 821px) {
+ html.sidebar-closed .sidebar { width: 64px; overflow-x: hidden; }
+ html.sidebar-closed .sidebar .brand { height: 62px; padding: 0; font-size: 0; overflow: hidden; }
+ html.sidebar-closed .sidebar .brand > * { display: none; }
+ html.sidebar-closed .sidebar .sidebar-toggle-close { right: 17px; }
+ html.sidebar-closed .sidebar nav a { justify-content: center; padding: 11px 0; gap: 0; }
+ html.sidebar-closed .sidebar .nav-ico { display: flex; }
+ html.sidebar-closed .sidebar .nav-label { display: none; }
+ html.sidebar-closed .sidebar .nav-section-label { display: none; }
+ html.sidebar-closed .sidebar .nav-admin-block { display: block !important; border-top: 1px solid #243040; margin-top: 6px; padding-top: 6px; }
+ html.sidebar-closed .sidebar .nav-group-label { display: block; height: 1px; padding: 0; margin: 8px 14px; background: #243040; font-size: 0; overflow: hidden; }
+ html.sidebar-closed .sidebar .sidebar-footer { padding: 10px 0; }
+ html.sidebar-closed .sidebar .sidebar-footer > div { flex-direction: column; justify-content: center; align-items: center; gap: 12px !important; margin-bottom: 10px !important; }
+ html.sidebar-closed .sidebar .sidebar-footer > div:first-child > div:last-child { display: none; }
+ html.sidebar-closed .sidebar .sidebar-footer a { display: flex; }
+ html.sidebar-closed .sidebar .sidebar-footer .nav-ico { display: flex; }
+ html.sidebar-closed .sidebar .sidebar-footer .nav-label { display: none; }
+}
+@media (max-width: 820px) {
+ .sidebar { position: relative; }
+ .sidebar-toggle-close { top: 14px; }
+ .sidebar-toggle-open { position: fixed; top: 12px; left: 12px; z-index: 9998; background: var(--ink); color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.25); }
+ html.sidebar-closed .sidebar { display: none; }
+ html.sidebar-closed .sidebar-toggle-open { display: block; }
+ html.sidebar-closed .main { padding-top: 56px; }
+}
+
+/* Das Logo darf nicht unter den Einklappen-Knopf rutschen */
+.sidebar .brand { padding-right: 52px; }
+.sidebar .brand img { max-width: 100% !important; height: auto; }
+
+/* Sprachwechsler: in der schmalen Iconleiste nicht anzeigen */
+@media (min-width: 821px) {
+ html.sidebar-closed .sidebar .lang-row { display: none; }
+}
diff --git a/DeskHub-Webseite/assets/js/live-tickets.js b/DeskHub-Webseite/assets/js/live-tickets.js
new file mode 100644
index 00000000..80aeb63d
--- /dev/null
+++ b/DeskHub-Webseite/assets/js/live-tickets.js
@@ -0,0 +1,103 @@
+/**
+ * Live-Anzeige im Mitarbeiterbereich: neue Tickets erscheinen sofort als Hinweis unten rechts, die Zahl steht im Browser-Tab und die
+ * Kennzahlen auf der Übersicht (Elemente mit data-live-count) aktualisieren sich von selbst. Fragt /staff/live_tickets.php ab.
+ */
+(function () {
+ 'use strict';
+ if (window.__deskhubLive) return;
+ window.__deskhubLive = true;
+
+ var KEY = 'deskhub_live_last_ticket';
+ var since = 0;
+ try { since = parseInt(sessionStorage.getItem(KEY) || '0', 10) || 0; } catch (e) {}
+ var unseen = 0;
+ var baseTitle = document.title;
+ var stopped = false;
+ var box = null;
+
+ function container() {
+ if (box) return box;
+ box = document.createElement('div');
+ box.setAttribute('aria-live', 'polite');
+ box.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;flex-direction:column;gap:10px;max-width:360px;font:14px/1.4 "Segoe UI",system-ui,sans-serif';
+ document.body.appendChild(box);
+ return box;
+ }
+
+ function toast(t) {
+ var a = document.createElement('a');
+ a.href = '/staff/ticket_view.php?n=' + encodeURIComponent(t.number);
+ a.style.cssText = 'display:block;background:#0f172a;color:#fff;text-decoration:none;border-radius:12px;padding:12px 16px;box-shadow:0 10px 30px rgba(0,0,0,.35);border-left:4px solid #2dd4bf;transition:opacity .4s,transform .4s;opacity:0;transform:translateY(10px)';
+ var h = document.createElement('div');
+ h.style.cssText = 'font-size:.74rem;letter-spacing:.04em;text-transform:uppercase;color:#5eead4;font-weight:700';
+ h.textContent = 'Neues Ticket #' + t.number + (t.department ? ' · ' + t.department : '');
+ var s = document.createElement('div');
+ s.style.cssText = 'font-weight:600;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
+ s.textContent = t.subject;
+ var n = document.createElement('div');
+ n.style.cssText = 'color:#94a3b8;font-size:.82rem';
+ n.textContent = t.name;
+ a.appendChild(h); a.appendChild(s); a.appendChild(n);
+ container().appendChild(a);
+ requestAnimationFrame(function () { a.style.opacity = '1'; a.style.transform = 'none'; });
+ setTimeout(function () { a.style.opacity = '0'; setTimeout(function () { a.remove(); }, 450); }, 14000);
+ a.addEventListener('click', function () { a.remove(); });
+ }
+
+ function info(text) {
+ var d = document.createElement('div');
+ d.style.cssText = 'background:#334155;color:#e2e8f0;border-radius:10px;padding:8px 14px;font-size:.84rem;box-shadow:0 6px 18px rgba(0,0,0,.3)';
+ d.textContent = text;
+ container().appendChild(d);
+ setTimeout(function () { d.remove(); }, 14000);
+ }
+
+ function setCounts(c) {
+ document.querySelectorAll('[data-live-count]').forEach(function (el) {
+ var v = c[el.getAttribute('data-live-count')];
+ if (v === undefined || el.textContent.trim() === String(v)) return;
+ el.textContent = v;
+ el.style.transition = 'transform .3s';
+ el.style.transform = 'scale(1.25)';
+ setTimeout(function () { el.style.transform = 'none'; }, 350);
+ });
+ }
+
+ function updateTitle() {
+ document.title = unseen > 0 ? '(' + unseen + ') ' + baseTitle : baseTitle;
+ }
+ function seen() {
+ if (unseen === 0) return;
+ unseen = 0;
+ updateTitle();
+ }
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) seen(); });
+ window.addEventListener('focus', seen);
+
+ function poll() {
+ if (stopped) return;
+ fetch('/staff/live_tickets.php?since=' + since, { credentials: 'same-origin', cache: 'no-store' })
+ .then(function (r) {
+ if (r.status === 401 || r.status === 403) { stopped = true; return null; }
+ return r.ok ? r.json() : null;
+ })
+ .then(function (d) {
+ if (!d) return;
+ setCounts(d.counts || {});
+ var list = d.tickets || [];
+ list.forEach(toast);
+ if (d.more > 0) info('… und ' + d.more + ' weitere neue Tickets');
+ if (list.length) {
+ unseen += list.length + (d.more || 0);
+ if (document.hidden || !document.hasFocus()) updateTitle(); else unseen = 0;
+ }
+ if (d.latest > since) {
+ since = d.latest;
+ try { sessionStorage.setItem(KEY, String(since)); } catch (e) {}
+ }
+ })
+ .catch(function () {})
+ .then(function () { if (!stopped) setTimeout(poll, document.hidden ? 30000 : 10000); });
+ }
+ setTimeout(poll, 1500);
+})();
diff --git a/DeskHub-Webseite/assets/js/sidebar.js b/DeskHub-Webseite/assets/js/sidebar.js
new file mode 100644
index 00000000..f47a2a51
--- /dev/null
+++ b/DeskHub-Webseite/assets/js/sidebar.js
@@ -0,0 +1,108 @@
+/**
+ * Seitenleiste im Mitarbeiterbereich: ein-/ausklappen. Eingeklappt bleibt eine schmale Iconleiste sichtbar.
+ * Die Icons werden hier anhand der Seitenadresse zugeordnet, auch für Menüpunkte von Plugins (sonst ein Puzzle-Teil).
+ */
+(function () {
+ var root = document.documentElement, KEY = 'deskhub_sidebar';
+ var side = document.getElementById('sidebar');
+ if (!side) return;
+
+ var P = {
+ grid: '',
+ chart: '',
+ inbox: '',
+ check: '',
+ ban: '',
+ users: '',
+ user: '',
+ phone: '',
+ chat: '',
+ puzzle: '',
+ building: '',
+ flag: '',
+ clock: '',
+ help: '',
+ sliders: '',
+ key: '',
+ link: '',
+ list: '',
+ file: '',
+ mail: '',
+ book: '',
+ bot: '',
+ briefcase: '',
+ shield: '',
+ shieldCheck: '',
+ calendar: '',
+ clipboard: '',
+ branch: '',
+ trend: '',
+ repeat: '',
+ gear: '',
+ db: '',
+ download: '',
+ lock: '',
+ activity: '',
+ megaphone: '',
+ sun: '',
+ userx: '',
+ logout: '',
+ globe: '',
+ filter: ''
+ };
+ var PAGES = {
+ reports: 'grid', report_builder: 'chart', tickets: 'inbox', tasks: 'check', banlist: 'ban', agent_directory: 'users', apps: 'phone', live_chat: 'chat',
+ departments: 'building', priorities: 'flag', sla_plans: 'clock', help_topics: 'help', custom_fields: 'sliders', api_keys: 'key', webhooks: 'link',
+ custom_lists: 'list', canned_responses: 'file', site_pages: 'file', email_addresses: 'mail', email_templates: 'mail', kb_manage: 'book', assistant: 'bot',
+ plugins: 'puzzle', customers: 'user', organizations: 'briefcase', staff_manage: 'users', teams: 'users', roles: 'shield', availability: 'calendar',
+ security_check: 'shieldCheck', audit_log: 'clipboard', ticket_rules: 'branch', escalation_rules: 'trend', recurring_tickets: 'repeat', time_entries: 'clock',
+ business_hours: 'clock', settings: 'gear', backup: 'db', update: 'download', profile: 'user', logout: 'logout', ticket_templates: 'file', status_page: 'activity', languages: 'globe'
+ };
+ var PLUGINS = {
+ schichtuebergabe: 'sun', spamfilter: 'filter', vertretung: 'userx', virenscan: 'shieldCheck', datenschutz: 'lock', ueberwachung: 'activity', rundmail: 'megaphone',
+ 'community-bot': 'bot', 'nas-backup': 'db', tagesbericht: 'clipboard'
+ };
+
+ function svg(name) {
+ return '';
+ }
+ function iconFor(a) {
+ var u;
+ try { u = new URL(a.getAttribute('href'), location.href); } catch (e) { return 'puzzle'; }
+ if (/\/staff\/plugin\.php$/.test(u.pathname)) return PLUGINS[u.searchParams.get('p')] || 'puzzle';
+ var m = u.pathname.match(/\/([a-z_]+)\.php$/);
+ return (m && PAGES[m[1]]) || 'puzzle';
+ }
+
+ var links = side.querySelectorAll('nav a[href], .sidebar-footer a[href$="profile.php"], .sidebar-footer a[href$="logout.php"]');
+ Array.prototype.forEach.call(links, function (a) {
+ if (a.querySelector('.nav-ico')) return;
+ var text = (a.textContent || '').replace(/\s+/g, ' ').trim();
+ if (!text) return;
+ var ico = document.createElement('span');
+ ico.className = 'nav-ico';
+ ico.innerHTML = svg(iconFor(a));
+ var label = document.createElement('span');
+ label.className = 'nav-label';
+ while (a.firstChild) label.appendChild(a.firstChild);
+ a.appendChild(ico);
+ a.appendChild(label);
+ a.setAttribute('title', text.replace(/\s*\d+\s*$/, ''));
+ });
+
+ var btn = document.getElementById('sidebar-toggle');
+ function paint() {
+ var closed = root.classList.contains('sidebar-closed');
+ btn.innerHTML = closed ? '»' : '«';
+ var t = closed ? 'Seitenleiste ausklappen' : 'Seitenleiste einklappen';
+ btn.setAttribute('title', t);
+ btn.setAttribute('aria-label', t);
+ }
+ btn.addEventListener('click', function () {
+ var closed = !root.classList.contains('sidebar-closed');
+ root.classList.toggle('sidebar-closed', closed);
+ try { localStorage.setItem(KEY, closed ? 'closed' : 'open'); } catch (e) {}
+ paint();
+ });
+ paint();
+})();
diff --git a/DeskHub-Webseite/assets/js/upload-compress.js b/DeskHub-Webseite/assets/js/upload-compress.js
new file mode 100644
index 00000000..fde7f464
--- /dev/null
+++ b/DeskHub-Webseite/assets/js/upload-compress.js
@@ -0,0 +1,78 @@
+/**
+ * Verkleinert große Fotos, bevor sie hochgeladen werden (Handy-Fotos haben oft 5–15 MB).
+ * Das macht den Upload deutlich schneller. Ist etwas nicht möglich, wird die Originaldatei unverändert hochgeladen.
+ * Gilt für alle auf der Seite; nur JPG/PNG über 1 MB, längste Seite höchstens 2400 px.
+ */
+(function () {
+ var MAX_SIDE = 2400, MIN_BYTES = 1024 * 1024, QUALITY = 0.82;
+ if (!window.DataTransfer || !window.HTMLCanvasElement || !window.URL) return;
+
+ function note(input, text) {
+ var el = input.parentNode && input.parentNode.querySelector('.upload-note');
+ if (!el) {
+ el = document.createElement('div');
+ el.className = 'upload-note';
+ el.style.cssText = 'font-size:.8rem;color:#64748b;margin-top:4px';
+ input.insertAdjacentElement('afterend', el);
+ }
+ el.textContent = text;
+ }
+
+ function shrink(file) {
+ return new Promise(function (resolve) {
+ if (!/^image\/(jpeg|png)$/.test(file.type) || file.size < MIN_BYTES) return resolve(file);
+ var url = URL.createObjectURL(file), img = new Image();
+ img.onerror = function () { URL.revokeObjectURL(url); resolve(file); };
+ img.onload = function () {
+ URL.revokeObjectURL(url);
+ var w = img.naturalWidth, h = img.naturalHeight, k = Math.min(1, MAX_SIDE / Math.max(w, h));
+ var c = document.createElement('canvas');
+ c.width = Math.round(w * k); c.height = Math.round(h * k);
+ var ctx = c.getContext('2d');
+ if (!ctx) return resolve(file);
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(0, 0, c.width, c.height);
+ ctx.drawImage(img, 0, 0, c.width, c.height);
+ c.toBlob(function (blob) {
+ if (!blob || blob.size >= file.size) return resolve(file);
+ var name = file.name.replace(/\.(png|jpe?g)$/i, '') + '.jpg';
+ resolve(new File([blob], name, { type: 'image/jpeg', lastModified: file.lastModified }));
+ }, 'image/jpeg', QUALITY);
+ };
+ img.src = url;
+ });
+ }
+
+ document.addEventListener('change', function (e) {
+ var input = e.target;
+ if (!input || input.type !== 'file' || input.dataset.shrunk === '1' || !input.files || !input.files.length) return;
+ var files = Array.prototype.slice.call(input.files);
+ if (!files.some(function (f) { return /^image\/(jpeg|png)$/.test(f.type) && f.size >= MIN_BYTES; })) return;
+ note(input, 'Bilder werden für einen schnelleren Upload verkleinert …');
+ Promise.all(files.map(shrink)).then(function (out) {
+ try {
+ var dt = new DataTransfer();
+ out.forEach(function (f) { dt.items.add(f); });
+ input.dataset.shrunk = '1';
+ input.files = dt.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ input.dataset.shrunk = '';
+ var before = files.reduce(function (s, f) { return s + f.size; }, 0), after = out.reduce(function (s, f) { return s + f.size; }, 0);
+ note(input, after < before ? 'Bilder verkleinert: ' + (before / 1048576).toFixed(1) + ' MB → ' + (after / 1048576).toFixed(1) + ' MB' : '');
+ } catch (err) { note(input, ''); }
+ });
+ }, true);
+
+ // Beim Absenden: Knopf sperren, damit nicht doppelt hochgeladen wird, und Fortschritt anzeigen
+ document.addEventListener('submit', function (e) {
+ var form = e.target;
+ if (!form || !form.querySelector || !form.querySelector('input[type=file]')) return;
+ var has = Array.prototype.some.call(form.querySelectorAll('input[type=file]'), function (i) { return i.files && i.files.length; });
+ if (!has) return;
+ setTimeout(function () {
+ if (e.defaultPrevented) return;
+ var b = form.querySelector('button[type=submit]');
+ if (b) { b.disabled = true; b.textContent = 'Wird hochgeladen …'; }
+ }, 0);
+ });
+})();
diff --git a/DeskHub-Webseite/cron/run_plugins.php b/DeskHub-Webseite/cron/run_plugins.php
new file mode 100644
index 00000000..bf5f316d
--- /dev/null
+++ b/DeskHub-Webseite/cron/run_plugins.php
@@ -0,0 +1,38 @@
+ time()]);
+ // „cron.run“: wie bisher höchstens alle 5 Minuten
+ if (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim($pdo, 'cron_plugins_run_last', 290)) plugin_hook('cron.run', ['time' => time()]);
+ // Plugin- und Theme-Katalog aktuell halten (höchstens alle 5 Minuten)
+ if (function_exists('catalog_refresh_background') && (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280))) catalog_refresh_background();
+ set_setting('cron_run_plugins_last_run', db_now());
+ // Woher kam der Lauf? Der echte Server-Cronjob (Kommandozeile) oder der eingebaute Hintergrund-Betrieb bei einem Seitenaufruf
+ set_setting('cron_run_plugins_source', php_sapi_name() === 'cli' ? 'cron' : 'web');
+ if (php_sapi_name() === 'cli') set_setting('cron_run_plugins_last_cli', db_now());
+ return ['count' => $n, 'log' => ['[' . date('Y-m-d H:i:s') . "] Hintergrundaufgaben von {$n} aktiven Plugin(s) ausgelöst."]];
+}
+
+if (php_sapi_name() === 'cli') {
+ // Läuft zeitgleich der eingebaute Hintergrund-Betrieb, nicht doppelt ausführen (gleiche Sperre wie dort)
+ if (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim(get_db(), 'cron_run_plugins_last_run', 50)) {
+ foreach (run_plugins_cron(get_db(), true)['log'] as $line) echo $line . "\n";
+ } else {
+ echo "[" . date('Y-m-d H:i:s') . "] Übersprungen: wurde gerade erst ausgeführt.\n";
+ }
+}
diff --git a/DeskHub-Webseite/cron/send_csat.php b/DeskHub-Webseite/cron/send_csat.php
new file mode 100644
index 00000000..48ba629f
--- /dev/null
+++ b/DeskHub-Webseite/cron/send_csat.php
@@ -0,0 +1,25 @@
+ 0, 'log' => ['[' . date('Y-m-d H:i:s') . '] Zufriedenheitsabfrage ist deaktiviert – nichts zu tun.']];
+ }
+ $sent = csat_send_due($pdo);
+ return ['sent' => $sent, 'log' => ['[' . date('Y-m-d H:i:s') . "] Zufriedenheitsabfrage: {$sent} Mail(s) gesendet."]];
+}
+
+if (php_sapi_name() === 'cli') {
+ foreach (run_send_csat(get_db(), true)['log'] as $line) echo $line . "\n";
+}
diff --git a/DeskHub-Webseite/database/migrate.php b/DeskHub-Webseite/database/migrate.php
index 7104ff74..562739b3 100644
--- a/DeskHub-Webseite/database/migrate.php
+++ b/DeskHub-Webseite/database/migrate.php
@@ -1330,6 +1330,114 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
}
}
+ // Migration 0048: Zufriedenheitsabfrage (CSAT), Statusseite für Kunden und Ticket-Vorlagen.
+ if (!isset($done['php_0048_csat_status_templates'])) {
+ try {
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
+ $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
+ $pdo->exec("CREATE TABLE IF NOT EXISTS ticket_csat (
+ id $ai,
+ ticket_id INT NOT NULL,
+ token VARCHAR(64) NOT NULL,
+ staff_id INT NULL,
+ department_id INT NULL,
+ rating TINYINT NULL,
+ comment TEXT NULL,
+ sent_at DATETIME NOT NULL,
+ answered_at DATETIME NULL
+ )$eng");
+ $pdo->exec('CREATE UNIQUE INDEX idx_ticket_csat_ticket ON ticket_csat (ticket_id)');
+ $pdo->exec('CREATE UNIQUE INDEX idx_ticket_csat_token ON ticket_csat (token)');
+ $pdo->exec("CREATE TABLE IF NOT EXISTS status_components (
+ id $ai,
+ name VARCHAR(120) NOT NULL,
+ description VARCHAR(255) NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'operational',
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL
+ )$eng");
+ $pdo->exec("CREATE TABLE IF NOT EXISTS status_incidents (
+ id $ai,
+ title VARCHAR(200) NOT NULL,
+ body TEXT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'investigating',
+ impact VARCHAR(20) NOT NULL DEFAULT 'minor',
+ component_id INT NULL,
+ created_by INT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ resolved_at DATETIME NULL
+ )$eng");
+ $pdo->exec('CREATE INDEX idx_status_incidents_created ON status_incidents (created_at)');
+ $pdo->exec("CREATE TABLE IF NOT EXISTS status_incident_updates (
+ id $ai,
+ incident_id INT NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ body TEXT NULL,
+ created_by INT NULL,
+ created_at DATETIME NOT NULL
+ )$eng");
+ $pdo->exec('CREATE INDEX idx_status_incident_updates_incident ON status_incident_updates (incident_id)');
+ $pdo->exec("CREATE TABLE IF NOT EXISTS ticket_templates (
+ id $ai,
+ name VARCHAR(120) NOT NULL,
+ description VARCHAR(255) NULL,
+ subject VARCHAR(200) NOT NULL DEFAULT '',
+ body TEXT NULL,
+ department_id INT NULL,
+ help_topic_id INT NULL,
+ priority_id INT NULL,
+ is_public TINYINT NOT NULL DEFAULT 1,
+ is_active TINYINT NOT NULL DEFAULT 1,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL
+ )$eng");
+ $defaults = ['csat_enabled' => '0', 'csat_delay_minutes' => '30', 'status_page_enabled' => '0', 'status_page_title' => 'Systemstatus'];
+ $chk = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
+ $ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
+ foreach ($defaults as $k => $v) {
+ $chk->execute([$k]);
+ if ((int) $chk->fetchColumn() === 0) $ins->execute([$k, $v]);
+ }
+ $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0048_csat_status_templates']);
+ $result['applied']++;
+ if ($verbose) $result['log'][] = ' [OK] php_0048_csat_status_templates';
+ } catch (Throwable $e) {
+ $result['errors'][] = 'php_0048_csat_status_templates: ' . $e->getMessage();
+ if ($verbose) $result['log'][] = ' [FAIL] php_0048_csat_status_templates - ' . $e->getMessage();
+ }
+ }
+
+ // Migration 0049: gesammelte, noch nicht übersetzte Texte (Sammelmodus der Mehrsprachigkeit).
+ if (!isset($done['php_0049_i18n_missing'])) {
+ try {
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
+ $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
+ $pdo->exec("CREATE TABLE IF NOT EXISTS i18n_missing (
+ id $ai,
+ text_hash CHAR(32) NOT NULL,
+ text VARCHAR(400) NOT NULL,
+ hits INT NOT NULL DEFAULT 1,
+ last_seen DATETIME NOT NULL
+ )$eng");
+ $pdo->exec('CREATE UNIQUE INDEX idx_i18n_missing_hash ON i18n_missing (text_hash)');
+ $chk = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
+ $ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
+ foreach (['default_language' => 'de', 'i18n_collect' => '0'] as $k => $v) {
+ $chk->execute([$k]);
+ if ((int) $chk->fetchColumn() === 0) $ins->execute([$k, $v]);
+ }
+ $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0049_i18n_missing']);
+ $result['applied']++;
+ if ($verbose) $result['log'][] = ' [OK] php_0049_i18n_missing';
+ } catch (Throwable $e) {
+ $result['errors'][] = 'php_0049_i18n_missing: ' . $e->getMessage();
+ if ($verbose) $result['log'][] = ' [FAIL] php_0049_i18n_missing - ' . $e->getMessage();
+ }
+ }
+
return $result;
}
diff --git a/DeskHub-Webseite/docs/video/deskhub-demo.gif b/DeskHub-Webseite/docs/video/deskhub-demo.gif
index b94b3dd2..242af019 100644
Binary files a/DeskHub-Webseite/docs/video/deskhub-demo.gif and b/DeskHub-Webseite/docs/video/deskhub-demo.gif differ