Upload via GUI (335 Dateien)

This commit is contained in:
2026-10-24 20:14:51 +00:00
parent c3b6bd5dcd
commit 7ee6d97c63
8 changed files with 536 additions and 0 deletions
+76
View File
@@ -1058,3 +1058,79 @@ body:has(.auth-wrap) .page-content > .auth-wrap {
details > summary { outline: none; } details > summary { outline: none; }
details > summary::-webkit-details-marker { display: none; } details > summary::-webkit-details-marker { display: none; }
details[open] > summary span:last-child { transform: rotate(180deg); display:inline-block; } 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; }
}
+103
View File
@@ -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);
})();
+108
View File
@@ -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: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
check: '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
ban: '<circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
phone: '<rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>',
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
puzzle: '<path d="M20.5 11H19V7a2 2 0 0 0-2-2h-4V3.5a2.5 2.5 0 0 0-5 0V5H4a2 2 0 0 0-2 2v3.8h1.5a2.7 2.7 0 0 1 0 5.4H2V20a2 2 0 0 0 2 2h3.8v-1.5a2.7 2.7 0 0 1 5.4 0V22H17a2 2 0 0 0 2-2v-4h1.5a2.5 2.5 0 0 0 0-5z"/>',
building: '<rect x="4" y="2" width="16" height="20" rx="2"/><line x1="9" y1="22" x2="9" y2="18"/><line x1="15" y1="22" x2="15" y2="18"/><line x1="8" y1="6" x2="8.01" y2="6"/><line x1="12" y1="6" x2="12.01" y2="6"/><line x1="16" y1="6" x2="16.01" y2="6"/><line x1="8" y1="10" x2="8.01" y2="10"/><line x1="12" y1="10" x2="12.01" y2="10"/><line x1="16" y1="10" x2="16.01" y2="10"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
key: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
mail: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
book: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
bot: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M12 8V4"/><circle cx="12" cy="3" r="1"/><line x1="8" y1="14" x2="8.01" y2="14"/><line x1="16" y1="14" x2="16.01" y2="14"/>',
briefcase: '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
shieldCheck: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
clipboard: '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/>',
branch: '<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
trend: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
repeat: '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
db: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
activity: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
megaphone: '<path d="M3 11l18-8v18L3 13z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
userx: '<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="18" y1="8" x2="23" y2="13"/><line x1="23" y1="8" x2="18" y2="13"/>',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
globe: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>'
};
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 '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (P[name] || P.puzzle) + '</svg>';
}
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 ? '&raquo;' : '&laquo;';
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();
})();
@@ -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 <input type="file"> 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);
});
})();
+38
View File
@@ -0,0 +1,38 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/run_plugins.php
*
* Löst die Hintergrundaufgaben der Plugins aus (Hook "cron.run"): Überwachung, Tagesbericht, NAS-Backup, Rundmail-Versand usw.
* Läuft jede Minute, z. B. per Cronjob (wird mit „Neu installieren / aktualisieren“ unter Einstellungen → Cron-Jobs eingetragen):
* * * * * * /usr/bin/php /pfad/zu/deskhub/cron/run_plugins.php
* Das Ereignis „cron.minute“ kommt bei jedem Lauf, „cron.run“ höchstens alle 5 Minuten.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{count:int, log:string[]} */
function run_plugins_cron(PDO $pdo, bool $verbose = false): array
{
$n = count(plugin_state()['loaded'] ?? []);
// „cron.minute“: bei jedem Lauf (etwa jede Minute), für Aufgaben mit kurzem Prüfabstand (zum Beispiel die Überwachung)
plugin_hook('cron.minute', ['time' => 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";
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/send_csat.php
*
* Verschickt die Zufriedenheitsabfrage an Kunden, deren Ticket geschlossen oder gelöst wurde (Einstellungen → Allgemein →
* „Zufriedenheitsabfrage“). Pro Ticket gibt es höchstens eine Mail.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{sent:int, log:string[]} */
function run_send_csat(PDO $pdo, bool $verbose = false): array
{
set_setting('cron_send_csat_last_run', db_now());
if (!csat_enabled()) {
return ['sent' => 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";
}
+108
View File
@@ -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; return $result;
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB