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