diff --git a/desktop/src/app.js b/desktop/src/app.js index 400b6ffd..cc92218b 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -1,916 +1,963 @@ -'use strict'; -// append() soll null/false/Arrays wie h() behandeln (sonst erscheint der Text "null") -const _append = Element.prototype.append; -Element.prototype.append = function (...a) { return _append.apply(this, a.flat(Infinity).filter((x) => x != null && x !== false)); }; -const $app = document.getElementById('app'); -const state = { staff: null, meta: null, since: '', badge: 0, page: 'dashboard', timers: [] }; - -// ─── Helfer ────────────────────────────────────────────────────────────────── -function h(tag, attrs, ...kids) { - const el = document.createElement(tag); - for (const [k, v] of Object.entries(attrs || {})) { - if (v === false || v == null) continue; - if (k === 'class') el.className = v; - else if (k === 'color') el.style.setProperty('background', v); - else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); - else if (k === 'value') el.value = v; - else if (k === 'checked' || k === 'disabled' || k === 'selected') el[k] = !!v; - else el.setAttribute(k, v); - } - for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid))); - return el; -} -const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); return el; }; -const can = (p) => state.staff && (state.staff.role === 'admin' || state.staff.permissions?.[p] === true); -const isAdmin = () => state.staff?.role === 'admin'; - -function toast(msg, error) { - const t = document.getElementById('toast'); - t.textContent = msg; t.className = 'toast' + (error ? ' error' : ''); - clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.add('hidden'), error ? 5000 : 2500); -} -async function api(path, opts = {}) { - const r = await window.deskhub.api({ path, ...opts }); - if (r.error) { - const down = r.error.code === 'NETWORK' || [502, 503, 504].includes(r.error.status); - if (down) setOffline(true); - const e = new Error(down ? 'Keine Verbindung zum Server. Es wird automatisch erneut versucht.' : r.error.message); - e.code = r.error.code; e.status = r.error.status; e.offline = down; throw e; - } - if (state.offline) setOffline(false); - return r.data; -} - -// ─── Verbindungsstatus & Systemhinweise (Banner unter der Kopfleiste) ─────── -function setOffline(on) { - if (state.offline === on) return; - state.offline = on; - clearTimeout(state.retryTimer); - if (on) { state.retryIn = 10; state.retryTimer = setTimeout(retryTick, 1000); } - else { toast('Verbindung wiederhergestellt'); if (state.staff) go(state.page, state.arg); } - paintBanners(); -} -async function retryNow() { - state.retryIn = 10; paintBanners(); - try { await api('me.php'); } catch { /* Banner bleibt, Zähler läuft weiter */ } -} -function retryTick() { - if (!state.offline) return; - state.retryIn -= 1; - if (state.retryIn <= 0) { retryNow().finally(() => { if (state.offline) state.retryTimer = setTimeout(retryTick, 1000); }); return; } - paintBanners(); state.retryTimer = setTimeout(retryTick, 1000); -} -function paintBanners() { - const box = document.getElementById('banners'); if (!box) return; - clear(box); - if (state.offline) { - box.append(h('div', { class: 'banner error' }, h('span', { class: 'grow' }, `Keine Verbindung zum Server – nächster Versuch in ${state.retryIn || 10} s. Bereits geladene Daten sind eventuell veraltet.`), - h('button', { class: 'btn ghost sm', onclick: retryNow }, 'Jetzt erneut versuchen'))); - } - for (const i of state.health || []) box.append(h('div', { class: 'banner ' + (i.level === 'error' ? 'error' : 'warn') }, h('span', { class: 'grow' }, (i.level === 'error' ? 'Störung: ' : 'Hinweis: ') + i.message))); -} -const post = (path, body) => api(path, { method: 'POST', body }); -async function run(fn, okMsg) { - try { const r = await fn(); if (okMsg) toast(okMsg); return r; } - catch (e) { toast(e.message, true); return undefined; } -} -const fmtDate = (s) => s ? new Date(s.replace(' ', 'T')).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; -const textToHtml = (t) => '

' + t.replace(/&/g, '&').replace(//g, '>').split(/\n{2,}/).map((p) => p.replace(/\n/g, '
')).join('

') + '

'; -const statusInfo = (v) => state.meta?.statuses.find((s) => s.value === v) || { label: v, color: '#64748b' }; - -function modal(title, bodyEl, buttons) { - const bg = h('div', { class: 'modal-bg' }); - const close = () => bg.remove(); - bg.append(h('div', { class: 'modal' }, h('h2', {}, title), bodyEl, - h('div', { class: 'row', style: null }, ...(buttons || []).map((b) => h('button', { class: 'btn ' + (b.cls || ''), onclick: () => b.fn(close) }, b.label)), - h('button', { class: 'btn ghost', onclick: close }, 'Abbrechen')))); - bg.addEventListener('mousedown', (e) => { if (e.target === bg) close(); }); - document.body.append(bg); - return close; -} - -// ─── Login ─────────────────────────────────────────────────────────────────── -async function showLogin(prefillUrl) { - clear($app); - document.body.classList.remove('in-app'); - const url = h('input', { placeholder: 'https://support.example.de', value: prefillUrl || localStorage.getItem('serverUrl') || '' }); - const email = h('input', { type: 'email', placeholder: 'mitarbeiter@firma.de' }); - const pw = h('input', { type: 'password' }); - const totp = h('input', { placeholder: '6-stelliger Code', maxlength: 6 }); - const totpRow = h('div', { class: 'hidden' }, h('label', {}, 'Authenticator-Code'), totp); - const err = h('div', { class: 'err' }); - const btn = h('button', { class: 'btn', type: 'submit' }, 'Anmelden'); - const form = h('form', {}, h('label', {}, 'Server-Adresse'), url, h('label', {}, 'E-Mail'), email, h('label', {}, 'Passwort'), pw, totpRow, err, h('div', { class: 'row' }, btn)); - form.addEventListener('submit', async (e) => { - e.preventDefault(); btn.disabled = true; err.textContent = ''; - const r = await window.deskhub.login({ serverUrl: url.value, email: email.value, password: pw.value, totpCode: totp.value }); - btn.disabled = false; - if (r.error) { err.textContent = r.error; return; } - if (r.requiresTotp) { totpRow.classList.remove('hidden'); totp.focus(); err.textContent = 'Bitte Authenticator-Code eingeben.'; return; } - localStorage.setItem('serverUrl', url.value); - state.serverUrl = (await window.deskhub.session())?.serverUrl || ''; - await startApp(r.staff, null, state.serverUrl); - }); - $app.append(h('div', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mitarbeiter-Anmeldung'), form))); - (url.value ? email : url).focus(); -} - -// ─── App-Rahmen ────────────────────────────────────────────────────────────── -const adminSections = () => ADMIN.filter((s) => (s.admin ? isAdmin() : can(s.perm))); -const NAV = [['dashboard', 'home', 'Übersicht'], ['tickets', 'ticket', 'Tickets'], ['tasks', 'check', 'Aufgaben'], ['customers', 'users', 'Kunden'], ['chat', 'chat', 'Team-Chat']]; - -// Schlichte Linien-Icons (inline SVG, CSP-sicher über createElementNS) -const ICONS = { - home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10', - ticket: 'M3 9a2 2 0 002-2V5h14v2a2 2 0 002 2v2a2 2 0 00-2 2v2a2 2 0 002 2v2H5v-2a2 2 0 00-2-2v-2a2 2 0 002-2V9z M9 8v2 M9 14v2', - check: 'M4 12l5 5L20 6', - users: 'M16 11a4 4 0 10-8 0 4 4 0 008 0 M3 20c0-3.3 4-5 9-5s9 1.7 9 5', - chat: 'M4 5h16v11H9l-5 4V5z', - bubble: 'M21 12a8 8 0 01-11.6 7.1L4 20l1.1-4.6A8 8 0 1121 12z', - dot: 'M12 12m-4 0a4 4 0 108 0 4 4 0 10-8 0', - gear: 'M12 15a3 3 0 100-6 3 3 0 000 6z M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4.4l-.4 2.7a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4.4l.4-2.7a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z', - sliders: 'M4 6h10 M18 6h2 M4 12h4 M12 12h8 M4 18h12 M20 18h0 M14 4v4 M8 10v4 M16 16v4', - search: 'M11 4a7 7 0 100 14 7 7 0 000-14z M21 21l-4.3-4.3', - chart: 'M4 20V10 M10 20V4 M16 20v-7 M22 20H2', -}; -function icon(name) { - const NS = 'http://www.w3.org/2000/svg'; - const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); - for (const d of (ICONS[name] || ICONS.dot).split(' M').map((x, i) => (i ? 'M' + x : x))) { const path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); svg.appendChild(path); } - return svg; -} -const initials = (name) => (name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join(''); - -// Schneller Start: Oberfläche sofort aus dem Zwischenspeicher aufbauen, Serverdaten danach im Hintergrund erneuern. -const metaKey = (url) => 'meta:' + url; -async function startApp(staff, cachedMeta, serverUrl) { - state.staff = staff; - state.since = ''; state.badge = 0; - if (!state.prefs) state.prefs = await window.deskhub.settings(); - applyTheme(); - const fresh = (async () => { - try { - const m = await api('metadata.php'); - const changed = !state.meta || JSON.stringify(m.staff.permissions) !== JSON.stringify(state.meta.staff.permissions) || m.liveChatEnabled !== state.meta.liveChatEnabled; - state.meta = m; state.staff = m.staff; state.health = m.health || []; - store.set(metaKey(`${serverUrl}:${staff.id}`), m); - return changed; - } catch (e) { toast(e.message, true); return false; } - })(); - if (cachedMeta) { - state.meta = cachedMeta; state.staff = cachedMeta.staff; - renderShell(); go('dashboard'); schedulePoll(0); - if (await fresh) { renderShell(); go(state.page, state.arg); } - } else { - await fresh; renderShell(); go('dashboard'); schedulePoll(0); - } -} - -// Design: "system" folgt der Windows-Einstellung (und reagiert live auf Wechsel), sonst fest Hell/Dunkel. -const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); -function applyTheme() { - const pref = state.prefs?.theme || 'system'; - document.documentElement.dataset.theme = pref === 'system' ? (darkQuery.matches ? 'dark' : 'light') : pref; -} -darkQuery.addEventListener('change', () => applyTheme()); - -function renderShell() { - clear($app); - document.body.classList.add('in-app'); - const items = [...NAV]; - if (state.meta?.liveChatEnabled) items.push(['livechat', 'bubble', 'Live-Chat']); - if (can('view_reports')) items.push(['reports', 'chart', 'Berichte']); - items.push(['availability', 'dot', 'Verfügbarkeit']); - if (adminSections().length) items.push(['admin', 'sliders', 'Verwaltung']); - const top = h('header', { class: 'topbar' }, - h('div', { class: 'brand' }, h('span', { class: 'logo' }, 'D'), 'DeskHub'), - can('create_ticket') ? h('button', { class: 'btn ok', onclick: () => go('newticket') }, 'Neues Ticket') : null, - h('button', { class: 'searchbtn', onclick: openPalette }, h('span', { class: 'nl' }, icon('search'), 'Ticket, Kunde oder Bereich suchen…'), h('kbd', {}, 'Strg K')), - h('div', { class: 'spacer' }), - h('div', { class: 'who' }, h('div', { class: 'avatar' }, initials(state.staff.name)), - h('div', {}, h('div', { class: 'name' }, state.staff.name), h('div', { class: 'role' }, state.staff.role === 'admin' ? 'Administrator' : 'Mitarbeiter'))), - h('button', { class: 'btn ghost sm', onclick: doLogout }, 'Abmelden')); - const side = h('nav', { class: 'side' }, - ...items.map(([k, ic, label]) => h('button', { class: 'nav', 'data-page': k, onclick: () => go(k) }, h('span', { class: 'nl' }, icon(ic), label), k === 'tickets' ? h('span', { class: 'badge hidden', id: 'badge' }) : null)), - h('div', { class: 'spacer' }), - h('button', { class: 'nav', 'data-page': 'settings', onclick: () => go('settings') }, h('span', { class: 'nl' }, icon('gear'), 'Einstellungen'))); - $app.append(h('div', { class: 'shell' }, top, side, h('div', { class: 'mainwrap' }, h('div', { id: 'banners' }), h('main', { class: 'main', id: 'main' })))); - paintBanners(); -} - -const PAGE_GROUP = { ticket: 'tickets', newticket: 'tickets' }; -const VIEWS_BY_PAGE = () => ({ dashboard: vDashboard, tickets: vTickets, ticket: vTicket, newticket: vNewTicket, tasks: vTasks, customers: vCustomers, - chat: vChat, reports: vReports, livechat: vLiveChat, availability: vAvailability, admin: vAdmin, settings: vSettings }); - -function go(page, arg) { - state.page = page; state.arg = arg; - for (const n of document.querySelectorAll('.nav')) n.classList.toggle('active', n.dataset.page === (PAGE_GROUP[page] || page)); - state.timers.filter((t) => t.page).forEach((t) => clearInterval(t.id)); - state.timers = state.timers.filter((t) => !t.page); - const main = clear(document.getElementById('main')); - main.scrollTop = 0; - const view = VIEWS_BY_PAGE()[page]; - if (!view) return; - Promise.resolve(view(main, arg)).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message))); -} -function every(ms, fn) { const id = setInterval(fn, ms); state.timers.push({ id, page: true }); } - -async function doLogout() { - clearTimeout(state.pollTimer); - await window.deskhub.logout(); - state.timers.forEach((t) => clearInterval(t.id ?? t)); state.timers = []; state.staff = null; - window.deskhub.badge(0, null); - showLogin(); -} - -// ─── Benachrichtigungen & Zähler ──────────────────────────────────────────── -function badgeImage(n) { - const c = document.createElement('canvas'); c.width = c.height = 32; - const g = c.getContext('2d'); - g.fillStyle = '#ef4444'; g.beginPath(); g.arc(16, 16, 15, 0, Math.PI * 2); g.fill(); - g.fillStyle = '#fff'; g.font = 'bold 20px Segoe UI, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; - g.fillText(n > 99 ? '99+' : String(n), 16, 17); - return c.toDataURL('image/png'); -} -function paintBadge() { - const b = document.getElementById('badge'); - if (b) { b.textContent = state.badge; b.classList.toggle('hidden', !state.badge); } - window.deskhub.badge(state.badge, state.badge ? badgeImage(state.badge) : null); -} -function resetBadge() { state.badge = 0; paintBadge(); } - -function schedulePoll(delay) { - clearTimeout(state.pollTimer); - const seconds = Math.max(15, Number(state.prefs?.pollSeconds) || 60); - state.pollTimer = setTimeout(async () => { await pollActivity(); if (state.staff) schedulePoll(seconds * 1000); }, delay ?? seconds * 1000); -} - -async function pollActivity() { - try { - const a = await api('activity.php', { query: { since: state.since } }); - if (state.since && (a.newReplies || a.newUnassigned)) { - state.badge += a.newReplies + a.newUnassigned; - if (state.prefs?.notifications !== false) { - const parts = []; - if (a.newReplies) parts.push(`${a.newReplies} Aktualisierung(en) an deinen Tickets`); - if (a.newUnassigned) parts.push(`${a.newUnassigned} neue(s) nicht zugewiesene(s) Ticket(s)`); - window.deskhub.notify('DeskHub', parts.join(', ')); - } - paintBadge(); - } - state.since = a.serverTime; - state.health = a.health || []; paintBanners(); - } catch {} -} - -// ─── Übersicht ─────────────────────────────────────────────────────────────── -const dueClass = (t) => (t.isOverdue ? 'over' : 'muted'); -function ticketTable(items, opts = {}) { - if (!items.length) return h('div', { class: 'panel muted' }, 'Keine Tickets gefunden.'); - const selected = opts.selected; - const allBox = selected ? h('input', { type: 'checkbox', onchange: (e) => { items.forEach((t) => (e.target.checked ? selected.add(t.number) : selected.delete(t.number))); opts.onSelect?.(); for (const c of document.querySelectorAll('.rowcheck')) c.checked = e.target.checked; } }) : null; - return h('div', { class: 'panel flush' }, h('table', { class: 'tickets' }, - h('tr', {}, selected ? h('th', { class: 'chk' }, allBox) : null, h('th', {}, 'Nr.'), h('th', {}, 'Betreff'), h('th', {}, 'Kunde'), h('th', {}, 'Status'), h('th', {}, 'Priorität'), h('th', {}, 'Zugewiesen'), h('th', {}, 'Fällig'), h('th', {}, 'Aktualisiert')), - ...items.map((t) => h('tr', { class: 'click' + (t.isOverdue ? ' overdue' : ''), onclick: () => go('ticket', t.number) }, - selected ? h('td', { class: 'chk', onclick: (e) => e.stopPropagation() }, h('input', { type: 'checkbox', class: 'rowcheck', checked: selected.has(t.number), onchange: (e) => { e.target.checked ? selected.add(t.number) : selected.delete(t.number); e.target.closest('tr').classList.toggle('sel', e.target.checked); opts.onSelect?.(); } })) : null, - h('td', { class: 'nowrap' }, t.number), h('td', {}, (t.isSpam ? '🚫 ' : '') + t.subject), h('td', {}, t.requesterName), - h('td', { class: 'nowrap' }, h('span', { class: 'pill', color: statusInfo(t.status).color }, statusInfo(t.status).label)), - h('td', { class: 'nowrap' }, h('span', { class: 'dot', color: t.priority.color }), t.priority.name), - h('td', {}, t.assignedStaff?.name || '—'), h('td', { class: dueClass(t) + ' nowrap' }, t.dueLabel || ''), h('td', { class: 'muted nowrap' }, fmtDate(t.updatedAt)))))); -} - -async function vDashboard(main) { - const body = h('div'); - const hour = new Date().getHours(), greet = hour < 11 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend'; - main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, `${greet}, ${state.staff.name.split(' ')[0]}`), - h('div', { class: 'sub' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }))), - h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), body); - const load = async () => { - const d = await run(() => api('dashboard.php')); if (!d) return; - const stat = (n, label, q, cls) => h('div', { class: 'stat ' + (cls || '') + (q ? ' link' : ''), onclick: q ? () => go('tickets', q) : null }, h('div', { class: 'n' }, n), h('div', { class: 'muted' }, label)); - clear(body).append(h('div', { class: 'cards' }, - stat(d.myOpen, 'Meine offenen', { view: 'mine' }), stat(d.overdue, 'Überfällig', { view: 'overdue' }, d.overdue ? 'bad' : ''), - stat(d.unassigned, 'Nicht zugewiesen', { view: 'unassigned' }, d.unassigned ? 'warn' : ''), stat(d.unanswered, 'Unbeantwortet', { view: 'active' }), - stat(d.resolvedToday, 'Heute gelöst', null, 'ok'), d.slaPercent != null ? stat(Math.round(d.slaPercent) + '%', 'SLA-Quote', null, d.slaPercent < 80 ? 'warn' : 'ok') : null), - h('div', { class: 'split2' }, - h('div', {}, h('h3', {}, 'Zuletzt aktualisiert'), ticketTable(d.recent)), - d.departments?.length ? h('div', {}, h('h3', {}, 'Abteilungen'), h('div', { class: 'panel' }, ...d.departments.map((x) => { - const max = Math.max(1, ...d.departments.map((y) => y.open)); - return h('div', { class: 'bar' }, h('div', { class: 'row' }, h('span', { class: 'grow' }, x.name), h('span', { class: 'muted' }, `${x.open} offen`), x.overdue ? h('span', { class: 'over' }, `${x.overdue} überfällig`) : null), - h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round((x.open / max) * 100) }))); - }))) : null)); - for (const f of body.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); - }; - await load(); - every(30000, load); -} - -// ─── Tickets ───────────────────────────────────────────────────────────────── -const VIEWS = { - active: ['Alle aktiven', { status: 'open,pending,answered,on_hold', assigned: '', sla: '' }], - mine: ['Meine', { status: 'open,pending,answered,on_hold', assigned: 'me', sla: '' }], - unassigned: ['Nicht zugewiesen', { status: 'open,pending,answered,on_hold', assigned: 'unassigned', sla: '' }], - overdue: ['Überfällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'overdue' }], - atrisk: ['Bald fällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'at_risk' }], - closed: ['Erledigt', { status: 'resolved,closed', assigned: '', sla: '' }], - all: ['Alle', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '' }], -}; -const store = { - get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, - set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, - del(k) { try { localStorage.removeItem(k); } catch {} }, -}; - -async function vTickets(main, preset) { - const saved = store.get('ticketFilter', {}); - const f = { view: 'active', q: '', departmentId: '', priorityId: '', page: 1, ...saved, ...(preset || {}) }; - if (!VIEWS[f.view]) f.view = 'active'; - const selected = new Set(); - const list = h('div'), bulk = h('div', { class: 'bulk hidden' }); - const custom = {}; // gespeicherte Ansichten (Serverseite), getrennt von den festen Reitern - const filt = () => ({ ...((VIEWS[f.view] || custom[f.view] || ['', {}])[1]), q: f.q, departmentId: f.departmentId, priorityId: f.priorityId, page: f.page }); - const persist = () => store.set('ticketFilter', { view: f.view, departmentId: f.departmentId, priorityId: f.priorityId }); - const tabs = h('div', { class: 'tabs wrap' }); - const viewsBar = h('div', { class: 'viewsbar' }); - const applyView = (q) => { - const k = 'q' + q.id; const { q: text = '', departmentId = '', priorityId = '', ...rest } = q.filters || {}; - custom[k] = [q.name, { status: '', assigned: '', sla: '', ...rest }]; - Object.assign(f, { view: k, q: text, departmentId, priorityId, page: 1 }); selected.clear(); - search.value = text; paintTabs(); paintViews(); load(); - }; - const saveView = () => { - const name = h('input', { placeholder: 'z. B. Meine überfälligen' }), shared = h('input', { type: 'checkbox' }); - modal('Ansicht speichern', h('div', {}, h('p', { class: 'muted' }, 'Speichert die aktuellen Filter (Reiter, Suche, Abteilung, Priorität).'), h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), - [{ label: 'Speichern', fn: async (close) => { - const { page, ...filters } = filt(); - if (await run(() => post('queues.php', { action: 'save', name: name.value, isShared: shared.checked, filters }), 'Ansicht gespeichert')) { close(); paintViews(); } - } }]); - }; - const paintViews = async () => { - const r = await api('queues.php').catch(() => null); const items = r?.items || []; - clear(viewsBar).append(items.length ? h('span', { class: 'muted viewlabel' }, 'Ansichten') : null, - ...items.map((q) => h('span', { class: 'viewchip' + (f.view === 'q' + q.id ? ' active' : '') }, - h('button', { class: 'viewbtn', title: q.mine ? 'Meine Ansicht' : `Geteilt von ${q.ownerName || ''}`, onclick: () => applyView(q) }, q.name + (q.isShared ? ' ⇄' : '')), - q.mine || isAdmin() ? h('button', { class: 'viewx', title: 'Ansicht löschen', onclick: () => confirm('Ansicht löschen?') && run(async () => { await post('queues.php', { action: 'delete', id: q.id }); paintViews(); }) }, '✕') : null)), - h('button', { class: 'btn ghost sm', onclick: saveView }, '+ Ansicht speichern')); - }; - const paintTabs = () => { clear(tabs).append(...Object.entries(VIEWS).map(([k, [label]]) => h('button', { class: 'tab' + (f.view === k ? ' active' : ''), onclick: () => { f.view = k; f.page = 1; selected.clear(); persist(); paintTabs(); load(); } }, label))); }; - - const paintBulk = () => { - bulk.classList.toggle('hidden', selected.size === 0); clear(bulk); - if (!selected.size) return; - const act = async (patch, label) => { - let ok = 0; const fails = []; - for (const number of [...selected]) { try { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); ok++; } catch (e) { fails.push(`${number}: ${e.message}`); } } - toast(`${label}: ${ok} Ticket(s)` + (fails.length ? ` · ${fails.length} Fehler (${fails[0]})` : ''), !!fails.length); - selected.clear(); load(); - }; - const st = h('select', { onchange: (e) => { if (e.target.value) act({ status: e.target.value }, 'Status gesetzt'); } }, h('option', { value: '' }, 'Status setzen…'), ...state.meta.statuses.map((s) => h('option', { value: s.value }, s.label))); - const as = h('select', { onchange: (e) => { if (e.target.value !== '-') act({ assignedStaffId: e.target.value === '' ? null : +e.target.value }, 'Zugewiesen'); } }, h('option', { value: '-' }, 'Zuweisen…'), h('option', { value: '' }, '— niemand —'), ...state.meta.agents.map((a) => h('option', { value: a.id }, a.name))); - bulk.append(h('b', {}, `${selected.size} ausgewählt`), can('reply_ticket') || can('close_ticket') ? st : null, can('assign_ticket') ? as : null, - can('assign_ticket') ? h('button', { class: 'btn sm', onclick: () => act({ assignedStaffId: state.staff.id }, 'Mir zugewiesen') }, 'Mir zuweisen') : null, - h('button', { class: 'btn ghost sm', onclick: () => { selected.clear(); paintBulk(); for (const c of document.querySelectorAll('.rowcheck')) c.checked = false; } }, 'Auswahl aufheben')); - }; - - const load = async (silent) => { - const r = await (silent ? api('tickets.php', { query: filt() }).catch(() => null) : run(() => api('tickets.php', { query: filt() }))); - if (!r) return; - clear(list).append(ticketTable(r.items, { selected, onSelect: paintBulk }), h('div', { class: 'row pager' }, - h('button', { class: 'btn ghost sm', disabled: f.page <= 1, onclick: () => { f.page--; load(); } }, '‹ Zurück'), - h('span', { class: 'muted' }, `Seite ${r.page} · ${r.total} Tickets`), - h('button', { class: 'btn ghost sm', disabled: !r.hasMore, onclick: () => { f.page++; load(); } }, 'Weiter ›'))); - paintBulk(); - }; - const sel = (key, opts) => h('select', { onchange: (e) => { f[key] = e.target.value; f.page = 1; persist(); load(); } }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(f[key]) === String(v) }, l))); - const search = h('input', { placeholder: 'Suchen (Betreff, Nummer, Kunde, Text)… Strg+F', value: f.q, class: 'search', onkeydown: (e) => { if (e.key === 'Enter') { f.q = e.target.value; f.page = 1; load(); } } }); - search.id = 'ticket-search'; - main.append(h('div', { class: 'row' }, h('h2', { class: 'grow' }, 'Tickets'), h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), - tabs, viewsBar, h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => { f.q = search.value; f.page = 1; load(); } }, 'Suchen'), - sel('departmentId', [['', 'Alle Abteilungen'], ...state.meta.departments.map((d) => [d.id, d.name])]), sel('priorityId', [['', 'Alle Prioritäten'], ...state.meta.priorities.map((p) => [p.id, p.name])])), - bulk, list); - paintTabs(); paintViews(); resetBadge(); - await load(); - every(30000, () => { if (!selected.size && !document.querySelector('.modal-bg')) load(true); }); -} - -// ─── Ticket-Detail ─────────────────────────────────────────────────────────── -async function vTicket(main, number) { - let t; - try { t = (await api('ticket.php', { query: { number } })).ticket; } - catch (e) { main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Zurück'), h('div', { class: 'warnbox' }, e.message)); return; } - - // Rechte gelten pro Abteilung des Tickets; ältere Server liefern sie nicht -> globale Rechte - const can = (p) => (t.permissions ? t.permissions[p] === true : (state.staff.role === 'admin' || state.staff.permissions?.[p] === true)); - const reload = () => go('ticket', number); - const update = (patch, msg) => run(async () => { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); reload(); }, msg); - const mkSelect = (opts, val, onchange, disabled) => h('select', { disabled, onchange: (e) => onchange(e.target.value) }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val) }, l))); - const mine = t.assignedStaff?.id === state.staff.id; - const closed = ['resolved', 'closed'].includes(t.status); - - const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket')); - const assignSel = mkSelect([['', '— niemand —'], ...state.meta.agents.map((a) => [a.id, a.name])], t.assignedStaff?.id ?? '', (v) => update({ assignedStaffId: v === '' ? null : Number(v) }, 'Zuweisung geändert'), !can('assign_ticket')); - - // Antwort-Editor mit Entwurf - let mode = can('reply_ticket') ? 'reply' : 'note'; - const draftKey = (m) => `draft:${number}:${m}`; - const ta = h('textarea', { placeholder: 'Nachricht… (Strg+Enter zum Senden)', class: 'editor' }); - const loadDraft = () => { ta.value = store.get(draftKey(mode), ''); }; - ta.addEventListener('input', () => ta.value ? store.set(draftKey(mode), ta.value) : store.del(draftKey(mode))); - const tabs = h('div', { class: 'tabs' }); - const sendStatus = mkSelect([['', 'Status: automatisch'], ...state.meta.statuses.map((s) => [s.value, 'Setzen auf: ' + s.label])], '', () => {}); - let cannedList = []; - const canned = h('select', { onchange: (e) => { const c = cannedList.find((x) => String(x.id) === e.target.value); if (c) { ta.value += (ta.value ? '\n' : '') + c.body.replace(//gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '').trim(); ta.dispatchEvent(new Event('input')); ta.focus(); } e.target.value = ''; } }, h('option', { value: '' }, 'Textbaustein…')); - // Nur Textbausteine der Abteilung des Tickets und die für alle Abteilungen (ältere Server liefern alle: dann hier filtern) - api('canned_responses.php', { query: { departmentId: t.department?.id } }).then((r) => { - cannedList = r.items.filter((c) => c.departmentId == null || c.departmentId === t.department?.id); - const group = (label, items) => { if (items.length) canned.append(h('optgroup', { label }, ...items.map((c) => h('option', { value: c.id }, c.title)))); }; - group('Abteilung ' + (t.department?.name ?? ''), cannedList.filter((c) => c.departmentId != null)); - group('Für alle Abteilungen', cannedList.filter((c) => c.departmentId == null)); - }).catch(() => {}); - - // Signatur wählen (meine / Abteilung / keine) mit Vorschau; ältere Server liefern keine Signaturen: dann ohne Auswahl - const sigs = t.replySignatures; - let sigChoice = sigs?.default ?? 'none'; - const sigPreview = h('div', { class: 'sigpreview' }); - // Die Signatur kommt als bereits vom Server bereinigtes HTML; hier wird sie nochmals gesäubert (keine Skripte, Handler, Stile, Links) - const sigNodes = (html) => { - const doc = new DOMParser().parseFromString('' + html, 'text/html'); - doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,input,button,svg,math,base').forEach((n) => n.remove()); - doc.body.querySelectorAll('*').forEach((el) => { - for (const a of [...el.attributes]) { - const n = a.name.toLowerCase(); - if (n.startsWith('on') || n === 'style' || n === 'srcset' || n === 'href') el.removeAttribute(a.name); - else if (n === 'src' && !/^(https:|data:image\/)/i.test(a.value.trim())) el.removeAttribute(a.name); - } - }); - return [...doc.body.childNodes]; - }; - const paintSig = () => { - const e = sigChoice === 'own' ? sigs.own : sigChoice === 'dept' ? sigs.department : null; - clear(sigPreview); - sigPreview.classList.toggle('text', !(e?.available && e.html)); - if (e?.available && e.html) sigPreview.append(...sigNodes(e.html)); - else sigPreview.textContent = e?.available ? e.text : (sigChoice === 'none' ? 'Es wird keine Signatur angehängt.' : 'Keine Signatur hinterlegt.'); - }; - const sigSelect = sigs ? h('select', { onchange: (e) => { sigChoice = e.target.value; paintSig(); } }, - h('option', { value: 'own', selected: sigChoice === 'own', disabled: !sigs.own.available }, 'Meine Signatur' + (sigs.own.available ? '' : ' (nicht hinterlegt)')), - h('option', { value: 'dept', selected: sigChoice === 'dept', disabled: !sigs.department.available }, 'Abteilung „' + sigs.department.name + '“' + (sigs.department.available ? '' : ' (nicht hinterlegt)')), - h('option', { value: 'none', selected: sigChoice === 'none' }, 'Keine Signatur')) : null; - const sigBox = sigs ? h('div', { class: 'sigbox' }, h('div', { class: 'row' }, h('span', { class: 'muted small' }, 'Signatur'), sigSelect, h('span', { class: 'muted small' }, 'wird nur an Antworten an den Kunden angehängt')), sigPreview) : null; - if (sigs) paintSig(); - const sendBtn = h('button', { class: 'btn' }, 'Senden'); - const paintTabs = () => { - clear(tabs); - if (can('reply_ticket')) tabs.append(h('button', { class: 'tab' + (mode === 'reply' ? ' active' : ''), onclick: () => { mode = 'reply'; loadDraft(); paintTabs(); } }, 'Antwort an Kunden')); - if (can('post_note')) tabs.append(h('button', { class: 'tab' + (mode === 'note' ? ' active' : ''), onclick: () => { mode = 'note'; loadDraft(); paintTabs(); } }, 'Interne Notiz')); - sendStatus.classList.toggle('hidden', mode !== 'reply'); - sigBox?.classList.toggle('hidden', mode !== 'reply'); - sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern'; - ta.classList.toggle('noteedit', mode === 'note'); - }; - paintTabs(); loadDraft(); - const send = async () => { - if (!ta.value.trim() || sendBtn.disabled) return; - if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return; - sendBtn.disabled = true; - const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); - sendBtn.disabled = false; - if (ok) { store.del(draftKey(mode)); reload(); } - }; - sendBtn.addEventListener('click', send); - ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); } }); - - const quote = (m) => { ta.value += (ta.value ? '\n\n' : '') + m.message.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'; ta.dispatchEvent(new Event('input')); ta.focus(); }; - const threadsEl = h('div', { class: 'threads' }, ...t.threads.map((m) => { - const cust = m.authorType !== 'staff' && !m.isInternalNote; - return h('div', { class: 'thread ' + (m.isInternalNote ? 'note' : cust ? 'cust' : 'staff') }, - h('div', { class: 'head' }, h('span', { class: 'who' }, h('span', { class: 'avatar sm' }, initials(m.authorName)), h('b', {}, m.authorName), - h('span', { class: 'kind' }, m.isInternalNote ? 'Interne Notiz' : cust ? 'Kunde' : 'Antwort')), h('span', {}, fmtDate(m.createdAt))), - h('div', { class: 'body' }, m.message), - m.attachments.length ? h('div', { class: 'muted att' }, '📎 ' + m.attachments.map((a) => `${a.name} (${Math.round(a.size / 1024)} KB)`).join(', ') + ' – Download nur im Web') : null, - ...m.comments.map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff.name + ': '), c.message)), - cust && can('reply_ticket') ? h('div', { class: 'tools' }, h('button', { class: 'btn ghost sm', onclick: () => { mode = 'reply'; paintTabs(); loadDraft(); quote(m); } }, 'Zitieren')) : null); - })); - - const spamBtn = can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => run(async () => { await post('ticket_spam.php', { number, action: t.isSpam ? 'unspam' : 'spam' }); reload(); }, t.isSpam ? 'Kein Spam' : 'Als Spam markiert') }, t.isSpam ? 'Kein Spam' : 'Spam') : null; - const others = h('div', { class: 'muted' }, 'Lade…'); - if (t.requesterEmail) api('customer.php', { query: { email: t.requesterEmail } }).then((r) => { - const rest = r.tickets.filter((x) => x.number !== number).slice(0, 6); - clear(others).append(...(rest.length ? rest.map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, h('span', { class: 'dot', color: statusInfo(x.status).color }), `${x.number} · ${x.subject}`)) : ['Keine weiteren Tickets.'])); - }).catch(() => clear(others).append('—')); - - const actions = h('div', { class: 'tkactions' }, - can('assign_ticket') && !mine ? h('button', { class: 'btn sm', onclick: () => update({ assignedStaffId: state.staff.id }, 'Dir zugewiesen') }, 'Mir zuweisen') : null, - !closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => update({ status: 'resolved' }, 'Gelöst') }, 'Lösen') : null, - !closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => update({ status: 'closed' }, 'Geschlossen') }, 'Schließen') : null, - closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null, - spamBtn, - h('button', { class: 'btn ghost sm', title: 'Ticketnummer kopieren', onclick: () => { navigator.clipboard?.writeText(t.number); toast('Nummer kopiert'); } }, 'Nummer kopieren'), - h('button', { class: 'btn ghost sm', onclick: reload }, 'Aktualisieren')); - const st = statusInfo(t.status); - const prop = (label, ...kids) => [h('dt', {}, label), h('dd', {}, ...kids)]; - - main.append( - h('div', { class: 'tkhead' }, - h('div', { class: 'tkhead-top' }, h('button', { class: 'btn ghost sm', onclick: () => go('tickets') }, 'Zurück zu Tickets'), h('span', { class: 'chip' }, '#' + t.number), - h('span', { class: 'pill', color: st.color }, st.label), t.isOverdue ? h('span', { class: 'pill', color: '#d63b3b' }, 'Überfällig') : null, t.isSpam ? h('span', { class: 'pill', color: '#667384' }, 'Spam') : null), - h('h2', {}, t.subject), - h('div', { class: 'sub' }, `${t.requesterName} · ${t.department.name} · erstellt ${fmtDate(t.createdAt)}`), - actions), - t.lockedByOtherName ? h('div', { class: 'warnbox' }, `Dieser Chat wird gerade von „${t.lockedByOtherName}“ bearbeitet – nur Ansicht.`) : null, - h('div', { class: 'split' }, - h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null), - h('div', {}, - h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'), - h('dl', { class: 'props' }, - ...prop('Status', statusSel), ...prop('Zugewiesen an', assignSel), - ...prop('Priorität', h('span', { class: 'dot', color: t.priority.color }), t.priority.name), - ...prop('Abteilung', t.department.name), - ...prop('Erstellt', fmtDate(t.createdAt)), - ...(t.resolutionDueAt ? prop('Lösung fällig', h('span', { class: t.isOverdue ? 'over' : '' }, fmtDate(t.resolutionDueAt))) : []))), - (t.customFields && t.customFields.length) ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Angaben des Kunden'), - h('dl', { class: 'props' }, ...t.customFields.flatMap((f) => prop(f.label, f.value || '–')))) : null, - h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kunde'), - h('div', { class: 'custcard' }, h('span', { class: 'avatar' }, initials(t.requesterName)), h('div', {}, h('b', {}, t.requesterName), h('div', { class: 'muted' }, t.requesterEmail))), - t.requesterEmail ? h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => go('customers', t.requesterEmail) }, 'Kundenprofil')) : null), - h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Weitere Tickets des Kunden'), others)))); - - // Neue Nachrichten von Kunden/Kollegen erkennen, ohne den Entwurf zu verlieren - every(45000, async () => { - try { - const n = (await api('ticket.php', { query: { number } })).ticket; - if (n.threads.length !== t.threads.length || n.status !== t.status) { toast('Ticket wurde aktualisiert'); reload(); } - } catch {} - }); - threadsEl.lastElementChild?.scrollIntoView({ block: 'nearest' }); -} - -async function vNewTicket(main) { - if (!can('create_ticket')) { main.append(h('div', { class: 'warnbox' }, 'Dir fehlt das Recht „Tickets erstellen“.')); return; } - const m = state.meta; - const f = { name: h('input'), email: h('input', { type: 'email' }), subject: h('input'), message: h('textarea'), - dept: h('select', {}, ...m.departments.map((d) => h('option', { value: d.id }, d.name))), - prio: h('select', {}, ...m.priorities.map((p) => h('option', { value: p.id }, p.name))), - agent: h('select', {}, h('option', { value: '' }, '— niemand —'), ...m.agents.map((a) => h('option', { value: a.id }, a.name))), - notify: h('input', { type: 'checkbox', checked: true }) }; - main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Tickets'), h('h2', {}, 'Neues Ticket'), - h('div', { class: 'panel' }, - h('label', {}, 'Name des Kunden'), f.name, h('label', {}, 'E-Mail des Kunden (optional)'), f.email, h('label', {}, 'Betreff'), f.subject, - h('label', {}, 'Beschreibung'), f.message, h('label', {}, 'Abteilung'), f.dept, h('label', {}, 'Priorität'), f.prio, - h('label', {}, 'Zuweisen an'), f.agent, h('label', { class: 'check' }, f.notify, 'Kunden per E-Mail benachrichtigen'), - h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => { - const r = await run(() => post('ticket_create.php', { requesterName: f.name.value, requesterEmail: f.email.value, subject: f.subject.value, - message: textToHtml(f.message.value), departmentId: +f.dept.value, priorityId: +f.prio.value, assignedStaffId: f.agent.value ? +f.agent.value : null, notifyCustomer: f.notify.checked }), 'Ticket erstellt'); - if (r) go('ticket', r.ticket.number); - } }, 'Ticket erstellen')))); -} - -// ─── Aufgaben ──────────────────────────────────────────────────────────────── -async function vTasks(main) { - const body = h('div'); - main.append(h('div', { class: 'row' }, h('h2', { class: 'grow' }, 'Aufgaben'), h('button', { class: 'btn', onclick: () => newTask() }, '+ Aufgabe')), body); - let staffOpts = []; - const load = async () => { - const r = await api('tasks.php', { query: { filter: 'all' } }).catch((e) => { toast(e.message, true); return null; }); - if (!r) return; staffOpts = r.staffOptions; - clear(body).append(h('div', { class: 'panel' }, r.items.length ? h('table', {}, h('tr', {}, h('th', {}, ''), h('th', {}, 'Aufgabe'), h('th', {}, 'Zuständig'), h('th', {}, 'Fällig'), h('th', {}, '')), - ...r.items.map((t) => h('tr', {}, h('td', {}, h('input', { type: 'checkbox', checked: t.status === 'done', onchange: (e) => run(async () => { await post('tasks.php', { action: 'update_status', id: t.id, status: e.target.checked ? 'done' : 'open' }); load(); }) })), - h('td', {}, h('b', {}, t.title), t.description ? h('div', { class: 'muted' }, t.description) : null, t.ticketNumber ? h('a', { href: '#', onclick: (e) => { e.preventDefault(); go('ticket', t.ticketNumber); } }, ' ' + t.ticketNumber) : null), - h('td', {}, t.assignedName || '—'), h('td', {}, fmtDate(t.dueAt)), - h('td', {}, h('button', { class: 'btn danger sm', onclick: () => confirm('Aufgabe löschen?') && run(async () => { await post('tasks.php', { action: 'delete', id: t.id }); load(); }) }, '✕'))))) : h('div', { class: 'muted' }, 'Keine Aufgaben.'))); - }; - function newTask() { - const title = h('input'), desc = h('textarea'), who = h('select', {}, h('option', { value: '' }, '— niemand —'), ...staffOpts.map((s) => h('option', { value: s.id }, s.name))), due = h('input', { type: 'datetime-local' }); - modal('Neue Aufgabe', h('div', {}, h('label', {}, 'Titel'), title, h('label', {}, 'Beschreibung'), desc, h('label', {}, 'Zuständig'), who, h('label', {}, 'Fällig'), due), - [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('tasks.php', { action: 'create', title: title.value, description: desc.value || null, assignedToId: who.value ? +who.value : null, dueAt: due.value ? due.value.replace('T', ' ') + ':00' : null }))) { close(); load(); } } }]); - } - load(); -} - -// ─── Kunden ────────────────────────────────────────────────────────────────── -async function vCustomers(main, email) { - const body = h('div'); - const search = h('input', { placeholder: 'Name oder E-Mail suchen…', value: email || '', onkeydown: (e) => e.key === 'Enter' && load() }); - main.append(h('h2', {}, 'Kunden'), h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => load() }, 'Suchen')), body); - const load = async () => { - const r = await run(() => api('customers.php', { query: { q: search.value } })); if (!r) return; - clear(body).append(h('div', { class: 'panel' }, h('table', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'E-Mail'), h('th', {}, 'Organisation'), h('th', {}, 'Tickets')), - ...r.items.map((c) => h('tr', { class: 'click', onclick: () => open(c) }, h('td', {}, c.name + (c.isGuest ? ' (Gast)' : '')), h('td', {}, c.email), h('td', {}, c.organizationName || ''), h('td', {}, c.ticketCount)))))); - }; - const open = async (c) => { - const r = await run(() => api('customer.php', { query: c.id != null ? { userId: c.id } : { email: c.email } })); if (!r) return; - clear(body).append(h('button', { class: 'btn ghost', onclick: load }, '‹ Liste'), h('h3', {}, r.customer.name), h('div', { class: 'muted' }, r.customer.email), ticketTable(r.tickets)); - }; - load(); -} - -// ─── Team-Chat ─────────────────────────────────────────────────────────────── -async function vChat(main) { - const list = h('div', { class: 'chatlist' }), box = h('div', { class: 'chatbox' }, h('div', { class: 'msgs muted' }, 'Kollegen auswählen')); - main.append(h('h2', {}, 'Team-Chat'), h('div', { class: 'chatwrap' }, list, box)); - let current = null; - const loadContacts = async () => { - const r = await api('chat.php').catch(() => null); if (!r) return; - clear(list).append(...r.contacts.map((c) => h('div', { class: 'item' + (current === c.id ? ' active' : ''), onclick: () => openChat(c.id) }, c.name, c.unreadCount ? h('span', { class: 'badge' }, c.unreadCount) : null))); - }; - const openChat = async (id) => { - current = id; - const r = await run(() => api('chat.php', { query: { with: id } })); if (!r) return; - const msgs = h('div', { class: 'msgs' }, ...r.messages.map((m) => h('div', { class: 'msg' + (m.fromStaffId === state.staff.id ? ' mine' : '') }, h('small', {}, `${m.fromName} · ${fmtDate(m.createdAt)}`), m.message))); - const inp = h('input', { placeholder: 'Nachricht…', onkeydown: (e) => e.key === 'Enter' && send() }); - const send = async () => { if (!inp.value.trim()) return; const t = inp.value; inp.value = ''; if (await run(() => post('chat.php', { action: 'send', toId: id, message: t }))) openChat(id); }; - clear(box).append(h('div', { class: 'msgs-title', style: null }), msgs, h('div', { class: 'chatinput' }, inp, h('button', { class: 'btn', onclick: send }, 'Senden'))); - msgs.scrollTop = msgs.scrollHeight; inp.focus(); loadContacts(); - }; - await loadContacts(); - every(15000, () => { loadContacts(); }); -} - -// ─── Live-Chat ─────────────────────────────────────────────────────────────── -async function vLiveChat(main) { - const list = h('div', { class: 'chatlist' }), box = h('div', { class: 'chatbox' }, h('div', { class: 'msgs muted' }, 'Chat auswählen')); - main.append(h('h2', {}, 'Live-Chat (Kunden)'), h('div', { class: 'chatwrap' }, list, box)); - let cur = null; - const loadList = async () => { - const r = await api('live_chat.php', { query: { action: 'list' } }).catch(() => null); if (!r) return; - clear(list).append(...(r.items.length ? r.items.map((c) => h('div', { class: 'item' + (cur === c.number ? ' active' : ''), onclick: () => openChat(c.number) }, h('span', {}, c.name, h('div', { class: 'muted' }, c.preview)), c.needsReply ? h('span', { class: 'badge' }, '!') : null)) : [h('div', { class: 'item muted' }, 'Keine aktiven Chats')])); - }; - const openChat = async (number) => { - cur = number; - const r = await run(() => api('live_chat.php', { query: { action: 'messages', number, since: 0 } })); if (!r) return; - const msgs = h('div', { class: 'msgs' }, ...r.messages.map((m) => h('div', { class: 'msg' + (m.authorType === 'staff' ? ' mine' : '') }, h('small', {}, `${m.authorName || r.requesterName} · ${fmtDate(m.createdAt)}`), m.message))); - const inp = h('input', { placeholder: 'Antwort…', disabled: !!r.lockedByOtherName, onkeydown: (e) => e.key === 'Enter' && send() }); - const send = async () => { if (!inp.value.trim()) return; const t = inp.value; inp.value = ''; if (await run(() => post('live_chat.php', { action: 'send', number, message: t }))) openChat(number); }; - clear(box).append(r.lockedByOtherName ? h('div', { class: 'warnbox' }, `Wird gerade von ${r.lockedByOtherName} bearbeitet.`) : null, - h('div', { class: 'row' }, h('b', {}, `${r.requesterName} (${r.departmentName})`), h('button', { class: 'btn danger sm', onclick: () => confirm('Chat beenden?') && run(async () => { await post('live_chat.php', { action: 'end', number }); cur = null; clear(box); loadList(); }) }, 'Chat beenden')), - msgs, h('div', { class: 'chatinput' }, inp, h('button', { class: 'btn', disabled: !!r.lockedByOtherName, onclick: send }, 'Senden'))); - msgs.scrollTop = msgs.scrollHeight; - }; - await loadList(); - every(5000, () => { loadList(); if (cur && !document.querySelector('.chatinput input')?.value) openChat(cur); }); -} - -// ─── Berichte (Bericht-Baukasten) ──────────────────────────────────────────── -async function vReports(main) { - const cfg = { dimension: 'department', metrics: ['total', 'open', 'resolved'], from: '', to: '', sort: 'label', desc: false, filters: {} }; - const out = h('div'), controls = h('div', { class: 'panel' }), savedBar = h('div', { class: 'viewsbar' }); - let meta = null; - const fmt = (v, unit) => (v == null ? '–' : unit === '%' ? `${String(v).replace('.', ',')} %` : unit === 'Std' ? `${String(v).replace('.', ',')} Std` : String(v)); - const query = (extra = {}) => ({ dimension: cfg.dimension, metrics: cfg.metrics.join(','), from: cfg.from, to: cfg.to, sort: cfg.sort, desc: cfg.desc ? 1 : '', ...cfg.filters, ...extra }); - const run_ = async (extra) => { - const r = await run(() => api('report_builder.php', { query: query(extra) })); if (!r) return; - meta = r; Object.assign(cfg, { dimension: r.config.dimension, metrics: r.config.metrics, from: r.config.from, to: r.config.to, sort: r.config.sort, desc: r.config.desc }); - paintControls(); paintSaved(); - const max = {}; for (const c of r.columns.slice(1)) max[c.key] = Math.max(1, ...r.rows.map((x) => Number(x[c.key]) || 0)); - clear(out).append(h('div', { class: 'panel flush' }, r.rows.length ? h('table', {}, - h('tr', {}, ...r.columns.map((c) => h('th', {}, c.label))), - ...r.rows.map((row) => h('tr', {}, h('td', {}, h('b', {}, row.label)), ...r.columns.slice(1).map((c) => h('td', {}, fmt(row[c.key], c.unit), c.unit === '' && row[c.key] != null ? h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round((row[c.key] / max[c.key]) * 100) })) : null)))), - h('tr', { class: 'totalrow' }, h('td', {}, 'Gesamt'), ...r.columns.slice(1).map((c) => h('td', {}, fmt(r.totals[c.key], c.unit))))) : h('div', { class: 'muted pad' }, 'Keine Tickets im gewählten Zeitraum.'))); - for (const f of out.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); - }; - const paintSaved = () => clear(savedBar).append(meta?.saved?.length ? h('span', { class: 'muted viewlabel' }, 'Gespeichert') : null, - ...(meta?.saved || []).map((s) => h('span', { class: 'viewchip' }, h('button', { class: 'viewbtn', onclick: () => run_({ saved: s.id }) }, s.name + (s.isShared ? ' ⇄' : '')), - s.mine || isAdmin() ? h('button', { class: 'viewx', title: 'Löschen', onclick: () => confirm('Bericht löschen?') && run(async () => { await post('report_builder.php', { action: 'delete', id: s.id }); run_(); }) }, '✕') : null))); - const paintControls = () => { - const dim = h('select', { onchange: (e) => { cfg.dimension = e.target.value; } }, ...(meta?.dimensions || []).map((d) => h('option', { value: d.key, selected: d.key === cfg.dimension }, d.label))); - const from = h('input', { type: 'date', value: cfg.from, onchange: (e) => { cfg.from = e.target.value; } }), to = h('input', { type: 'date', value: cfg.to, onchange: (e) => { cfg.to = e.target.value; } }); - const dept = h('select', { onchange: (e) => { cfg.filters.departmentId = e.target.value; } }, h('option', { value: '' }, 'Alle Abteilungen'), ...state.meta.departments.map((d) => h('option', { value: d.id, selected: String(cfg.filters.departmentId) === String(d.id) }, d.name))); - const prio = h('select', { onchange: (e) => { cfg.filters.priorityId = e.target.value; } }, h('option', { value: '' }, 'Alle Prioritäten'), ...state.meta.priorities.map((p) => h('option', { value: p.id, selected: String(cfg.filters.priorityId) === String(p.id) }, p.name))); - const metrics = (meta?.metrics || []).map((m) => h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: cfg.metrics.includes(m.key), onchange: (e) => { cfg.metrics = e.target.checked ? [...cfg.metrics, m.key] : cfg.metrics.filter((k) => k !== m.key); } }), m.label)); - clear(controls).append(h('div', { class: 'row' }, h('label', {}, 'Gruppieren nach'), dim, h('label', {}, 'Von'), from, h('label', {}, 'Bis'), to), h('div', { class: 'row' }, dept, prio), - h('div', { class: 'checks wide' }, ...metrics), - h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run_() }, 'Bericht erstellen'), - h('button', { class: 'btn ghost', onclick: () => { const name = h('input', { placeholder: 'z. B. Monatsbericht Support' }), shared = h('input', { type: 'checkbox' }); - modal('Bericht speichern', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('report_builder.php', { action: 'save', name: name.value, isShared: shared.checked, config: { ...cfg } }), 'Bericht gespeichert')) { close(); run_(); } } }]); } }, 'Bericht speichern'), - h('span', { class: 'muted' }, 'CSV-Export: in der Weboberfläche unter Bericht-Baukasten.'))); - }; - main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Berichte'), h('div', { class: 'sub' }, 'Frei konfigurierbare Auswertungen nach Erstellungsdatum der Tickets'))), controls, savedBar, out); - await run_(); -} - -// ─── Verfügbarkeit ─────────────────────────────────────────────────────────── -async function vAvailability(main) { - const r = await api('availability.php'); - const labels = { available: '🟢 Verfügbar', busy: '🔴 Beschäftigt', break: '🟡 Pause', offline: '⚪ Offline' }; - const sel = h('select', {}, ...Object.entries(labels).map(([v, l]) => h('option', { value: v, selected: v === r.myStatus }, l))); - const note = h('input', { value: r.myNote, placeholder: 'Notiz (optional)' }); - main.append(h('h2', {}, 'Verfügbarkeit'), h('div', { class: 'panel' }, h('label', {}, 'Mein Status'), sel, h('label', {}, 'Notiz'), note, - h('div', { class: 'row' }, h('button', { class: 'btn', onclick: () => run(() => post('availability.php', { status: sel.value, note: note.value }), 'Gespeichert') }, 'Speichern'))), - h('h3', {}, 'Team'), h('div', { class: 'panel' }, h('table', {}, ...r.team.map((m) => h('tr', {}, h('td', {}, m.name), h('td', {}, m.isOnVacation ? '🏖 Urlaub' : labels[m.status] || m.status), h('td', { class: 'muted' }, m.note)))))); -} - -// ─── Verwaltung (rechtegesteuert, wie in der Android-App) ─────────────────── -const T = 'text', N = 'number', B = 'bool', A = 'area', H = 'heading', ES = 'emailsel', XD = 'extradepts'; -const ADMIN = [ - { key: 'departments', label: 'Abteilungen', perm: 'manage_departments', file: 'departments.php', cols: [['Name', 'name'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Mitglieder', (x) => (x.members || []).length], ['Abteilungsleiter', 'managerName'], ['E-Mail', 'email'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], - fields: [['_h1', 'Abteilungsinformationen', H], ['name', 'Name', T], ['isActive', 'Aktiv', B], ['managerId', 'Abteilungsleiter', 'staffOptions?'], ['slaPlanId', 'SLA-Plan (leer = Zeiten der Priorität)', 'slaPlans?'], ['email', 'Abteilungs-E-Mail (optional, sonst Standardadresse)', ES], ['isDefault', 'Standardabteilung', B], ['signature', 'Signatur', A], ['notes', 'Administratornotizen (intern)', A]], - info: (x) => x?.members?.length ? `Mitglieder: ${x.members.map((m) => m.name + (m.isPrimary ? '' : ' (zusätzlich)')).join(', ')}` : '' }, - { key: 'priorities', label: 'Prioritäten / SLA', perm: 'manage_priorities', file: 'priorities.php', cols: [['Name', 'name'], ['Reaktion (h)', 'responseHours'], ['Lösung (h)', 'resolutionHours']], - fields: [['name', 'Name', T], ['color', 'Farbe (#RRGGBB)', T], ['responseHours', 'Reaktionszeit (Std.)', N], ['resolutionHours', 'Lösungszeit (Std.)', N], ['sortOrder', 'Reihenfolge', N]] }, - { key: 'sla_plans', label: 'SLA-Pläne', perm: 'manage_priorities', file: 'sla_plans.php', cols: [['Name', 'name'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Reaktion (Std)', 'responseHours'], ['Lösung (Std)', 'resolutionHours'], ['Abteilungen', 'departmentCount'], ['Hilfethemen', 'helpTopicCount']], - fields: [['name', 'Name', T], ['isActive', 'Aktiv', B], ['responseHours', 'Reaktionszeit (Std)', N], ['resolutionHours', 'Lösungszeit (Std)', N], ['notes', 'Administratornotizen (intern)', A]] }, - { key: 'help_topics', label: 'Hilfethemen', perm: 'manage_help_topics', file: 'help_topics.php', cols: [['Hilfethema', 'path'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Typ', (x) => x.isPublic ? 'Öffentlich' : 'Intern'], ['Priorität', 'priorityName'], ['Abteilung', 'departmentName'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], - fields: [['_h1', 'Hilfethemeninformationen', H], ['name', 'Thema', T], ['isActive', 'Aktiv', B], ['isPublic', 'Öffentlich (für Kunden sichtbar)', B], ['parentId', 'Übergeordnetes Thema', 'items?'], ['notes', 'Interne Notizen (vertraulich)', A], - ['_h2', 'Optionen bei neuem Ticket', H], ['departmentId', 'Abteilung', 'departments'], ['priorityId', 'Priorität', 'priorities'], ['slaPlanId', 'SLA-Plan (leer = Abteilung/Priorität)', 'slaPlans?'], ['sortOrder', 'Reihenfolge', N]] }, - { key: 'kb', label: 'Wissensdatenbank', perm: 'manage_kb', file: 'kb.php', list: 'articles', saveAction: 'save_article', deleteAction: 'delete_article', cols: [['Titel', 'title'], ['Kategorie', 'categoryName'], ['Veröffentlicht', (x) => x.isPublished ? '✓' : ''], ['Aufrufe', 'views']], - fields: [['title', 'Titel', T], ['categoryId', 'Kategorie', 'categories'], ['body', 'Inhalt', A], ['isPublished', 'Veröffentlicht', B]] }, - { key: 'email_addresses', label: 'E-Mail-Adressen', perm: 'manage_email_addresses', file: 'email_addresses.php', cols: [['E-Mail', 'email'], ['Anzeigename', 'label'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Abteilung', 'departmentName'], ['Abruf', (x) => x.imapEnabled ? '✓' : '–'], ['Eigener SMTP', (x) => x.smtpEnabled ? '✓' : '–'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], - actions: [['Test-Mail senden', 'sendTest'], ['Postfach testen', 'testImap'], ['SMTP testen', 'testSmtp']], - fields: [['_h1', 'Konto', H], ['email', 'E-Mail-Adresse', T], ['label', 'Anzeigename', T], ['isActive', 'Aktiv (sonst deaktiviert)', B], ['departmentId', 'Abteilung', 'departments'], ['priorityId', 'Priorität', 'priorities?'], ['helpTopicId', 'Hilfethema', 'helpTopics?'], ['isDefault', 'Standard-Absenderadresse der Abteilung', B], ['notes', 'Interne Notizen', A], - ['_h2', 'Entferntes Postfach (Abruf per IMAP)', H], ['imapEnabled', 'Abruf aktivieren', B], ['imapHost', 'Server', T], ['imapPort', 'Port', N], - ['imapEncryption', 'Verschlüsselung', [['ssl', 'SSL/TLS (993)'], ['tls', 'STARTTLS (143)'], ['none', 'Keine']]], ['imapUsername', 'Benutzername (leer = E-Mail-Adresse)', T], ['imapPassword', 'Passwort (leer = unverändert)', 'pw'], - ['imapFolder', 'Ordner', T], ['imapAfterFetch', 'Aktion nach Abruf', [['seen', 'Als gelesen markieren'], ['archive', 'In Ordner verschieben'], ['delete', 'Löschen']]], ['imapArchiveFolder', 'Zielordner (bei Verschieben)', T], ['imapSpamFolder', 'Spam-/Junk-Ordner', T], - ['_h3', 'Abgehend (eigener SMTP-Server)', H], ['smtpEnabled', 'Eigenen SMTP-Server verwenden', B], ['smtpHost', 'Server', T], ['smtpPort', 'Port', N], - ['smtpEncryption', 'Verschlüsselung', [['tls', 'STARTTLS (587)'], ['ssl', 'SSL/TLS (465)'], ['none', 'Keine']]], ['smtpUsername', 'Benutzername', T], ['smtpPassword', 'Passwort (leer = unverändert)', 'pw']] }, - { key: 'organizations', label: 'Organisationen', perm: 'manage_organizations', file: 'organizations.php', cols: [['Name', 'name'], ['Mitglieder', 'memberCount']], - fields: [['name', 'Name', T], ['notes', 'Notizen', A]] }, - { key: 'staff', label: 'Mitarbeiter', perm: 'manage_staff', file: 'staff_manage.php', cols: [['Name', 'name'], ['E-Mail', 'email'], ['Typ', (x) => x.role === 'admin' ? 'Administrator' : 'Mitarbeiter'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Gesperrt'], ['Hauptabteilung', 'departmentName'], ['Teams', (x) => (x.teamIds || []).length]], - fields: [['_h1', 'Konto', H], ['name', 'Name', T], ['email', 'E-Mail', T], ['isActive', 'Aktiv (sonst gesperrt)', B], ['password', 'Passwort (leer = unverändert)', 'pw'], ['signature', 'Signatur', A], - ['_h2', 'Zugang', H], ['departmentId', 'Hauptabteilung', 'departments?'], ['extraDepartments', 'Erweiterter Zugriff', XD], - ['_h3', 'Berechtigungen', H], ['role', 'Kontotyp', [['agent', 'Mitarbeiter'], ['admin', 'Administrator (alle Rechte)']]], ['roleId', 'Rolle (für Mitarbeiter)', 'roles?'], - ['_h4', 'Teams', H], ['teamIds', 'Mitglied in Teams', 'multi:teams']] }, - { key: 'banlist', label: 'Sperrliste', admin: true, file: 'banlist.php', cols: [['Typ', 'matchType'], ['Wert', 'value'], ['Grund', 'reason'], ['Von', 'addedByName']], saveAction: 'create', noEdit: true, - fields: [['matchType', 'Typ', [['email', 'E-Mail-Adresse'], ['domain', 'Domain']]], ['value', 'Wert', T], ['reason', 'Grund', T]] }, - { key: 'audit', label: 'Admin-Protokoll', perm: 'view_audit_log', file: 'audit_log.php', readonly: true, paged: true, cols: [['Zeit', (x) => fmtDate(x.createdAt)], ['Mitarbeiter', 'staffName'], ['Aktion', 'action']] }, - { key: 'time', label: 'Zeiterfassung', perm: 'view_time_entries', file: 'time_entries.php', readonly: true, list: 'entries', cols: [['Datum', (x) => fmtDate(x.loggedAt)], ['Mitarbeiter', 'staffName'], ['Ticket', 'ticketNumber'], ['Minuten', 'minutes'], ['Notiz', 'note']] }, -]; - -async function vAdmin(main, sectionKey) { - const sections = adminSections(); - if (!sectionKey) { - main.append(h('h2', {}, 'Verwaltung'), h('div', { class: 'tiles' }, ...sections.map((s) => h('div', { class: 'tile', onclick: () => go('admin', s.key) }, s.label))), - h('p', { class: 'muted' }, 'Sichtbar sind nur Bereiche, für die du freigeschaltet bist. Systemnahe Bereiche (Backup, Update, Postfächer, Einstellungen) gibt es nur in der Weboberfläche.')); - return; - } - const s = sections.find((x) => x.key === sectionKey); - if (!s) { main.append(h('div', { class: 'warnbox' }, 'Kein Zugriff.')); return; } - const body = h('div'); let page = 1; - const addBtn = h('button', { class: 'btn', onclick: () => edit(null) }, '+ Neu'); - if (s.readonly || (s.custom && !s.add)) addBtn.classList.add('hidden'); - main.append(h('div', { class: 'row' }, h('button', { class: 'btn ghost', onclick: () => go('admin') }, '‹ Verwaltung'), h('h2', { class: 'grow' }, s.label), addBtn), body); - if (s.custom) return s.custom(body, addBtn); - let resp; - const load = async () => { - resp = await run(() => api(s.file, { query: s.paged ? { page } : {} })); if (!resp) return; - const items = resp[s.list || 'items'] || []; - const cell = (x, c) => typeof c === 'function' ? c(x) : (x[c] ?? ''); - clear(body).append(h('div', { class: 'panel' }, items.length ? h('table', {}, h('tr', {}, ...s.cols.map((c) => h('th', {}, c[0])), s.readonly ? null : h('th', {})), - ...items.map((x) => h('tr', { class: s.readonly || s.noEdit ? '' : 'click', onclick: () => !s.readonly && !s.noEdit && edit(x) }, ...s.cols.map((c) => h('td', {}, cell(x, c[1]))), - s.readonly ? null : h('td', {}, h('button', { class: 'btn danger sm', onclick: (e) => { e.stopPropagation(); remove(x); } }, '✕'))))) : h('div', { class: 'muted' }, 'Keine Einträge.')), - s.paged ? h('div', { class: 'row' }, h('button', { class: 'btn ghost sm', disabled: page <= 1, onclick: () => { page--; load(); } }, '‹'), h('span', { class: 'muted' }, `Seite ${resp.page}`), h('button', { class: 'btn ghost sm', disabled: !resp.hasMore, onclick: () => { page++; load(); } }, '›')) : null); - if (s.key === 'time') body.append(h('p', { class: 'muted' }, `Gesamt: ${resp.totalMinutes} Minuten`)); - }; - const remove = (x) => confirm('Wirklich löschen?') && run(async () => { await post(s.file, { action: s.deleteAction || 'delete', id: x.id }); load(); }, 'Gelöscht'); - function edit(x) { - const inputs = {}; - const rows = s.fields.map(([k, label, type]) => { - let el; - if (type === H) return h('div', { class: 'sect' }, label); - if (type === ES) { - const cur = String(x?.[k] ?? '').toLowerCase(), opts = resp.emailOptions || []; - el = h('select', {}, h('option', { value: '' }, '— Automatisch —'), ...opts.map((o) => h('option', { value: o.email, selected: o.email.toLowerCase() === cur }, `${o.email} (${o.departmentName})`)), - cur && !opts.some((o) => o.email.toLowerCase() === cur) ? h('option', { value: x[k], selected: true }, x[k]) : null); - inputs[k] = el; return [h('label', {}, label), el]; - } - if (type === XD) { - const cur = new Map((x?.[k] || []).map((e) => [e.departmentId, e])); - const boxes = (resp.departments || []).map((d) => ({ - d, on: h('input', { type: 'checkbox', checked: cur.has(d.id) }), nt: h('input', { type: 'checkbox', checked: cur.get(d.id)?.notify ?? true }), - role: h('select', {}, h('option', { value: '' }, 'Hauptrolle'), ...(resp.roles || []).map((r) => h('option', { value: r.id, selected: cur.get(d.id)?.roleId === r.id }, r.name))), - })); - inputs[k] = { get: () => boxes.filter((b) => b.on.checked).map((b) => ({ departmentId: b.d.id, notify: b.nt.checked, roleId: b.role.value === '' ? null : Number(b.role.value) })) }; - return [h('label', {}, label), h('div', {}, ...boxes.map((b) => h('div', { class: 'row' }, h('label', { class: 'check grow' }, b.on, b.d.name), b.role, h('label', { class: 'check' }, b.nt, 'Benachrichtigungen'))))]; - } - if (typeof type === 'string' && type.startsWith('multi:')) { - const cur = new Set(x?.[k] || []), boxes = (resp[type.slice(6)] || []).map((o) => ({ o, el: h('input', { type: 'checkbox', checked: cur.has(o.id) }) })); - inputs[k] = { get: () => boxes.filter((b) => b.el.checked).map((b) => b.o.id) }; - return [h('label', {}, label), h('div', {}, ...(boxes.length ? boxes.map((b) => h('label', { class: 'check' }, b.el, b.o.name)) : [h('span', { class: 'muted' }, 'Keine vorhanden.')]))]; - } - if (type === B) { el = h('input', { type: 'checkbox', checked: x ? !!x[k] : k === 'isActive' || k === 'isPublished' || k === 'isPublic' }); inputs[k] = el; return h('label', { class: 'check' }, el, label); } - if (type === A) el = h('textarea', { value: x?.[k] ?? '' }); - else if (type === N) el = h('input', { type: 'number', value: x?.[k] ?? (k === 'imapPort' ? 993 : k === 'smtpPort' ? 587 : 0) }); - else if (type === 'pw') el = h('input', { type: 'password' }); - else if (typeof type === 'string' && type !== T) { - const key = type.replace('?', ''), opt = type.endsWith('?'); - const list = key === 'items' ? (resp.items || []).filter((o) => !x || o.id !== x.id).map((o) => ({ id: o.id, name: o.path || o.name })) : (resp[key] || []); - el = h('select', {}, opt ? h('option', { value: '' }, '—') : null, ...list.map((o) => h('option', { value: o.id, selected: x && String(x[k]) === String(o.id) }, o.name))); - } else if (Array.isArray(type)) el = h('select', {}, ...type.map(([v, l]) => h('option', { value: v, selected: x && x[k] === v }, l))); - else el = h('input', { value: x?.[k] ?? (k === 'imapFolder' ? 'INBOX' : k === 'imapArchiveFolder' ? 'Processed' : k === 'imapSpamFolder' ? 'Junk' : '') }); - inputs[k] = el; - return [h('label', {}, label), el]; - }); - if (s.info && s.info(x)) rows.push(h('p', { class: 'muted' }, s.info(x))); - const extra = x && s.actions ? s.actions.map(([label, action]) => ({ label, cls: 'ghost', fn: async () => { - const r = await run(() => post(s.file, { action, id: x.id })); if (r) toast(r.message || 'OK'); - } })) : []; - modal(x ? 'Bearbeiten' : 'Neu', h('div', {}, ...rows), [...extra, { label: 'Speichern', fn: async (close) => { - const body = { action: s.saveAction || 'save' }; if (x) body.id = x.id; - for (const [k, , type] of s.fields) { - if (type === H) continue; - if (inputs[k]?.get) { body[k] = inputs[k].get(); continue; } - const el = inputs[k]; let v = type === B ? el.checked : type === N ? Number(el.value) : el.value; - if (typeof type === 'string' && ![T, N, B, A, 'pw', ES].includes(type)) v = v === '' ? null : Number(v); - if (type === 'pw' && v === '') v = ''; - body[k] = v; - } - if (await run(() => post(s.file, body), 'Gespeichert')) { close(); load(); } - } }]); - } - load(); -} - -// ─── Einstellungen ─────────────────────────────────────────────────────────── -async function vSettings(main) { - const p = await window.deskhub.settings(); state.prefs = p; - const save = async (patch, msg) => { state.prefs = await window.deskhub.setSettings(patch); applyTheme(); if ('pollSeconds' in patch) schedulePoll(); if (msg) toast(msg); }; - const check = (key, label, hint) => h('div', { class: 'setting' }, h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!p[key], onchange: (e) => save({ [key]: e.target.checked }, 'Gespeichert') }), label), hint ? h('div', { class: 'muted hint' }, hint) : null); - const session = await window.deskhub.session(); - main.append(h('h2', {}, 'Einstellungen'), - h('div', { class: 'panel' }, h('h3', {}, 'Darstellung'), - h('label', {}, 'Design'), h('select', { onchange: (e) => save({ theme: e.target.value }, 'Gespeichert') }, - ...[['system', 'Wie Windows (automatisch)'], ['light', 'Hell'], ['dark', 'Dunkel']].map(([v, l]) => h('option', { value: v, selected: (p.theme || 'system') === v }, l)))), - h('div', { class: 'panel' }, h('h3', {}, 'Windows'), - check('autostart', 'Mit Windows starten', p.packaged ? 'Startet DeskHub beim Anmelden im Hintergrund (Tray).' : 'Nur in der installierten Version verfügbar.'), - check('closeToTray', 'Beim Schließen im Infobereich weiterlaufen', 'So kommen Benachrichtigungen auch bei geschlossenem Fenster an. Beenden über das Tray-Menü.')), - h('div', { class: 'panel' }, h('h3', {}, 'Benachrichtigungen'), - check('notifications', 'Windows-Benachrichtigungen anzeigen', 'Bei neuen Aktualisierungen an deinen Tickets und neuen, nicht zugewiesenen Tickets.'), - h('label', {}, 'Abfrage-Intervall'), h('select', { onchange: (e) => save({ pollSeconds: +e.target.value }, 'Gespeichert') }, - ...[[30, 'alle 30 Sekunden'], [60, 'jede Minute'], [120, 'alle 2 Minuten'], [300, 'alle 5 Minuten']].map(([v, l]) => h('option', { value: v, selected: Number(p.pollSeconds) === v }, l))), - h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => window.deskhub.notify('DeskHub', 'Test-Benachrichtigung – so sieht eine Meldung aus.') }, 'Test-Benachrichtigung'))), - h('div', { class: 'panel' }, h('h3', {}, 'Konto'), - h('div', {}, `${state.staff.name} (${state.staff.email})`), h('div', { class: 'muted' }, session?.serverUrl || ''), - h('div', { class: 'row top' }, h('button', { class: 'btn ghost', onclick: doLogout }, 'Abmelden / Server wechseln'), - h('button', { class: 'btn ghost', onclick: () => { for (const k of Object.keys(localStorage).filter((k) => k.startsWith('draft:'))) localStorage.removeItem(k); toast('Entwürfe gelöscht'); } }, 'Gespeicherte Entwürfe löschen'))), - h('div', { class: 'panel muted' }, `DeskHub Desktop ${p.version} · Rechte und Daten kommen vom Server – genau wie in der Android-App.`), - h('div', { class: 'panel' }, h('h3', {}, 'Tastenkürzel'), h('table', {}, ...[['Strg + K', 'Schnellsuche / Befehle'], ['Strg + N', 'Neues Ticket'], ['Strg + Enter', 'Antwort senden'], ['F5', 'Ansicht aktualisieren'], ['Alt + ←', 'Zurück zur Ticketliste'], ['Esc', 'Fenster schließen']] - .map(([k, d]) => h('tr', {}, h('td', {}, h('kbd', {}, k)), h('td', {}, d)))))); -} - -// ─── Schnellsuche (Strg+K) ─────────────────────────────────────────────────── -function openPalette() { - if (!state.staff || document.querySelector('.palette-bg')) return; - const input = h('input', { placeholder: 'Ticketnummer, Betreff, Kunde oder Bereich…', class: 'palette-input' }); - const list = h('div', { class: 'palette-list' }); - const bg = h('div', { class: 'modal-bg palette-bg' }, h('div', { class: 'palette' }, input, list)); - let items = [], idx = 0, timer = null; - const close = () => bg.remove(); - const pages = () => { - const base = [...NAV.map(([k, , l]) => [k, l]), ['availability', 'Verfügbarkeit'], ['settings', 'Einstellungen']]; - if (state.meta?.liveChatEnabled) base.push(['livechat', 'Live-Chat']); - const cmds = base.map(([k, l]) => ({ label: l, hint: 'Bereich', run: () => go(k) })); - if (can('create_ticket')) cmds.push({ label: 'Neues Ticket erstellen', hint: 'Aktion', run: () => go('newticket') }); - for (const s of adminSections()) cmds.push({ label: s.label, hint: 'Verwaltung', run: () => go('admin', s.key) }); - return cmds; - }; - const paint = () => { - clear(list).append(...(items.length ? items.map((it, i) => h('div', { class: 'palette-item' + (i === idx ? ' active' : ''), onclick: () => { close(); it.run(); } }, h('span', {}, it.label), h('span', { class: 'muted' }, it.hint))) : [h('div', { class: 'muted pad' }, 'Keine Treffer.')])); - }; - const refresh = async () => { - const q = input.value.trim().toLowerCase(); - items = pages().filter((c) => !q || c.label.toLowerCase().includes(q)).slice(0, 6); - idx = 0; paint(); - if (q.length >= 2) { - try { - const r = await api('tickets.php', { query: { q: input.value.trim(), perPage: 10, status: 'open,pending,answered,on_hold,resolved,closed' } }); - if (input.value.trim().toLowerCase() !== q) return; - items = [...r.items.map((t) => ({ label: `${t.number} · ${t.subject}`, hint: `${t.requesterName} · ${statusInfo(t.status).label}`, run: () => go('ticket', t.number) })), ...items]; - paint(); - } catch {} - } - }; - input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(refresh, 200); }); - input.addEventListener('keydown', (e) => { - if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); paint(); e.preventDefault(); } - else if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); paint(); e.preventDefault(); } - else if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); } - }); - bg.addEventListener('mousedown', (e) => { if (e.target === bg) close(); }); - document.body.append(bg); input.focus(); refresh(); -} - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { const m = document.querySelector('.modal-bg'); if (m) { m.remove(); e.preventDefault(); } return; } - if (!state.staff) return; - const mod = e.ctrlKey || e.metaKey; - if (mod && e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette(); } - else if (mod && e.key.toLowerCase() === 'n' && can('create_ticket')) { e.preventDefault(); go('newticket'); } - else if (mod && e.key.toLowerCase() === 'f' && state.page === 'tickets') { e.preventDefault(); document.getElementById('ticket-search')?.focus(); } - else if (e.key === 'F5') { e.preventDefault(); go(state.page, state.arg); } - else if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); go('tickets'); } -}); - -// ─── Start ─────────────────────────────────────────────────────────────────── -window.deskhub.onSessionExpired(() => { clearTimeout(state.pollTimer); state.staff = null; toast('Sitzung abgelaufen – bitte neu anmelden.', true); showLogin(); }); -window.deskhub.onNavigate((page) => { if (state.staff) go(page); }); -(async () => { - // erst starten, wenn auch admin-extra.js geladen ist (ergänzt ADMIN) - if (document.readyState !== 'complete') await new Promise((r) => window.addEventListener('load', r, { once: true })); - state.prefs = await window.deskhub.settings(); applyTheme(); - const s = await window.deskhub.session(); - if (!s) return showLogin(); - state.serverUrl = s.serverUrl; - const cached = store.get(metaKey(`${s.serverUrl}:${s.staff.id}`), null); - if (cached) { startApp(s.staff, cached, s.serverUrl); return; } - try { await api('me.php'); await startApp(s.staff, null, s.serverUrl); } - catch { showLogin(s.serverUrl); } -})(); +'use strict'; +// append() soll null/false/Arrays wie h() behandeln (sonst erscheint der Text "null") +const _append = Element.prototype.append; +Element.prototype.append = function (...a) { return _append.apply(this, a.flat(Infinity).filter((x) => x != null && x !== false)); }; +const $app = document.getElementById('app'); +const state = { staff: null, meta: null, since: '', badge: 0, page: 'dashboard', timers: [] }; + +// ─── Helfer ────────────────────────────────────────────────────────────────── +function h(tag, attrs, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === false || v == null) continue; + if (k === 'class') el.className = v; + else if (k === 'color') el.style.setProperty('background', v); + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'value') el.value = v; + else if (k === 'checked' || k === 'disabled' || k === 'selected') el[k] = !!v; + else el.setAttribute(k, v); + } + for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid))); + return el; +} +const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); return el; }; +const can = (p) => state.staff && (state.staff.role === 'admin' || state.staff.permissions?.[p] === true); +const isAdmin = () => state.staff?.role === 'admin'; + +function toast(msg, error) { + const t = document.getElementById('toast'); + t.textContent = msg; t.className = 'toast' + (error ? ' error' : ''); + clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.add('hidden'), error ? 5000 : 2500); +} +async function api(path, opts = {}) { + const r = await window.deskhub.api({ path, ...opts }); + if (r.error) { + const down = r.error.code === 'NETWORK' || [502, 503, 504].includes(r.error.status); + if (down) setOffline(true); + const e = new Error(down ? 'Keine Verbindung zum Server. Es wird automatisch erneut versucht.' : r.error.message); + e.code = r.error.code; e.status = r.error.status; e.offline = down; throw e; + } + if (state.offline) setOffline(false); + return r.data; +} + +// ─── Verbindungsstatus & Systemhinweise (Banner unter der Kopfleiste) ─────── +function setOffline(on) { + if (state.offline === on) return; + state.offline = on; + clearTimeout(state.retryTimer); + if (on) { state.retryIn = 10; state.retryTimer = setTimeout(retryTick, 1000); } + else { toast('Verbindung wiederhergestellt'); if (state.staff) go(state.page, state.arg); } + paintBanners(); +} +async function retryNow() { + state.retryIn = 10; paintBanners(); + try { await api('me.php'); } catch { /* Banner bleibt, Zähler läuft weiter */ } +} +function retryTick() { + if (!state.offline) return; + state.retryIn -= 1; + if (state.retryIn <= 0) { retryNow().finally(() => { if (state.offline) state.retryTimer = setTimeout(retryTick, 1000); }); return; } + paintBanners(); state.retryTimer = setTimeout(retryTick, 1000); +} +function paintBanners() { + const box = document.getElementById('banners'); if (!box) return; + clear(box); + if (state.offline) { + box.append(h('div', { class: 'banner error' }, h('span', { class: 'grow' }, `Keine Verbindung zum Server – nächster Versuch in ${state.retryIn || 10} s. Bereits geladene Daten sind eventuell veraltet.`), + h('button', { class: 'btn ghost sm', onclick: retryNow }, 'Jetzt erneut versuchen'))); + } + for (const i of state.health || []) box.append(h('div', { class: 'banner ' + (i.level === 'error' ? 'error' : 'warn') }, h('span', { class: 'grow' }, (i.level === 'error' ? 'Störung: ' : 'Hinweis: ') + i.message))); +} +const post = (path, body) => api(path, { method: 'POST', body }); +async function run(fn, okMsg) { + try { const r = await fn(); if (okMsg) toast(okMsg); return r; } + catch (e) { toast(e.message, true); return undefined; } +} +const fmtDate = (s) => s ? new Date(s.replace(' ', 'T')).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; +const textToHtml = (t) => '

' + t.replace(/&/g, '&').replace(//g, '>').split(/\n{2,}/).map((p) => p.replace(/\n/g, '
')).join('

') + '

'; +const statusInfo = (v) => state.meta?.statuses.find((s) => s.value === v) || { label: v, color: '#64748b' }; + +function modal(title, bodyEl, buttons) { + const bg = h('div', { class: 'modal-bg' }); + const close = () => bg.remove(); + bg.append(h('div', { class: 'modal' }, h('h2', {}, title), bodyEl, + h('div', { class: 'row', style: null }, ...(buttons || []).map((b) => h('button', { class: 'btn ' + (b.cls || ''), onclick: () => b.fn(close) }, b.label)), + h('button', { class: 'btn ghost', onclick: close }, 'Abbrechen')))); + bg.addEventListener('mousedown', (e) => { if (e.target === bg) close(); }); + document.body.append(bg); + return close; +} + +// ─── Login ─────────────────────────────────────────────────────────────────── +async function showLogin(prefillUrl) { + clear($app); + document.body.classList.remove('in-app'); + const url = h('input', { placeholder: 'https://support.example.de', value: prefillUrl || localStorage.getItem('serverUrl') || '' }); + const email = h('input', { type: 'email', placeholder: 'mitarbeiter@firma.de' }); + const pw = h('input', { type: 'password' }); + const totp = h('input', { placeholder: '6-stelliger Code', maxlength: 6 }); + const totpRow = h('div', { class: 'hidden' }, h('label', {}, 'Authenticator-Code'), totp); + const err = h('div', { class: 'err' }); + const btn = h('button', { class: 'btn', type: 'submit' }, 'Anmelden'); + const form = h('form', {}, h('label', {}, 'Server-Adresse'), url, h('label', {}, 'E-Mail'), email, h('label', {}, 'Passwort'), pw, totpRow, err, h('div', { class: 'row' }, btn)); + form.addEventListener('submit', async (e) => { + e.preventDefault(); btn.disabled = true; err.textContent = ''; + const r = await window.deskhub.login({ serverUrl: url.value, email: email.value, password: pw.value, totpCode: totp.value }); + btn.disabled = false; + if (r.error) { err.textContent = r.error; return; } + if (r.requiresTotp) { totpRow.classList.remove('hidden'); totp.focus(); err.textContent = 'Bitte Authenticator-Code eingeben.'; return; } + localStorage.setItem('serverUrl', url.value); + state.serverUrl = (await window.deskhub.session())?.serverUrl || ''; + await startApp(r.staff, null, state.serverUrl); + }); + $app.append(h('div', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mitarbeiter-Anmeldung'), form))); + (url.value ? email : url).focus(); +} + +// ─── App-Rahmen ────────────────────────────────────────────────────────────── +const adminSections = () => ADMIN.filter((s) => (s.admin ? isAdmin() : can(s.perm))); +const NAV = [['dashboard', 'home', 'Übersicht'], ['tickets', 'ticket', 'Tickets'], ['tasks', 'check', 'Aufgaben'], ['customers', 'users', 'Kunden'], ['chat', 'chat', 'Team-Chat']]; + +// Schlichte Linien-Icons (inline SVG, CSP-sicher über createElementNS) +const ICONS = { + home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10', + ticket: 'M3 9a2 2 0 002-2V5h14v2a2 2 0 002 2v2a2 2 0 00-2 2v2a2 2 0 002 2v2H5v-2a2 2 0 00-2-2v-2a2 2 0 002-2V9z M9 8v2 M9 14v2', + check: 'M4 12l5 5L20 6', + users: 'M16 11a4 4 0 10-8 0 4 4 0 008 0 M3 20c0-3.3 4-5 9-5s9 1.7 9 5', + chat: 'M4 5h16v11H9l-5 4V5z', + bubble: 'M21 12a8 8 0 01-11.6 7.1L4 20l1.1-4.6A8 8 0 1121 12z', + dot: 'M12 12m-4 0a4 4 0 108 0 4 4 0 10-8 0', + gear: 'M12 15a3 3 0 100-6 3 3 0 000 6z M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4.4l-.4 2.7a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4.4l.4-2.7a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z', + sliders: 'M4 6h10 M18 6h2 M4 12h4 M12 12h8 M4 18h12 M20 18h0 M14 4v4 M8 10v4 M16 16v4', + search: 'M11 4a7 7 0 100 14 7 7 0 000-14z M21 21l-4.3-4.3', + chart: 'M4 20V10 M10 20V4 M16 20v-7 M22 20H2', +}; +function icon(name) { + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); + for (const d of (ICONS[name] || ICONS.dot).split(' M').map((x, i) => (i ? 'M' + x : x))) { const path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); svg.appendChild(path); } + return svg; +} +const initials = (name) => (name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join(''); + +// Schneller Start: Oberfläche sofort aus dem Zwischenspeicher aufbauen, Serverdaten danach im Hintergrund erneuern. +const metaKey = (url) => 'meta:' + url; +async function startApp(staff, cachedMeta, serverUrl) { + state.staff = staff; + state.since = ''; state.badge = 0; + if (!state.prefs) state.prefs = await window.deskhub.settings(); + applyTheme(); + const fresh = (async () => { + try { + const m = await api('metadata.php'); + const changed = !state.meta || JSON.stringify(m.staff.permissions) !== JSON.stringify(state.meta.staff.permissions) || m.liveChatEnabled !== state.meta.liveChatEnabled; + state.meta = m; state.staff = m.staff; state.health = m.health || []; + store.set(metaKey(`${serverUrl}:${staff.id}`), m); + return changed; + } catch (e) { toast(e.message, true); return false; } + })(); + if (cachedMeta) { + state.meta = cachedMeta; state.staff = cachedMeta.staff; + renderShell(); go('dashboard'); schedulePoll(0); + if (await fresh) { renderShell(); go(state.page, state.arg); } + } else { + await fresh; renderShell(); go('dashboard'); schedulePoll(0); + } +} + +// Design: "system" folgt der Windows-Einstellung (und reagiert live auf Wechsel), sonst fest Hell/Dunkel. +const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); +function applyTheme() { + const pref = state.prefs?.theme || 'system'; + document.documentElement.dataset.theme = pref === 'system' ? (darkQuery.matches ? 'dark' : 'light') : pref; +} +darkQuery.addEventListener('change', () => applyTheme()); + +function renderShell() { + clear($app); + document.body.classList.add('in-app'); + const items = [...NAV]; + if (state.meta?.liveChatEnabled) items.push(['livechat', 'bubble', 'Live-Chat']); + if (can('view_reports')) items.push(['reports', 'chart', 'Berichte']); + items.push(['availability', 'dot', 'Verfügbarkeit']); + if (adminSections().length) items.push(['admin', 'sliders', 'Verwaltung']); + const top = h('header', { class: 'topbar' }, + h('div', { class: 'brand' }, h('span', { class: 'logo' }, 'D'), 'DeskHub'), + can('create_ticket') ? h('button', { class: 'btn ok', onclick: () => go('newticket') }, 'Neues Ticket') : null, + h('button', { class: 'searchbtn', onclick: openPalette }, h('span', { class: 'nl' }, icon('search'), 'Ticket, Kunde oder Bereich suchen…'), h('kbd', {}, 'Strg K')), + h('div', { class: 'spacer' }), + h('div', { class: 'who' }, h('div', { class: 'avatar' }, initials(state.staff.name)), + h('div', {}, h('div', { class: 'name' }, state.staff.name), h('div', { class: 'role' }, state.staff.role === 'admin' ? 'Administrator' : 'Mitarbeiter'))), + h('button', { class: 'btn ghost sm', onclick: doLogout }, 'Abmelden')); + const navBtn = ([k, ic, label]) => h('button', { class: 'nav', 'data-page': k, onclick: () => go(k) }, h('span', { class: 'nl' }, icon(ic), label), k === 'tickets' ? h('span', { class: 'badge hidden', id: 'badge' }) : null); + const teamKeys = new Set(['availability', 'admin']); + const work = items.filter(([k]) => !teamKeys.has(k)), team = items.filter(([k]) => teamKeys.has(k)); + const side = h('nav', { class: 'side' }, + ...work.map(navBtn), + team.length ? h('div', { class: 'navsep' }) : null, + ...team.map(navBtn), + h('div', { class: 'spacer' }), + h('button', { class: 'nav', 'data-page': 'settings', onclick: () => go('settings') }, h('span', { class: 'nl' }, icon('gear'), 'Einstellungen'))); + $app.append(h('div', { class: 'shell' }, top, side, h('div', { class: 'mainwrap' }, h('div', { id: 'banners' }), h('main', { class: 'main', id: 'main' })))); + paintBanners(); +} + +const PAGE_GROUP = { ticket: 'tickets', newticket: 'tickets' }; +const VIEWS_BY_PAGE = () => ({ dashboard: vDashboard, tickets: vTickets, ticket: vTicket, newticket: vNewTicket, tasks: vTasks, customers: vCustomers, + chat: vChat, reports: vReports, livechat: vLiveChat, availability: vAvailability, admin: vAdmin, settings: vSettings }); + +function go(page, arg) { + state.page = page; state.arg = arg; + for (const n of document.querySelectorAll('.nav')) n.classList.toggle('active', n.dataset.page === (PAGE_GROUP[page] || page)); + state.timers.filter((t) => t.page).forEach((t) => clearInterval(t.id)); + state.timers = state.timers.filter((t) => !t.page); + const main = clear(document.getElementById('main')); + main.scrollTop = 0; + const view = VIEWS_BY_PAGE()[page]; + if (!view) return; + Promise.resolve(view(main, arg)).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message))); +} +function every(ms, fn) { const id = setInterval(fn, ms); state.timers.push({ id, page: true }); } + +async function doLogout() { + clearTimeout(state.pollTimer); + await window.deskhub.logout(); + state.timers.forEach((t) => clearInterval(t.id ?? t)); state.timers = []; state.staff = null; + window.deskhub.badge(0, null); + showLogin(); +} + +// ─── Benachrichtigungen & Zähler ──────────────────────────────────────────── +function badgeImage(n) { + const c = document.createElement('canvas'); c.width = c.height = 32; + const g = c.getContext('2d'); + g.fillStyle = '#ef4444'; g.beginPath(); g.arc(16, 16, 15, 0, Math.PI * 2); g.fill(); + g.fillStyle = '#fff'; g.font = 'bold 20px Segoe UI, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillText(n > 99 ? '99+' : String(n), 16, 17); + return c.toDataURL('image/png'); +} +function paintBadge() { + const b = document.getElementById('badge'); + if (b) { b.textContent = state.badge; b.classList.toggle('hidden', !state.badge); } + window.deskhub.badge(state.badge, state.badge ? badgeImage(state.badge) : null); +} +function resetBadge() { state.badge = 0; paintBadge(); } + +function schedulePoll(delay) { + clearTimeout(state.pollTimer); + const seconds = Math.max(15, Number(state.prefs?.pollSeconds) || 60); + state.pollTimer = setTimeout(async () => { await pollActivity(); if (state.staff) schedulePoll(seconds * 1000); }, delay ?? seconds * 1000); +} + +async function pollActivity() { + try { + const a = await api('activity.php', { query: { since: state.since } }); + if (state.since && (a.newReplies || a.newUnassigned)) { + state.badge += a.newReplies + a.newUnassigned; + if (state.prefs?.notifications !== false) { + const parts = []; + if (a.newReplies) parts.push(`${a.newReplies} Aktualisierung(en) an deinen Tickets`); + if (a.newUnassigned) parts.push(`${a.newUnassigned} neue(s) nicht zugewiesene(s) Ticket(s)`); + window.deskhub.notify('DeskHub', parts.join(', ')); + } + paintBadge(); + } + state.since = a.serverTime; + state.health = a.health || []; paintBanners(); + } catch {} +} + +// ─── Übersicht ─────────────────────────────────────────────────────────────── +const dueClass = (t) => (t.isOverdue ? 'over' : 'muted'); +function ticketTable(items, opts = {}) { + if (!items.length) return h('div', { class: 'panel muted' }, 'Keine Tickets gefunden.'); + const selected = opts.selected; + const allBox = selected ? h('input', { type: 'checkbox', onchange: (e) => { items.forEach((t) => (e.target.checked ? selected.add(t.number) : selected.delete(t.number))); opts.onSelect?.(); for (const c of document.querySelectorAll('.rowcheck')) c.checked = e.target.checked; } }) : null; + return h('div', { class: 'panel flush' }, h('table', { class: 'tickets' }, + h('tr', {}, selected ? h('th', { class: 'chk' }, allBox) : null, h('th', {}, 'Nr.'), h('th', {}, 'Betreff'), h('th', {}, 'Kunde'), h('th', {}, 'Status'), h('th', {}, 'Priorität'), h('th', {}, 'Zugewiesen'), h('th', {}, 'Fällig'), h('th', {}, 'Aktualisiert')), + ...items.map((t) => h('tr', { class: 'click' + (t.isOverdue ? ' overdue' : ''), onclick: () => go('ticket', t.number) }, + selected ? h('td', { class: 'chk', onclick: (e) => e.stopPropagation() }, h('input', { type: 'checkbox', class: 'rowcheck', checked: selected.has(t.number), onchange: (e) => { e.target.checked ? selected.add(t.number) : selected.delete(t.number); e.target.closest('tr').classList.toggle('sel', e.target.checked); opts.onSelect?.(); } })) : null, + h('td', { class: 'nowrap' }, t.number), h('td', {}, (t.isSpam ? '🚫 ' : '') + t.subject), h('td', {}, t.requesterName), + h('td', { class: 'nowrap' }, h('span', { class: 'pill', color: statusInfo(t.status).color }, statusInfo(t.status).label)), + h('td', { class: 'nowrap' }, h('span', { class: 'dot', color: t.priority.color }), t.priority.name), + h('td', {}, t.assignedStaff?.name || '—'), h('td', { class: dueClass(t) + ' nowrap' }, t.dueLabel || ''), h('td', { class: 'muted nowrap' }, fmtDate(t.updatedAt)))))); +} + +async function vDashboard(main) { + const body = h('div'); + const hour = new Date().getHours(), greet = hour < 11 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend'; + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, `${greet}, ${state.staff.name.split(' ')[0]}`), + h('div', { class: 'sub' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }))), + h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), body); + const load = async () => { + const d = await run(() => api('dashboard.php')); if (!d) return; + const stat = (n, label, q, cls) => h('div', { class: 'stat ' + (cls || '') + (q ? ' link' : ''), onclick: q ? () => go('tickets', q) : null }, h('div', { class: 'n' }, n), h('div', { class: 'muted' }, label)); + clear(body).append(h('div', { class: 'cards' }, + stat(d.myOpen, 'Meine offenen', { view: 'mine' }), stat(d.overdue, 'Überfällig', { view: 'overdue' }, d.overdue ? 'bad' : ''), + stat(d.unassigned, 'Nicht zugewiesen', { view: 'unassigned' }, d.unassigned ? 'warn' : ''), stat(d.unanswered, 'Unbeantwortet', { view: 'active' }), + stat(d.resolvedToday, 'Heute gelöst', null, 'ok'), d.slaPercent != null ? stat(Math.round(d.slaPercent) + '%', 'SLA-Quote', null, d.slaPercent < 80 ? 'warn' : 'ok') : null), + h('div', { class: 'split2' }, + h('div', {}, h('h3', {}, 'Zuletzt aktualisiert'), ticketTable(d.recent)), + d.departments?.length ? h('div', {}, h('h3', {}, 'Abteilungen'), h('div', { class: 'panel' }, ...d.departments.map((x) => { + const max = Math.max(1, ...d.departments.map((y) => y.open)); + return h('div', { class: 'bar' }, h('div', { class: 'row' }, h('span', { class: 'grow' }, x.name), h('span', { class: 'muted' }, `${x.open} offen`), x.overdue ? h('span', { class: 'over' }, `${x.overdue} überfällig`) : null), + h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round((x.open / max) * 100) }))); + }))) : null)); + for (const f of body.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); + }; + await load(); + every(30000, load); +} + +// ─── Tickets ───────────────────────────────────────────────────────────────── +const VIEWS = { + active: ['Alle aktiven', { status: 'open,pending,answered,on_hold', assigned: '', sla: '' }], + mine: ['Meine', { status: 'open,pending,answered,on_hold', assigned: 'me', sla: '' }], + unassigned: ['Nicht zugewiesen', { status: 'open,pending,answered,on_hold', assigned: 'unassigned', sla: '' }], + overdue: ['Überfällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'overdue' }], + atrisk: ['Bald fällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'at_risk' }], + closed: ['Erledigt', { status: 'resolved,closed', assigned: '', sla: '' }], + all: ['Alle', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '' }], +}; +const store = { + get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, + set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, + del(k) { try { localStorage.removeItem(k); } catch {} }, +}; + +async function vTickets(main, preset) { + const saved = store.get('ticketFilter', {}); + const f = { view: 'active', q: '', departmentId: '', priorityId: '', page: 1, ...saved, ...(preset || {}) }; + if (!VIEWS[f.view]) f.view = 'active'; + const selected = new Set(); + const list = h('div'), bulk = h('div', { class: 'bulk hidden' }); + const custom = {}; // gespeicherte Ansichten (Serverseite), getrennt von den festen Reitern + const filt = () => ({ ...((VIEWS[f.view] || custom[f.view] || ['', {}])[1]), q: f.q, departmentId: f.departmentId, priorityId: f.priorityId, page: f.page }); + const persist = () => store.set('ticketFilter', { view: f.view, departmentId: f.departmentId, priorityId: f.priorityId }); + const tabs = h('div', { class: 'tabs wrap' }); + const viewsBar = h('div', { class: 'viewsbar' }); + const applyView = (q) => { + const k = 'q' + q.id; const { q: text = '', departmentId = '', priorityId = '', ...rest } = q.filters || {}; + custom[k] = [q.name, { status: '', assigned: '', sla: '', ...rest }]; + Object.assign(f, { view: k, q: text, departmentId, priorityId, page: 1 }); selected.clear(); + search.value = text; paintTabs(); paintViews(); load(); + }; + const saveView = () => { + const name = h('input', { placeholder: 'z. B. Meine überfälligen' }), shared = h('input', { type: 'checkbox' }); + modal('Ansicht speichern', h('div', {}, h('p', { class: 'muted' }, 'Speichert die aktuellen Filter (Reiter, Suche, Abteilung, Priorität).'), h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), + [{ label: 'Speichern', fn: async (close) => { + const { page, ...filters } = filt(); + if (await run(() => post('queues.php', { action: 'save', name: name.value, isShared: shared.checked, filters }), 'Ansicht gespeichert')) { close(); paintViews(); } + } }]); + }; + const paintViews = async () => { + const r = await api('queues.php').catch(() => null); const items = r?.items || []; + clear(viewsBar).append(items.length ? h('span', { class: 'muted viewlabel' }, 'Ansichten') : null, + ...items.map((q) => h('span', { class: 'viewchip' + (f.view === 'q' + q.id ? ' active' : '') }, + h('button', { class: 'viewbtn', title: q.mine ? 'Meine Ansicht' : `Geteilt von ${q.ownerName || ''}`, onclick: () => applyView(q) }, q.name + (q.isShared ? ' ⇄' : '')), + q.mine || isAdmin() ? h('button', { class: 'viewx', title: 'Ansicht löschen', onclick: () => confirm('Ansicht löschen?') && run(async () => { await post('queues.php', { action: 'delete', id: q.id }); paintViews(); }) }, '✕') : null)), + h('button', { class: 'btn ghost sm', onclick: saveView }, '+ Ansicht speichern')); + }; + const paintTabs = () => { clear(tabs).append(...Object.entries(VIEWS).map(([k, [label]]) => h('button', { class: 'tab' + (f.view === k ? ' active' : ''), onclick: () => { f.view = k; f.page = 1; selected.clear(); persist(); paintTabs(); load(); } }, label))); }; + + const paintBulk = () => { + bulk.classList.toggle('hidden', selected.size === 0); clear(bulk); + if (!selected.size) return; + const act = async (patch, label) => { + let ok = 0; const fails = []; + for (const number of [...selected]) { try { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); ok++; } catch (e) { fails.push(`${number}: ${e.message}`); } } + toast(`${label}: ${ok} Ticket(s)` + (fails.length ? ` · ${fails.length} Fehler (${fails[0]})` : ''), !!fails.length); + selected.clear(); load(); + }; + const st = h('select', { onchange: (e) => { if (e.target.value) act({ status: e.target.value }, 'Status gesetzt'); } }, h('option', { value: '' }, 'Status setzen…'), ...state.meta.statuses.map((s) => h('option', { value: s.value }, s.label))); + const as = h('select', { onchange: (e) => { if (e.target.value !== '-') act({ assignedStaffId: e.target.value === '' ? null : +e.target.value }, 'Zugewiesen'); } }, h('option', { value: '-' }, 'Zuweisen…'), h('option', { value: '' }, '— niemand —'), ...state.meta.agents.map((a) => h('option', { value: a.id }, a.name))); + bulk.append(h('b', {}, `${selected.size} ausgewählt`), can('reply_ticket') || can('close_ticket') ? st : null, can('assign_ticket') ? as : null, + can('assign_ticket') ? h('button', { class: 'btn sm', onclick: () => act({ assignedStaffId: state.staff.id }, 'Mir zugewiesen') }, 'Mir zuweisen') : null, + h('button', { class: 'btn ghost sm', onclick: () => { selected.clear(); paintBulk(); for (const c of document.querySelectorAll('.rowcheck')) c.checked = false; } }, 'Auswahl aufheben')); + }; + + const load = async (silent) => { + const r = await (silent ? api('tickets.php', { query: filt() }).catch(() => null) : run(() => api('tickets.php', { query: filt() }))); + if (!r) return; + clear(list).append(ticketTable(r.items, { selected, onSelect: paintBulk }), h('div', { class: 'row pager' }, + h('button', { class: 'btn ghost sm', disabled: f.page <= 1, onclick: () => { f.page--; load(); } }, '‹ Zurück'), + h('span', { class: 'muted' }, `Seite ${r.page} · ${r.total} Tickets`), + h('button', { class: 'btn ghost sm', disabled: !r.hasMore, onclick: () => { f.page++; load(); } }, 'Weiter ›'))); + paintBulk(); + }; + const sel = (key, opts) => h('select', { onchange: (e) => { f[key] = e.target.value; f.page = 1; persist(); load(); } }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(f[key]) === String(v) }, l))); + const search = h('input', { placeholder: 'Suchen (Betreff, Nummer, Kunde, Text)… Strg+F', value: f.q, class: 'search', onkeydown: (e) => { if (e.key === 'Enter') { f.q = e.target.value; f.page = 1; load(); } } }); + search.id = 'ticket-search'; + main.append(h('div', { class: 'row' }, h('h2', { class: 'grow' }, 'Tickets'), h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), + tabs, viewsBar, h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => { f.q = search.value; f.page = 1; load(); } }, 'Suchen'), + sel('departmentId', [['', 'Alle Abteilungen'], ...state.meta.departments.map((d) => [d.id, d.name])]), sel('priorityId', [['', 'Alle Prioritäten'], ...state.meta.priorities.map((p) => [p.id, p.name])])), + bulk, list); + paintTabs(); paintViews(); resetBadge(); + await load(); + every(30000, () => { if (!selected.size && !document.querySelector('.modal-bg')) load(true); }); +} + +// ─── Ticket-Detail ─────────────────────────────────────────────────────────── +async function vTicket(main, number) { + let t; + try { t = (await api('ticket.php', { query: { number } })).ticket; } + catch (e) { main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Zurück'), h('div', { class: 'warnbox' }, e.message)); return; } + + // Rechte gelten pro Abteilung des Tickets; ältere Server liefern sie nicht -> globale Rechte + const can = (p) => (t.permissions ? t.permissions[p] === true : (state.staff.role === 'admin' || state.staff.permissions?.[p] === true)); + const reload = () => go('ticket', number); + const update = (patch, msg) => run(async () => { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); reload(); }, msg); + const mkSelect = (opts, val, onchange, disabled) => h('select', { disabled, onchange: (e) => onchange(e.target.value) }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val) }, l))); + const mine = t.assignedStaff?.id === state.staff.id; + const closed = ['resolved', 'closed'].includes(t.status); + + const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket')); + const assignSel = mkSelect([['', '— niemand —'], ...state.meta.agents.map((a) => [a.id, a.name])], t.assignedStaff?.id ?? '', (v) => update({ assignedStaffId: v === '' ? null : Number(v) }, 'Zuweisung geändert'), !can('assign_ticket')); + + // Antwort-Editor mit Entwurf + let mode = can('reply_ticket') ? 'reply' : 'note'; + const draftKey = (m) => `draft:${number}:${m}`; + const ta = h('textarea', { placeholder: 'Nachricht… (Strg+Enter zum Senden)', class: 'editor' }); + const loadDraft = () => { ta.value = store.get(draftKey(mode), ''); }; + ta.addEventListener('input', () => ta.value ? store.set(draftKey(mode), ta.value) : store.del(draftKey(mode))); + const tabs = h('div', { class: 'tabs' }); + const sendStatus = mkSelect([['', 'Status: automatisch'], ...state.meta.statuses.map((s) => [s.value, 'Setzen auf: ' + s.label])], '', () => {}); + let cannedList = []; + const canned = h('select', { onchange: (e) => { const c = cannedList.find((x) => String(x.id) === e.target.value); if (c) { ta.value += (ta.value ? '\n' : '') + c.body.replace(//gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '').trim(); ta.dispatchEvent(new Event('input')); ta.focus(); } e.target.value = ''; } }, h('option', { value: '' }, 'Textbaustein…')); + // Nur Textbausteine der Abteilung des Tickets und die für alle Abteilungen (ältere Server liefern alle: dann hier filtern) + api('canned_responses.php', { query: { departmentId: t.department?.id } }).then((r) => { + cannedList = r.items.filter((c) => c.departmentId == null || c.departmentId === t.department?.id); + const group = (label, items) => { if (items.length) canned.append(h('optgroup', { label }, ...items.map((c) => h('option', { value: c.id }, c.title)))); }; + group('Abteilung ' + (t.department?.name ?? ''), cannedList.filter((c) => c.departmentId != null)); + group('Für alle Abteilungen', cannedList.filter((c) => c.departmentId == null)); + }).catch(() => {}); + + // Signatur wählen (meine / Abteilung / keine) mit Vorschau; ältere Server liefern keine Signaturen: dann ohne Auswahl + const sigs = t.replySignatures; + let sigChoice = sigs?.default ?? 'none'; + const sigPreview = h('div', { class: 'sigpreview' }); + // Die Signatur kommt als bereits vom Server bereinigtes HTML; hier wird sie nochmals gesäubert (keine Skripte, Handler, Stile, Links) + const sigNodes = (html) => { + const doc = new DOMParser().parseFromString('' + html, 'text/html'); + doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,input,button,svg,math,base').forEach((n) => n.remove()); + doc.body.querySelectorAll('*').forEach((el) => { + for (const a of [...el.attributes]) { + const n = a.name.toLowerCase(); + if (n.startsWith('on') || n === 'style' || n === 'srcset' || n === 'href') el.removeAttribute(a.name); + else if (n === 'src' && !/^(https:|data:image\/)/i.test(a.value.trim())) el.removeAttribute(a.name); + } + }); + return [...doc.body.childNodes]; + }; + const paintSig = () => { + const e = sigChoice === 'own' ? sigs.own : sigChoice === 'dept' ? sigs.department : null; + clear(sigPreview); + sigPreview.classList.toggle('text', !(e?.available && e.html)); + if (e?.available && e.html) sigPreview.append(...sigNodes(e.html)); + else sigPreview.textContent = e?.available ? e.text : (sigChoice === 'none' ? 'Es wird keine Signatur angehängt.' : 'Keine Signatur hinterlegt.'); + }; + const sigSelect = sigs ? h('select', { onchange: (e) => { sigChoice = e.target.value; paintSig(); } }, + h('option', { value: 'own', selected: sigChoice === 'own', disabled: !sigs.own.available }, 'Meine Signatur' + (sigs.own.available ? '' : ' (nicht hinterlegt)')), + h('option', { value: 'dept', selected: sigChoice === 'dept', disabled: !sigs.department.available }, 'Abteilung „' + sigs.department.name + '“' + (sigs.department.available ? '' : ' (nicht hinterlegt)')), + h('option', { value: 'none', selected: sigChoice === 'none' }, 'Keine Signatur')) : null; + const sigBox = sigs ? h('div', { class: 'sigbox' }, h('div', { class: 'row' }, h('span', { class: 'muted small' }, 'Signatur'), sigSelect, h('span', { class: 'muted small' }, 'wird nur an Antworten an den Kunden angehängt')), sigPreview) : null; + if (sigs) paintSig(); + const sendBtn = h('button', { class: 'btn' }, 'Senden'); + const paintTabs = () => { + clear(tabs); + if (can('reply_ticket')) tabs.append(h('button', { class: 'tab' + (mode === 'reply' ? ' active' : ''), onclick: () => { mode = 'reply'; loadDraft(); paintTabs(); } }, 'Antwort an Kunden')); + if (can('post_note')) tabs.append(h('button', { class: 'tab' + (mode === 'note' ? ' active' : ''), onclick: () => { mode = 'note'; loadDraft(); paintTabs(); } }, 'Interne Notiz')); + sendStatus.classList.toggle('hidden', mode !== 'reply'); + sigBox?.classList.toggle('hidden', mode !== 'reply'); + sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern'; + ta.classList.toggle('noteedit', mode === 'note'); + }; + paintTabs(); loadDraft(); + const send = async () => { + if (!ta.value.trim() || sendBtn.disabled) return; + if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return; + sendBtn.disabled = true; + const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); + sendBtn.disabled = false; + if (ok) { store.del(draftKey(mode)); reload(); } + }; + sendBtn.addEventListener('click', send); + ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); } }); + + const quote = (m) => { ta.value += (ta.value ? '\n\n' : '') + m.message.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'; ta.dispatchEvent(new Event('input')); ta.focus(); }; + const threadsEl = h('div', { class: 'threads' }, ...t.threads.map((m) => { + const cust = m.authorType !== 'staff' && !m.isInternalNote; + return h('div', { class: 'thread ' + (m.isInternalNote ? 'note' : cust ? 'cust' : 'staff') }, + h('div', { class: 'head' }, h('span', { class: 'who' }, h('span', { class: 'avatar sm' }, initials(m.authorName)), h('b', {}, m.authorName), + h('span', { class: 'kind' }, m.isInternalNote ? 'Interne Notiz' : cust ? 'Kunde' : 'Antwort')), h('span', {}, fmtDate(m.createdAt))), + h('div', { class: 'body' }, m.message), + m.attachments.length ? h('div', { class: 'muted att' }, '📎 ' + m.attachments.map((a) => `${a.name} (${Math.round(a.size / 1024)} KB)`).join(', ') + ' – Download nur im Web') : null, + ...m.comments.map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff.name + ': '), c.message)), + cust && can('reply_ticket') ? h('div', { class: 'tools' }, h('button', { class: 'btn ghost sm', onclick: () => { mode = 'reply'; paintTabs(); loadDraft(); quote(m); } }, 'Zitieren')) : null); + })); + + const spamBtn = can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => run(async () => { await post('ticket_spam.php', { number, action: t.isSpam ? 'unspam' : 'spam' }); reload(); }, t.isSpam ? 'Kein Spam' : 'Als Spam markiert') }, t.isSpam ? 'Kein Spam' : 'Spam') : null; + const others = h('div', { class: 'muted' }, 'Lade…'); + if (t.requesterEmail) api('customer.php', { query: { email: t.requesterEmail } }).then((r) => { + const rest = r.tickets.filter((x) => x.number !== number).slice(0, 6); + clear(others).append(...(rest.length ? rest.map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, h('span', { class: 'dot', color: statusInfo(x.status).color }), `${x.number} · ${x.subject}`)) : ['Keine weiteren Tickets.'])); + }).catch(() => clear(others).append('—')); + + const actions = h('div', { class: 'tkactions' }, + can('assign_ticket') && !mine ? h('button', { class: 'btn sm', onclick: () => update({ assignedStaffId: state.staff.id }, 'Dir zugewiesen') }, 'Mir zuweisen') : null, + !closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => update({ status: 'resolved' }, 'Gelöst') }, 'Lösen') : null, + !closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => update({ status: 'closed' }, 'Geschlossen') }, 'Schließen') : null, + closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null, + spamBtn, + h('button', { class: 'btn ghost sm', title: 'Ticketnummer kopieren', onclick: () => { navigator.clipboard?.writeText(t.number); toast('Nummer kopiert'); } }, 'Nummer kopieren'), + h('button', { class: 'btn ghost sm', onclick: reload }, 'Aktualisieren')); + const st = statusInfo(t.status); + const prop = (label, ...kids) => [h('dt', {}, label), h('dd', {}, ...kids)]; + + main.append( + h('div', { class: 'tkhead' }, + h('div', { class: 'tkhead-top' }, h('button', { class: 'btn ghost sm', onclick: () => go('tickets') }, 'Zurück zu Tickets'), h('span', { class: 'chip' }, '#' + t.number), + h('span', { class: 'pill', color: st.color }, st.label), t.isOverdue ? h('span', { class: 'pill', color: '#d63b3b' }, 'Überfällig') : null, t.isSpam ? h('span', { class: 'pill', color: '#667384' }, 'Spam') : null), + h('h2', {}, t.subject), + h('div', { class: 'sub' }, `${t.requesterName} · ${t.department.name} · erstellt ${fmtDate(t.createdAt)}`), + actions), + t.lockedByOtherName ? h('div', { class: 'warnbox' }, `Dieser Chat wird gerade von „${t.lockedByOtherName}“ bearbeitet – nur Ansicht.`) : null, + h('div', { class: 'split' }, + h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null), + h('div', {}, + h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'), + h('dl', { class: 'props' }, + ...prop('Status', statusSel), ...prop('Zugewiesen an', assignSel), + ...prop('Priorität', h('span', { class: 'dot', color: t.priority.color }), t.priority.name), + ...prop('Abteilung', t.department.name), + ...prop('Erstellt', fmtDate(t.createdAt)), + ...(t.resolutionDueAt ? prop('Lösung fällig', h('span', { class: t.isOverdue ? 'over' : '' }, fmtDate(t.resolutionDueAt))) : []))), + (t.customFields && t.customFields.length) ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Angaben des Kunden'), + h('dl', { class: 'props' }, ...t.customFields.flatMap((f) => prop(f.label, f.value || '–')))) : null, + h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kunde'), + h('div', { class: 'custcard' }, h('span', { class: 'avatar' }, initials(t.requesterName)), h('div', {}, h('b', {}, t.requesterName), h('div', { class: 'muted' }, t.requesterEmail))), + t.requesterEmail ? h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => go('customers', t.requesterEmail) }, 'Kundenprofil')) : null), + h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Weitere Tickets des Kunden'), others)))); + + // Neue Nachrichten von Kunden/Kollegen erkennen, ohne den Entwurf zu verlieren + every(45000, async () => { + try { + const n = (await api('ticket.php', { query: { number } })).ticket; + if (n.threads.length !== t.threads.length || n.status !== t.status) { toast('Ticket wurde aktualisiert'); reload(); } + } catch {} + }); + threadsEl.lastElementChild?.scrollIntoView({ block: 'nearest' }); +} + +async function vNewTicket(main) { + if (!can('create_ticket')) { main.append(h('div', { class: 'warnbox' }, 'Dir fehlt das Recht „Tickets erstellen“.')); return; } + const m = state.meta; + const f = { name: h('input'), email: h('input', { type: 'email' }), subject: h('input'), message: h('textarea'), + dept: h('select', {}, ...m.departments.map((d) => h('option', { value: d.id }, d.name))), + prio: h('select', {}, ...m.priorities.map((p) => h('option', { value: p.id }, p.name))), + agent: h('select', {}, h('option', { value: '' }, '— niemand —'), ...m.agents.map((a) => h('option', { value: a.id }, a.name))), + notify: h('input', { type: 'checkbox', checked: true }) }; + main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Tickets'), h('h2', {}, 'Neues Ticket'), + h('div', { class: 'panel' }, + h('label', {}, 'Name des Kunden'), f.name, h('label', {}, 'E-Mail des Kunden (optional)'), f.email, h('label', {}, 'Betreff'), f.subject, + h('label', {}, 'Beschreibung'), f.message, h('label', {}, 'Abteilung'), f.dept, h('label', {}, 'Priorität'), f.prio, + h('label', {}, 'Zuweisen an'), f.agent, h('label', { class: 'check' }, f.notify, 'Kunden per E-Mail benachrichtigen'), + h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => { + const r = await run(() => post('ticket_create.php', { requesterName: f.name.value, requesterEmail: f.email.value, subject: f.subject.value, + message: textToHtml(f.message.value), departmentId: +f.dept.value, priorityId: +f.prio.value, assignedStaffId: f.agent.value ? +f.agent.value : null, notifyCustomer: f.notify.checked }), 'Ticket erstellt'); + if (r) go('ticket', r.ticket.number); + } }, 'Ticket erstellen')))); +} + +// ─── Aufgaben ──────────────────────────────────────────────────────────────── +async function vTasks(main) { + const body = h('div'); + main.append(h('div', { class: 'row' }, h('h2', { class: 'grow' }, 'Aufgaben'), h('button', { class: 'btn', onclick: () => newTask() }, '+ Aufgabe')), body); + let staffOpts = []; + const load = async () => { + const r = await api('tasks.php', { query: { filter: 'all' } }).catch((e) => { toast(e.message, true); return null; }); + if (!r) return; staffOpts = r.staffOptions; + clear(body).append(h('div', { class: 'panel' }, r.items.length ? h('table', {}, h('tr', {}, h('th', {}, ''), h('th', {}, 'Aufgabe'), h('th', {}, 'Zuständig'), h('th', {}, 'Fällig'), h('th', {}, '')), + ...r.items.map((t) => h('tr', {}, h('td', {}, h('input', { type: 'checkbox', checked: t.status === 'done', onchange: (e) => run(async () => { await post('tasks.php', { action: 'update_status', id: t.id, status: e.target.checked ? 'done' : 'open' }); load(); }) })), + h('td', {}, h('b', {}, t.title), t.description ? h('div', { class: 'muted' }, t.description) : null, t.ticketNumber ? h('a', { href: '#', onclick: (e) => { e.preventDefault(); go('ticket', t.ticketNumber); } }, ' ' + t.ticketNumber) : null), + h('td', {}, t.assignedName || '—'), h('td', {}, fmtDate(t.dueAt)), + h('td', {}, h('button', { class: 'btn danger sm', onclick: () => confirm('Aufgabe löschen?') && run(async () => { await post('tasks.php', { action: 'delete', id: t.id }); load(); }) }, '✕'))))) : h('div', { class: 'muted' }, 'Keine Aufgaben.'))); + }; + function newTask() { + const title = h('input'), desc = h('textarea'), who = h('select', {}, h('option', { value: '' }, '— niemand —'), ...staffOpts.map((s) => h('option', { value: s.id }, s.name))), due = h('input', { type: 'datetime-local' }); + modal('Neue Aufgabe', h('div', {}, h('label', {}, 'Titel'), title, h('label', {}, 'Beschreibung'), desc, h('label', {}, 'Zuständig'), who, h('label', {}, 'Fällig'), due), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('tasks.php', { action: 'create', title: title.value, description: desc.value || null, assignedToId: who.value ? +who.value : null, dueAt: due.value ? due.value.replace('T', ' ') + ':00' : null }))) { close(); load(); } } }]); + } + load(); +} + +// ─── Kunden ────────────────────────────────────────────────────────────────── +async function vCustomers(main, email) { + const body = h('div'); + const search = h('input', { placeholder: 'Name oder E-Mail suchen…', value: email || '', onkeydown: (e) => e.key === 'Enter' && load() }); + main.append(h('h2', {}, 'Kunden'), h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => load() }, 'Suchen')), body); + const load = async () => { + const r = await run(() => api('customers.php', { query: { q: search.value } })); if (!r) return; + clear(body).append(h('div', { class: 'panel' }, h('table', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'E-Mail'), h('th', {}, 'Organisation'), h('th', {}, 'Tickets')), + ...r.items.map((c) => h('tr', { class: 'click', onclick: () => open(c) }, h('td', {}, c.name + (c.isGuest ? ' (Gast)' : '')), h('td', {}, c.email), h('td', {}, c.organizationName || ''), h('td', {}, c.ticketCount)))))); + }; + const open = async (c) => { + const r = await run(() => api('customer.php', { query: c.id != null ? { userId: c.id } : { email: c.email } })); if (!r) return; + clear(body).append(h('button', { class: 'btn ghost', onclick: load }, '‹ Liste'), h('h3', {}, r.customer.name), h('div', { class: 'muted' }, r.customer.email), ticketTable(r.tickets)); + }; + load(); +} + +// ─── Team-Chat ─────────────────────────────────────────────────────────────── +async function vChat(main) { + const list = h('div', { class: 'chatlist' }), box = h('div', { class: 'chatbox' }, h('div', { class: 'msgs muted' }, 'Kollegen auswählen')); + main.append(h('h2', {}, 'Team-Chat'), h('div', { class: 'chatwrap' }, list, box)); + let current = null; + const loadContacts = async () => { + const r = await api('chat.php').catch(() => null); if (!r) return; + clear(list).append(...r.contacts.map((c) => h('div', { class: 'item' + (current === c.id ? ' active' : ''), onclick: () => openChat(c.id) }, c.name, c.unreadCount ? h('span', { class: 'badge' }, c.unreadCount) : null))); + }; + const openChat = async (id) => { + current = id; + const r = await run(() => api('chat.php', { query: { with: id } })); if (!r) return; + const msgs = h('div', { class: 'msgs' }, ...r.messages.map((m) => h('div', { class: 'msg' + (m.fromStaffId === state.staff.id ? ' mine' : '') }, h('small', {}, `${m.fromName} · ${fmtDate(m.createdAt)}`), m.message))); + const inp = h('input', { placeholder: 'Nachricht…', onkeydown: (e) => e.key === 'Enter' && send() }); + const send = async () => { if (!inp.value.trim()) return; const t = inp.value; inp.value = ''; if (await run(() => post('chat.php', { action: 'send', toId: id, message: t }))) openChat(id); }; + clear(box).append(h('div', { class: 'msgs-title', style: null }), msgs, h('div', { class: 'chatinput' }, inp, h('button', { class: 'btn', onclick: send }, 'Senden'))); + msgs.scrollTop = msgs.scrollHeight; inp.focus(); loadContacts(); + }; + await loadContacts(); + every(15000, () => { loadContacts(); }); +} + +// ─── Live-Chat ─────────────────────────────────────────────────────────────── +async function vLiveChat(main) { + const list = h('div', { class: 'chatlist' }), box = h('div', { class: 'chatbox' }, h('div', { class: 'msgs muted' }, 'Chat auswählen')); + main.append(h('h2', {}, 'Live-Chat (Kunden)'), h('div', { class: 'chatwrap' }, list, box)); + let cur = null; + const loadList = async () => { + const r = await api('live_chat.php', { query: { action: 'list' } }).catch(() => null); if (!r) return; + clear(list).append(...(r.items.length ? r.items.map((c) => h('div', { class: 'item' + (cur === c.number ? ' active' : ''), onclick: () => openChat(c.number) }, h('span', {}, c.name, h('div', { class: 'muted' }, c.preview)), c.needsReply ? h('span', { class: 'badge' }, '!') : null)) : [h('div', { class: 'item muted' }, 'Keine aktiven Chats')])); + }; + const openChat = async (number) => { + cur = number; + const r = await run(() => api('live_chat.php', { query: { action: 'messages', number, since: 0 } })); if (!r) return; + const msgs = h('div', { class: 'msgs' }, ...r.messages.map((m) => h('div', { class: 'msg' + (m.authorType === 'staff' ? ' mine' : '') }, h('small', {}, `${m.authorName || r.requesterName} · ${fmtDate(m.createdAt)}`), m.message))); + const inp = h('input', { placeholder: 'Antwort…', disabled: !!r.lockedByOtherName, onkeydown: (e) => e.key === 'Enter' && send() }); + const send = async () => { if (!inp.value.trim()) return; const t = inp.value; inp.value = ''; if (await run(() => post('live_chat.php', { action: 'send', number, message: t }))) openChat(number); }; + clear(box).append(r.lockedByOtherName ? h('div', { class: 'warnbox' }, `Wird gerade von ${r.lockedByOtherName} bearbeitet.`) : null, + h('div', { class: 'row' }, h('b', {}, `${r.requesterName} (${r.departmentName})`), h('button', { class: 'btn danger sm', onclick: () => confirm('Chat beenden?') && run(async () => { await post('live_chat.php', { action: 'end', number }); cur = null; clear(box); loadList(); }) }, 'Chat beenden')), + msgs, h('div', { class: 'chatinput' }, inp, h('button', { class: 'btn', disabled: !!r.lockedByOtherName, onclick: send }, 'Senden'))); + msgs.scrollTop = msgs.scrollHeight; + }; + await loadList(); + every(5000, () => { loadList(); if (cur && !document.querySelector('.chatinput input')?.value) openChat(cur); }); +} + +// ─── Berichte (Bericht-Baukasten) ──────────────────────────────────────────── +async function vReports(main) { + const cfg = { dimension: 'department', metrics: ['total', 'open', 'resolved'], from: '', to: '', sort: 'label', desc: false, filters: {} }; + const out = h('div'), controls = h('div', { class: 'panel' }), savedBar = h('div', { class: 'viewsbar' }); + let meta = null; + const fmt = (v, unit) => (v == null ? '–' : unit === '%' ? `${String(v).replace('.', ',')} %` : unit === 'Std' ? `${String(v).replace('.', ',')} Std` : String(v)); + const query = (extra = {}) => ({ dimension: cfg.dimension, metrics: cfg.metrics.join(','), from: cfg.from, to: cfg.to, sort: cfg.sort, desc: cfg.desc ? 1 : '', ...cfg.filters, ...extra }); + const run_ = async (extra) => { + const r = await run(() => api('report_builder.php', { query: query(extra) })); if (!r) return; + meta = r; Object.assign(cfg, { dimension: r.config.dimension, metrics: r.config.metrics, from: r.config.from, to: r.config.to, sort: r.config.sort, desc: r.config.desc }); + paintControls(); paintSaved(); + const max = {}; for (const c of r.columns.slice(1)) max[c.key] = Math.max(1, ...r.rows.map((x) => Number(x[c.key]) || 0)); + clear(out).append(h('div', { class: 'panel flush' }, r.rows.length ? h('table', {}, + h('tr', {}, ...r.columns.map((c) => h('th', {}, c.label))), + ...r.rows.map((row) => h('tr', {}, h('td', {}, h('b', {}, row.label)), ...r.columns.slice(1).map((c) => h('td', {}, fmt(row[c.key], c.unit), c.unit === '' && row[c.key] != null ? h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round((row[c.key] / max[c.key]) * 100) })) : null)))), + h('tr', { class: 'totalrow' }, h('td', {}, 'Gesamt'), ...r.columns.slice(1).map((c) => h('td', {}, fmt(r.totals[c.key], c.unit))))) : h('div', { class: 'muted pad' }, 'Keine Tickets im gewählten Zeitraum.'))); + for (const f of out.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); + }; + const paintSaved = () => clear(savedBar).append(meta?.saved?.length ? h('span', { class: 'muted viewlabel' }, 'Gespeichert') : null, + ...(meta?.saved || []).map((s) => h('span', { class: 'viewchip' }, h('button', { class: 'viewbtn', onclick: () => run_({ saved: s.id }) }, s.name + (s.isShared ? ' ⇄' : '')), + s.mine || isAdmin() ? h('button', { class: 'viewx', title: 'Löschen', onclick: () => confirm('Bericht löschen?') && run(async () => { await post('report_builder.php', { action: 'delete', id: s.id }); run_(); }) }, '✕') : null))); + const paintControls = () => { + const dim = h('select', { onchange: (e) => { cfg.dimension = e.target.value; } }, ...(meta?.dimensions || []).map((d) => h('option', { value: d.key, selected: d.key === cfg.dimension }, d.label))); + const from = h('input', { type: 'date', value: cfg.from, onchange: (e) => { cfg.from = e.target.value; } }), to = h('input', { type: 'date', value: cfg.to, onchange: (e) => { cfg.to = e.target.value; } }); + const dept = h('select', { onchange: (e) => { cfg.filters.departmentId = e.target.value; } }, h('option', { value: '' }, 'Alle Abteilungen'), ...state.meta.departments.map((d) => h('option', { value: d.id, selected: String(cfg.filters.departmentId) === String(d.id) }, d.name))); + const prio = h('select', { onchange: (e) => { cfg.filters.priorityId = e.target.value; } }, h('option', { value: '' }, 'Alle Prioritäten'), ...state.meta.priorities.map((p) => h('option', { value: p.id, selected: String(cfg.filters.priorityId) === String(p.id) }, p.name))); + const metrics = (meta?.metrics || []).map((m) => h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: cfg.metrics.includes(m.key), onchange: (e) => { cfg.metrics = e.target.checked ? [...cfg.metrics, m.key] : cfg.metrics.filter((k) => k !== m.key); } }), m.label)); + clear(controls).append(h('div', { class: 'row' }, h('label', {}, 'Gruppieren nach'), dim, h('label', {}, 'Von'), from, h('label', {}, 'Bis'), to), h('div', { class: 'row' }, dept, prio), + h('div', { class: 'checks wide' }, ...metrics), + h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run_() }, 'Bericht erstellen'), + h('button', { class: 'btn ghost', onclick: () => { const name = h('input', { placeholder: 'z. B. Monatsbericht Support' }), shared = h('input', { type: 'checkbox' }); + modal('Bericht speichern', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('report_builder.php', { action: 'save', name: name.value, isShared: shared.checked, config: { ...cfg } }), 'Bericht gespeichert')) { close(); run_(); } } }]); } }, 'Bericht speichern'), + h('span', { class: 'muted' }, 'CSV-Export: in der Weboberfläche unter Bericht-Baukasten.'))); + }; + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Berichte'), h('div', { class: 'sub' }, 'Frei konfigurierbare Auswertungen nach Erstellungsdatum der Tickets'))), controls, savedBar, out); + await run_(); +} + +// ─── Verfügbarkeit ─────────────────────────────────────────────────────────── +async function vAvailability(main) { + const r = await api('availability.php'); + const labels = { available: '🟢 Verfügbar', busy: '🔴 Beschäftigt', break: '🟡 Pause', offline: '⚪ Offline' }; + const sel = h('select', {}, ...Object.entries(labels).map(([v, l]) => h('option', { value: v, selected: v === r.myStatus }, l))); + const note = h('input', { value: r.myNote, placeholder: 'Notiz (optional)' }); + main.append(h('h2', {}, 'Verfügbarkeit'), h('div', { class: 'panel' }, h('label', {}, 'Mein Status'), sel, h('label', {}, 'Notiz'), note, + h('div', { class: 'row' }, h('button', { class: 'btn', onclick: () => run(() => post('availability.php', { status: sel.value, note: note.value }), 'Gespeichert') }, 'Speichern'))), + h('h3', {}, isAdmin() ? 'Team (als Administrator kannst du den Status ändern)' : 'Team'), + h('div', { class: 'panel' }, h('table', {}, ...r.team.map((m) => { + const cells = [h('td', {}, m.name), h('td', {}, m.isOnVacation ? '🏖 Urlaub' : (labels[m.status] || m.status) + (m.autoOffline ? ' (automatisch, inaktiv)' : '')), h('td', { class: 'muted' }, m.note)]; + if (isAdmin()) { + const ms = h('select', {}, ...Object.entries(labels).map(([v, l]) => h('option', { value: v, selected: v === m.status }, l))); + const mn = h('input', { value: m.note || '', placeholder: 'Notiz', maxlength: '120' }); + cells.push(h('td', {}, ms), h('td', {}, mn), h('td', {}, h('button', { class: 'btn sm', onclick: async () => { const ok = await run(() => post('availability.php', { status: ms.value, note: mn.value, staffId: m.id }), 'Status von ' + m.name + ' geändert'); if (ok) go('availability'); } }, 'Setzen'))); + } + return h('tr', {}, ...cells); + })))); +} + +// ─── Verwaltung (rechtegesteuert, wie in der Android-App) ─────────────────── +const T = 'text', N = 'number', B = 'bool', A = 'area', H = 'heading', ES = 'emailsel', XD = 'extradepts'; +const ADMIN = [ + { key: 'departments', label: 'Abteilungen', perm: 'manage_departments', file: 'departments.php', cols: [['Name', 'name'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Mitglieder', (x) => (x.members || []).length], ['Abteilungsleiter', 'managerName'], ['E-Mail', 'email'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], + fields: [['_h1', 'Abteilungsinformationen', H], ['name', 'Name', T], ['isActive', 'Aktiv', B], ['managerId', 'Abteilungsleiter', 'staffOptions?'], ['slaPlanId', 'SLA-Plan (leer = Zeiten der Priorität)', 'slaPlans?'], ['email', 'Abteilungs-E-Mail (optional, sonst Standardadresse)', ES], ['isDefault', 'Standardabteilung', B], ['signature', 'Signatur', A], ['notes', 'Administratornotizen (intern)', A]], + info: (x) => x?.members?.length ? `Mitglieder: ${x.members.map((m) => m.name + (m.isPrimary ? '' : ' (zusätzlich)')).join(', ')}` : '' }, + { key: 'priorities', label: 'Prioritäten / SLA', perm: 'manage_priorities', file: 'priorities.php', cols: [['Name', 'name'], ['Reaktion (h)', 'responseHours'], ['Lösung (h)', 'resolutionHours']], + fields: [['name', 'Name', T], ['color', 'Farbe (#RRGGBB)', T], ['responseHours', 'Reaktionszeit (Std.)', N], ['resolutionHours', 'Lösungszeit (Std.)', N], ['sortOrder', 'Reihenfolge', N]] }, + { key: 'sla_plans', label: 'SLA-Pläne', perm: 'manage_priorities', file: 'sla_plans.php', cols: [['Name', 'name'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Reaktion (Std)', 'responseHours'], ['Lösung (Std)', 'resolutionHours'], ['Abteilungen', 'departmentCount'], ['Hilfethemen', 'helpTopicCount']], + fields: [['name', 'Name', T], ['isActive', 'Aktiv', B], ['responseHours', 'Reaktionszeit (Std)', N], ['resolutionHours', 'Lösungszeit (Std)', N], ['notes', 'Administratornotizen (intern)', A]] }, + { key: 'help_topics', label: 'Hilfethemen', perm: 'manage_help_topics', file: 'help_topics.php', cols: [['Hilfethema', 'path'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Typ', (x) => x.isPublic ? 'Öffentlich' : 'Intern'], ['Priorität', 'priorityName'], ['Abteilung', 'departmentName'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], + fields: [['_h1', 'Hilfethemeninformationen', H], ['name', 'Thema', T], ['isActive', 'Aktiv', B], ['isPublic', 'Öffentlich (für Kunden sichtbar)', B], ['parentId', 'Übergeordnetes Thema', 'items?'], ['notes', 'Interne Notizen (vertraulich)', A], + ['_h2', 'Optionen bei neuem Ticket', H], ['departmentId', 'Abteilung', 'departments'], ['priorityId', 'Priorität', 'priorities'], ['slaPlanId', 'SLA-Plan (leer = Abteilung/Priorität)', 'slaPlans?'], ['sortOrder', 'Reihenfolge', N]] }, + { key: 'kb', label: 'Wissensdatenbank', perm: 'manage_kb', file: 'kb.php', list: 'articles', saveAction: 'save_article', deleteAction: 'delete_article', cols: [['Titel', 'title'], ['Kategorie', 'categoryName'], ['Veröffentlicht', (x) => x.isPublished ? '✓' : ''], ['Aufrufe', 'views']], + fields: [['title', 'Titel', T], ['categoryId', 'Kategorie', 'categories'], ['body', 'Inhalt', A], ['isPublished', 'Veröffentlicht', B]] }, + { key: 'email_addresses', label: 'E-Mail-Adressen', perm: 'manage_email_addresses', file: 'email_addresses.php', cols: [['E-Mail', 'email'], ['Anzeigename', 'label'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Abteilung', 'departmentName'], ['Abruf', (x) => x.imapEnabled ? '✓' : '–'], ['Eigener SMTP', (x) => x.smtpEnabled ? '✓' : '–'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], + actions: [['Test-Mail senden', 'sendTest'], ['Postfach testen', 'testImap'], ['SMTP testen', 'testSmtp']], + fields: [['_h1', 'Konto', H], ['email', 'E-Mail-Adresse', T], ['label', 'Anzeigename', T], ['isActive', 'Aktiv (sonst deaktiviert)', B], ['departmentId', 'Abteilung', 'departments'], ['priorityId', 'Priorität', 'priorities?'], ['helpTopicId', 'Hilfethema', 'helpTopics?'], ['isDefault', 'Standard-Absenderadresse der Abteilung', B], ['notes', 'Interne Notizen', A], + ['_h2', 'Entferntes Postfach (Abruf per IMAP)', H], ['imapEnabled', 'Abruf aktivieren', B], ['imapHost', 'Server', T], ['imapPort', 'Port', N], + ['imapEncryption', 'Verschlüsselung', [['ssl', 'SSL/TLS (993)'], ['tls', 'STARTTLS (143)'], ['none', 'Keine']]], ['imapUsername', 'Benutzername (leer = E-Mail-Adresse)', T], ['imapPassword', 'Passwort (leer = unverändert)', 'pw'], + ['imapFolder', 'Ordner', T], ['imapAfterFetch', 'Aktion nach Abruf', [['seen', 'Als gelesen markieren'], ['archive', 'In Ordner verschieben'], ['delete', 'Löschen']]], ['imapArchiveFolder', 'Zielordner (bei Verschieben)', T], ['imapSpamFolder', 'Spam-/Junk-Ordner', T], + ['_h3', 'Abgehend (eigener SMTP-Server)', H], ['smtpEnabled', 'Eigenen SMTP-Server verwenden', B], ['smtpHost', 'Server', T], ['smtpPort', 'Port', N], + ['smtpEncryption', 'Verschlüsselung', [['tls', 'STARTTLS (587)'], ['ssl', 'SSL/TLS (465)'], ['none', 'Keine']]], ['smtpUsername', 'Benutzername', T], ['smtpPassword', 'Passwort (leer = unverändert)', 'pw']] }, + { key: 'organizations', label: 'Organisationen', perm: 'manage_organizations', file: 'organizations.php', cols: [['Name', 'name'], ['Mitglieder', 'memberCount']], + fields: [['name', 'Name', T], ['notes', 'Notizen', A]] }, + { key: 'staff', label: 'Mitarbeiter', perm: 'manage_staff', file: 'staff_manage.php', cols: [['Name', 'name'], ['E-Mail', 'email'], ['Typ', (x) => x.role === 'admin' ? 'Administrator' : 'Mitarbeiter'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Gesperrt'], ['Hauptabteilung', 'departmentName'], ['Teams', (x) => (x.teamIds || []).length]], + fields: [['_h1', 'Konto', H], ['name', 'Name', T], ['email', 'E-Mail', T], ['isActive', 'Aktiv (sonst gesperrt)', B], ['password', 'Passwort (leer = unverändert)', 'pw'], ['signature', 'Signatur', A], + ['_h2', 'Zugang', H], ['departmentId', 'Hauptabteilung', 'departments?'], ['extraDepartments', 'Erweiterter Zugriff', XD], + ['_h3', 'Berechtigungen', H], ['role', 'Kontotyp', [['agent', 'Mitarbeiter'], ['admin', 'Administrator (alle Rechte)']]], ['roleId', 'Rolle (für Mitarbeiter)', 'roles?'], + ['_h4', 'Teams', H], ['teamIds', 'Mitglied in Teams', 'multi:teams']] }, + { key: 'banlist', label: 'Sperrliste', admin: true, file: 'banlist.php', cols: [['Typ', 'matchType'], ['Wert', 'value'], ['Grund', 'reason'], ['Von', 'addedByName']], saveAction: 'create', noEdit: true, + fields: [['matchType', 'Typ', [['email', 'E-Mail-Adresse'], ['domain', 'Domain']]], ['value', 'Wert', T], ['reason', 'Grund', T]] }, + { key: 'audit', label: 'Admin-Protokoll', perm: 'view_audit_log', file: 'audit_log.php', readonly: true, paged: true, cols: [['Zeit', (x) => fmtDate(x.createdAt)], ['Mitarbeiter', 'staffName'], ['Aktion', 'action']] }, + { key: 'time', label: 'Zeiterfassung', perm: 'view_time_entries', file: 'time_entries.php', readonly: true, list: 'entries', cols: [['Datum', (x) => fmtDate(x.loggedAt)], ['Mitarbeiter', 'staffName'], ['Ticket', 'ticketNumber'], ['Minuten', 'minutes'], ['Notiz', 'note']] }, +]; + +const ADMIN_GROUPS = [ + ['Verwalten', ['departments', 'priorities', 'sla_plans', 'help_topics', 'kb', 'email_addresses', 'email_templates', 'ticket_rules', 'banlist']], + ['Kunden', ['organizations']], + ['Agenten', ['staff', 'teams', 'roles', 'status']], + ['System', ['audit', 'time', 'escalations', 'business_hours']], +]; +const ADMIN_DESC = { + departments: 'Status, Leiter, Mitglieder, E-Mail', priorities: 'Reaktions- und Lösungszeiten', sla_plans: 'Benannte Zeiten je Abteilung oder Thema', help_topics: 'Thema, Typ, Abteilung, Priorität', + kb: 'Artikel und Kategorien', email_addresses: 'Adressen und Postfach-Abruf', email_templates: 'Texte der automatischen E-Mails', ticket_rules: 'Automatisierung und Auto-Antworten', banlist: 'Absender oder Mailanbieter sperren', + organizations: 'Kunden zu Firmen bündeln', + staff: 'Konten, Rollen, Abteilungszugriff', teams: 'Mitarbeiter-Gruppen', roles: 'Individuelle Berechtigungen', status: 'Status ändern, z. B. auf Offline setzen', + audit: 'Änderungen und fehlgeschlagene Mails', time: 'Erfasste Arbeitszeiten', escalations: 'Regeln für überfällige Tickets', business_hours: 'Öffnungszeiten und Feiertage', +}; +async function vAdmin(main, sectionKey) { + const sections = adminSections(); + if (!sectionKey) { + // Bereiche zu Gruppen ordnen (gleiche Gruppen wie im Adminportal und in der Android-App) + const entries = new Map(sections.map((s) => [s.key, { label: s.label, open: () => go('admin', s.key) }])); + if (isAdmin()) entries.set('status', { label: 'Status der Mitarbeiter', open: () => go('availability') }); + const used = new Set(); + const groupsView = ADMIN_GROUPS.map(([title, keys]) => { + const tiles = keys.filter((k) => entries.has(k)).map((k) => { used.add(k); const e = entries.get(k); + return h('div', { class: 'tile', tabindex: '0', onclick: e.open, onkeydown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); e.open(); } } }, + h('div', { class: 'tt' }, e.label), h('div', { class: 'td' }, ADMIN_DESC[k] || '')); }); + return tiles.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, title), h('div', { class: 'tiles' }, ...tiles)) : null; + }); + const rest = [...entries.keys()].filter((k) => !used.has(k)).map((k) => h('div', { class: 'tile', onclick: entries.get(k).open }, h('div', { class: 'tt' }, entries.get(k).label))); + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Verwaltung'), h('div', { class: 'sub' }, 'Nur Bereiche, für die du freigeschaltet bist. Backup, Update, Postfächer und Einstellungen gibt es nur in der Weboberfläche.'))), + ...groupsView, rest.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, 'Weitere'), h('div', { class: 'tiles' }, ...rest)) : null); + return; + } + const s = sections.find((x) => x.key === sectionKey); + if (!s) { main.append(h('div', { class: 'warnbox' }, 'Kein Zugriff.')); return; } + const body = h('div'); let page = 1; + const addBtn = h('button', { class: 'btn', onclick: () => edit(null) }, '+ Neu'); + if (s.readonly || (s.custom && !s.add)) addBtn.classList.add('hidden'); + main.append(h('div', { class: 'row' }, h('button', { class: 'btn ghost', onclick: () => go('admin') }, '‹ Verwaltung'), h('h2', { class: 'grow' }, s.label), addBtn), body); + if (s.custom) return s.custom(body, addBtn); + let resp; + const load = async () => { + resp = await run(() => api(s.file, { query: s.paged ? { page } : {} })); if (!resp) return; + const items = resp[s.list || 'items'] || []; + const cell = (x, c) => typeof c === 'function' ? c(x) : (x[c] ?? ''); + clear(body).append(h('div', { class: 'panel' }, items.length ? h('table', {}, h('tr', {}, ...s.cols.map((c) => h('th', {}, c[0])), s.readonly ? null : h('th', {})), + ...items.map((x) => h('tr', { class: s.readonly || s.noEdit ? '' : 'click', onclick: () => !s.readonly && !s.noEdit && edit(x) }, ...s.cols.map((c) => h('td', {}, cell(x, c[1]))), + s.readonly ? null : h('td', {}, h('button', { class: 'btn danger sm', onclick: (e) => { e.stopPropagation(); remove(x); } }, '✕'))))) : h('div', { class: 'muted' }, 'Keine Einträge.')), + s.paged ? h('div', { class: 'row' }, h('button', { class: 'btn ghost sm', disabled: page <= 1, onclick: () => { page--; load(); } }, '‹'), h('span', { class: 'muted' }, `Seite ${resp.page}`), h('button', { class: 'btn ghost sm', disabled: !resp.hasMore, onclick: () => { page++; load(); } }, '›')) : null); + if (s.key === 'time') body.append(h('p', { class: 'muted' }, `Gesamt: ${resp.totalMinutes} Minuten`)); + }; + const remove = (x) => confirm('Wirklich löschen?') && run(async () => { await post(s.file, { action: s.deleteAction || 'delete', id: x.id }); load(); }, 'Gelöscht'); + function edit(x) { + const inputs = {}; + const rows = s.fields.map(([k, label, type]) => { + let el; + if (type === H) return h('div', { class: 'sect' }, label); + if (type === ES) { + const cur = String(x?.[k] ?? '').toLowerCase(), opts = resp.emailOptions || []; + el = h('select', {}, h('option', { value: '' }, '— Automatisch —'), ...opts.map((o) => h('option', { value: o.email, selected: o.email.toLowerCase() === cur }, `${o.email} (${o.departmentName})`)), + cur && !opts.some((o) => o.email.toLowerCase() === cur) ? h('option', { value: x[k], selected: true }, x[k]) : null); + inputs[k] = el; return [h('label', {}, label), el]; + } + if (type === XD) { + const cur = new Map((x?.[k] || []).map((e) => [e.departmentId, e])); + const boxes = (resp.departments || []).map((d) => ({ + d, on: h('input', { type: 'checkbox', checked: cur.has(d.id) }), nt: h('input', { type: 'checkbox', checked: cur.get(d.id)?.notify ?? true }), + role: h('select', {}, h('option', { value: '' }, 'Hauptrolle'), ...(resp.roles || []).map((r) => h('option', { value: r.id, selected: cur.get(d.id)?.roleId === r.id }, r.name))), + })); + inputs[k] = { get: () => boxes.filter((b) => b.on.checked).map((b) => ({ departmentId: b.d.id, notify: b.nt.checked, roleId: b.role.value === '' ? null : Number(b.role.value) })) }; + return [h('label', {}, label), h('div', {}, ...boxes.map((b) => h('div', { class: 'row' }, h('label', { class: 'check grow' }, b.on, b.d.name), b.role, h('label', { class: 'check' }, b.nt, 'Benachrichtigungen'))))]; + } + if (typeof type === 'string' && type.startsWith('multi:')) { + const cur = new Set(x?.[k] || []), boxes = (resp[type.slice(6)] || []).map((o) => ({ o, el: h('input', { type: 'checkbox', checked: cur.has(o.id) }) })); + inputs[k] = { get: () => boxes.filter((b) => b.el.checked).map((b) => b.o.id) }; + return [h('label', {}, label), h('div', {}, ...(boxes.length ? boxes.map((b) => h('label', { class: 'check' }, b.el, b.o.name)) : [h('span', { class: 'muted' }, 'Keine vorhanden.')]))]; + } + if (type === B) { el = h('input', { type: 'checkbox', checked: x ? !!x[k] : k === 'isActive' || k === 'isPublished' || k === 'isPublic' }); inputs[k] = el; return h('label', { class: 'check' }, el, label); } + if (type === A) el = h('textarea', { value: x?.[k] ?? '' }); + else if (type === N) el = h('input', { type: 'number', value: x?.[k] ?? (k === 'imapPort' ? 993 : k === 'smtpPort' ? 587 : 0) }); + else if (type === 'pw') el = h('input', { type: 'password' }); + else if (typeof type === 'string' && type !== T) { + const key = type.replace('?', ''), opt = type.endsWith('?'); + const list = key === 'items' ? (resp.items || []).filter((o) => !x || o.id !== x.id).map((o) => ({ id: o.id, name: o.path || o.name })) : (resp[key] || []); + el = h('select', {}, opt ? h('option', { value: '' }, '—') : null, ...list.map((o) => h('option', { value: o.id, selected: x && String(x[k]) === String(o.id) }, o.name))); + } else if (Array.isArray(type)) el = h('select', {}, ...type.map(([v, l]) => h('option', { value: v, selected: x && x[k] === v }, l))); + else el = h('input', { value: x?.[k] ?? (k === 'imapFolder' ? 'INBOX' : k === 'imapArchiveFolder' ? 'Processed' : k === 'imapSpamFolder' ? 'Junk' : '') }); + inputs[k] = el; + return [h('label', {}, label), el]; + }); + if (s.info && s.info(x)) rows.push(h('p', { class: 'muted' }, s.info(x))); + const extra = x && s.actions ? s.actions.map(([label, action]) => ({ label, cls: 'ghost', fn: async () => { + const r = await run(() => post(s.file, { action, id: x.id })); if (r) toast(r.message || 'OK'); + } })) : []; + modal(x ? 'Bearbeiten' : 'Neu', h('div', {}, ...rows), [...extra, { label: 'Speichern', fn: async (close) => { + const body = { action: s.saveAction || 'save' }; if (x) body.id = x.id; + for (const [k, , type] of s.fields) { + if (type === H) continue; + if (inputs[k]?.get) { body[k] = inputs[k].get(); continue; } + const el = inputs[k]; let v = type === B ? el.checked : type === N ? Number(el.value) : el.value; + if (typeof type === 'string' && ![T, N, B, A, 'pw', ES].includes(type)) v = v === '' ? null : Number(v); + if (type === 'pw' && v === '') v = ''; + body[k] = v; + } + if (await run(() => post(s.file, body), 'Gespeichert')) { close(); load(); } + } }]); + } + load(); +} + +// ─── Einstellungen ─────────────────────────────────────────────────────────── +async function vSettings(main) { + const p = await window.deskhub.settings(); state.prefs = p; + const save = async (patch, msg) => { state.prefs = await window.deskhub.setSettings(patch); applyTheme(); if ('pollSeconds' in patch) schedulePoll(); if (msg) toast(msg); }; + const check = (key, label, hint) => h('div', { class: 'setting' }, h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!p[key], onchange: (e) => save({ [key]: e.target.checked }, 'Gespeichert') }), label), hint ? h('div', { class: 'muted hint' }, hint) : null); + const session = await window.deskhub.session(); + main.append(h('h2', {}, 'Einstellungen'), + h('div', { class: 'panel' }, h('h3', {}, 'Darstellung'), + h('label', {}, 'Design'), h('select', { onchange: (e) => save({ theme: e.target.value }, 'Gespeichert') }, + ...[['system', 'Wie Windows (automatisch)'], ['light', 'Hell'], ['dark', 'Dunkel']].map(([v, l]) => h('option', { value: v, selected: (p.theme || 'system') === v }, l)))), + h('div', { class: 'panel' }, h('h3', {}, 'Windows'), + check('autostart', 'Mit Windows starten', p.packaged ? 'Startet DeskHub beim Anmelden im Hintergrund (Tray).' : 'Nur in der installierten Version verfügbar.'), + check('closeToTray', 'Beim Schließen im Infobereich weiterlaufen', 'So kommen Benachrichtigungen auch bei geschlossenem Fenster an. Beenden über das Tray-Menü.')), + h('div', { class: 'panel' }, h('h3', {}, 'Benachrichtigungen'), + check('notifications', 'Windows-Benachrichtigungen anzeigen', 'Bei neuen Aktualisierungen an deinen Tickets und neuen, nicht zugewiesenen Tickets.'), + h('label', {}, 'Abfrage-Intervall'), h('select', { onchange: (e) => save({ pollSeconds: +e.target.value }, 'Gespeichert') }, + ...[[30, 'alle 30 Sekunden'], [60, 'jede Minute'], [120, 'alle 2 Minuten'], [300, 'alle 5 Minuten']].map(([v, l]) => h('option', { value: v, selected: Number(p.pollSeconds) === v }, l))), + h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => window.deskhub.notify('DeskHub', 'Test-Benachrichtigung – so sieht eine Meldung aus.') }, 'Test-Benachrichtigung'))), + h('div', { class: 'panel' }, h('h3', {}, 'Konto'), + h('div', {}, `${state.staff.name} (${state.staff.email})`), h('div', { class: 'muted' }, session?.serverUrl || ''), + h('div', { class: 'row top' }, h('button', { class: 'btn ghost', onclick: doLogout }, 'Abmelden / Server wechseln'), + h('button', { class: 'btn ghost', onclick: () => { for (const k of Object.keys(localStorage).filter((k) => k.startsWith('draft:'))) localStorage.removeItem(k); toast('Entwürfe gelöscht'); } }, 'Gespeicherte Entwürfe löschen'))), + h('div', { class: 'panel muted' }, `DeskHub Desktop ${p.version} · Rechte und Daten kommen vom Server – genau wie in der Android-App.`), + h('div', { class: 'panel' }, h('h3', {}, 'Tastenkürzel'), h('table', {}, ...[['Strg + K', 'Schnellsuche / Befehle'], ['Strg + N', 'Neues Ticket'], ['Strg + Enter', 'Antwort senden'], ['F5', 'Ansicht aktualisieren'], ['Alt + ←', 'Zurück zur Ticketliste'], ['Esc', 'Fenster schließen']] + .map(([k, d]) => h('tr', {}, h('td', {}, h('kbd', {}, k)), h('td', {}, d)))))); +} + +// ─── Schnellsuche (Strg+K) ─────────────────────────────────────────────────── +function openPalette() { + if (!state.staff || document.querySelector('.palette-bg')) return; + const input = h('input', { placeholder: 'Ticketnummer, Betreff, Kunde oder Bereich…', class: 'palette-input' }); + const list = h('div', { class: 'palette-list' }); + const bg = h('div', { class: 'modal-bg palette-bg' }, h('div', { class: 'palette' }, input, list)); + let items = [], idx = 0, timer = null; + const close = () => bg.remove(); + const pages = () => { + const base = [...NAV.map(([k, , l]) => [k, l]), ['availability', 'Verfügbarkeit'], ['settings', 'Einstellungen']]; + if (state.meta?.liveChatEnabled) base.push(['livechat', 'Live-Chat']); + const cmds = base.map(([k, l]) => ({ label: l, hint: 'Bereich', run: () => go(k) })); + if (can('create_ticket')) cmds.push({ label: 'Neues Ticket erstellen', hint: 'Aktion', run: () => go('newticket') }); + for (const s of adminSections()) cmds.push({ label: s.label, hint: 'Verwaltung', run: () => go('admin', s.key) }); + return cmds; + }; + const paint = () => { + clear(list).append(...(items.length ? items.map((it, i) => h('div', { class: 'palette-item' + (i === idx ? ' active' : ''), onclick: () => { close(); it.run(); } }, h('span', {}, it.label), h('span', { class: 'muted' }, it.hint))) : [h('div', { class: 'muted pad' }, 'Keine Treffer.')])); + }; + const refresh = async () => { + const q = input.value.trim().toLowerCase(); + items = pages().filter((c) => !q || c.label.toLowerCase().includes(q)).slice(0, 6); + idx = 0; paint(); + if (q.length >= 2) { + try { + const r = await api('tickets.php', { query: { q: input.value.trim(), perPage: 10, status: 'open,pending,answered,on_hold,resolved,closed' } }); + if (input.value.trim().toLowerCase() !== q) return; + items = [...r.items.map((t) => ({ label: `${t.number} · ${t.subject}`, hint: `${t.requesterName} · ${statusInfo(t.status).label}`, run: () => go('ticket', t.number) })), ...items]; + paint(); + } catch {} + } + }; + input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(refresh, 200); }); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); paint(); e.preventDefault(); } + else if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); paint(); e.preventDefault(); } + else if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); } + }); + bg.addEventListener('mousedown', (e) => { if (e.target === bg) close(); }); + document.body.append(bg); input.focus(); refresh(); +} + +// Meldet dem Server höchstens einmal pro Minute, dass das Programm bedient wird (sonst setzt der Server nach 30 Minuten ohne Aktivität auf Offline) +let lastPresence = 0; +function presencePing() { + if (!state.staff || Date.now() - lastPresence < 60000) return; + lastPresence = Date.now(); + post('presence.php', {}).catch(() => { lastPresence = 0; }); +} +['mousedown', 'keydown', 'wheel', 'touchstart'].forEach((ev) => document.addEventListener(ev, presencePing, { passive: true })); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { const m = document.querySelector('.modal-bg'); if (m) { m.remove(); e.preventDefault(); } return; } + if (!state.staff) return; + const mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette(); } + else if (mod && e.key.toLowerCase() === 'n' && can('create_ticket')) { e.preventDefault(); go('newticket'); } + else if (mod && e.key.toLowerCase() === 'f' && state.page === 'tickets') { e.preventDefault(); document.getElementById('ticket-search')?.focus(); } + else if (e.key === 'F5') { e.preventDefault(); go(state.page, state.arg); } + else if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); go('tickets'); } +}); + +// ─── Start ─────────────────────────────────────────────────────────────────── +window.deskhub.onSessionExpired(() => { clearTimeout(state.pollTimer); state.staff = null; toast('Sitzung abgelaufen – bitte neu anmelden.', true); showLogin(); }); +window.deskhub.onNavigate((page) => { if (state.staff) go(page); }); +(async () => { + // erst starten, wenn auch admin-extra.js geladen ist (ergänzt ADMIN) + if (document.readyState !== 'complete') await new Promise((r) => window.addEventListener('load', r, { once: true })); + state.prefs = await window.deskhub.settings(); applyTheme(); + const s = await window.deskhub.session(); + if (!s) return showLogin(); + state.serverUrl = s.serverUrl; + const cached = store.get(metaKey(`${s.serverUrl}:${s.staff.id}`), null); + if (cached) { startApp(s.staff, cached, s.serverUrl); return; } + try { await api('me.php'); await startApp(s.staff, null, s.serverUrl); } + catch { showLogin(s.serverUrl); } +})(); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index d702d92f..d402727b 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -173,6 +173,12 @@ tr.click { cursor: pointer; transition: background .08s; } tr.click:hover td { b .toast.error { border-left-color: var(--bad); } .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } .tile { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; cursor: pointer; font-weight: 600; box-shadow: var(--shadow-sm); transition: transform .12s, border-color .12s, box-shadow .12s; } +.tile .tt { font-weight: 600; } +.tile .td { font-weight: 400; font-size: .8rem; color: var(--muted); margin-top: 4px; line-height: 1.35; } +.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.tilegroup { margin-bottom: 26px; } +.grouplabel { margin: 0 0 10px; font-size: .72rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } +.navsep { height: 1px; background: var(--chrome-hover); margin: 8px 6px; } .tile:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); color: var(--accent); } .bulk .btn.ghost { color: inherit; border-color: rgba(255,255,255,.55); } .bulk .btn.ghost:hover { background: rgba(255,255,255,.18); } .sect { margin: 16px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }