Files
deskhub/desktop/src/app.js
T
2026-11-05 21:22:50 +00:00

1134 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Mac und Linux: normaler Fensterrahmen statt eigener Titelleiste (nur Windows hat sie)
if (!/Windows/i.test(navigator.userAgent)) document.documentElement.classList.add('native-frame');
// 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);
// Änderungen an Tickets (antworten, Status, Zuweisung): Zähler bald neu holen, damit er sofort stimmt
if (/^(ticket_message|ticket_update|ticket_spam|ticket_collaborators)\.php/.test(path) && opts.method === 'POST' && typeof pollSoon === 'function') pollSoon();
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')));
}
const u = state.update;
if (u?.available) {
box.append(h('div', { class: 'banner warn' },
h('span', { class: 'grow' }, u.busy ? `Update ${u.version} wird geladen … ${u.pct || 0} %` : `Update verfügbar: DeskHub ${u.version} (installiert: ${u.current}).${u.notes ? ' ' + u.notes : ''}`),
u.busy ? null : h('button', { class: 'btn sm', onclick: installUpdate }, 'Jetzt aktualisieren'),
u.busy ? null : h('button', { class: 'btn ghost sm', onclick: () => { state.update = { ...u, dismissed: u.version, available: false }; paintBanners(); } }, 'Später')));
}
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 });
// ─── Programm-Update: beim Start und danach alle 6 Stunden prüfen, Banner mit Button ───────────────────────
async function checkUpdate() {
try {
const r = await window.deskhub.updateCheck();
if (r.error || !r.data?.packaged || state.update?.busy) return;
const d = r.data;
state.update = { ...d, available: d.available && state.update?.dismissed !== d.version, dismissed: state.update?.dismissed };
paintBanners();
} catch { /* Update-Prüfung ist optional */ }
}
async function installUpdate() {
state.update = { ...state.update, busy: true, pct: 0 }; paintBanners();
const r = await window.deskhub.updateInstall();
if (r.error) { state.update = { ...state.update, busy: false }; paintBanners(); toast(r.error.message, true); }
}
window.deskhub.onUpdateProgress((pct) => { if (state.update?.busy) { state.update.pct = pct; paintBanners(); } });
/** E-Mail-Abruf von Hand (wie der Knopf in der Ticketliste der Weboberfläche). */
async function fetchMailNow(btn) {
const old = btn.textContent;
btn.disabled = true; btn.textContent = tr('⏳ Abruf läuft …');
try {
const r = await post('mail_fetch.php', {});
toast(r.message, r.level === 'error');
} catch (e) { toast(e.message, true); }
finally { btn.disabled = false; btn.textContent = old; }
}
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(window.I18N?.locale || 'de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
const textToHtml = (t) => '<p>' + t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').split(/\n{2,}/).map((p) => p.replace(/\n/g, '<br>')).join('</p><p>') + '</p>';
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('div', { class: 'login-lang' }, window.I18N.switcher()), 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;
checkUpdate(); clearInterval(state.updateTimer); state.updateTimer = setInterval(checkUpdate, 6 * 3600 * 1000);
state.since = ''; state.badge = 0; state.knownIds = null;
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,
can('fetch_mail') ? h('button', { class: 'btn ghost sm', title: tr('Ruft neue E-Mails aus den Postfächern ab'), onclick: (e) => fetchMailNow(e.currentTarget) }, tr('📥 E-Mails abrufen')) : 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' }),
window.I18N.switcher(),
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)).then(() => { if (page === 'ticket') pollSoon(); }).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message)));
}
window.addEventListener('focus', () => { if (state.staff) pollActivity(); }); // beim Zurückkehren zum Fenster sofort den aktuellen Stand holen
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); state.knownIds = 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);
}
// Der Zähler folgt dem Stand auf dem Server (siehe pollActivity) und wird nicht mehr beim Öffnen der Ticketliste geleert; nach einer Aktion fragen wir bald neu ab
function pollSoon() { if (state.staff) schedulePoll(1200); }
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);
}
/**
* Der Zähler zeigt, wie viele Tickets jetzt Aufmerksamkeit brauchen (offen, dir zugewiesen oder noch niemandem, seit der letzten
* Kundennachricht nicht geöffnet): Er sinkt von selbst, wenn ein Ticket geöffnet, beantwortet oder übernommen wird, auch anderswo
* (Weboberfläche, Chrome-Erweiterung). Benachrichtigt wird nur für Tickets, die neu dazukommen. Ältere Server ohne „attention“ liefern nur
* „seit dem letzten Mal“: Dafür bleibt die frühere, aufsummierende Zählung.
*/
async function pollActivity() {
try {
const a = await api('activity.php', { query: { since: state.since } });
if (a.attention && Array.isArray(a.attention.unassigned) && Array.isArray(a.attention.mine)) {
const known = state.knownIds;
const freshU = known ? a.attention.unassigned.filter((id) => !known.has(id)).length : 0;
const freshM = known ? a.attention.mine.filter((id) => !known.has(id)).length : 0;
state.knownIds = new Set([...a.attention.unassigned, ...a.attention.mine]);
if (state.badge !== state.knownIds.size) { state.badge = state.knownIds.size; paintBadge(); }
if ((freshU || freshM) && state.prefs?.notifications !== false) {
const parts = [];
if (freshM) parts.push(`${freshM} Aktualisierung(en) an deinen Tickets`);
if (freshU) parts.push(`${freshU} neue(s) nicht zugewiesene(s) Ticket(s)`);
window.deskhub.notify('DeskHub', parts.map((p) => tr(p)).join(', '));
}
} else 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.map((p) => tr(p)).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', {}, 'Eingang'), 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: 'muted nowrap' }, fmtDate(t.createdAt)), 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(window.I18N?.locale || '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' }],
paused: ['Pausiert', { status: 'on_hold', assigned: '', sla: '' }],
snoozed: ['Wiedervorlage', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '', snoozed: '1' }],
following: ['👁 Beobachtet', { status: 'open,pending,answered,on_hold', assigned: '', sla: '', following: '1' }],
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, can('share_views') ? h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar') : null),
[{ 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 bulkResolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']];
const act = async (patch, label) => {
if (['resolved', 'closed'].includes(patch.status)) {
const code = prompt('Abschlussgrund: ' + bulkResolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed');
if (!bulkResolutionOptions.some(([v]) => v === code)) return;
patch = { ...patch, resolutionCode: code, resolutionNote: prompt('Lösungsnotiz (optional):', '') || '' };
}
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();
await load();
every(30000, () => { if (!selected.size && !document.querySelector('.modal-bg')) load(true); });
}
// ─── Ticket-Detail ───────────────────────────────────────────────────────────
// Seite der Weboberfläche im Browser öffnen (einmalige Anmeldung; der Server prüft die Rechte)
async function openWebPage(path) {
const r = await window.deskhub.openWeb(path);
if (r?.error) toast(r.error.message || 'Die Seite konnte nicht geöffnet werden.', true);
}
// Wiedervorlage: Zeitpunkt wählen (Voreinstellungen oder eigener Zeitpunkt), Rückgabe als ISO-Zeit (UTC) für den Server
function snoozeChoices() {
const now = Date.now();
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(8, 0, 0, 0);
return [['1h', 'In 1 Stunde', now + 3600e3], ['3h', 'In 3 Stunden', now + 3 * 3600e3], ['tomorrow', 'Morgen früh (8:00 Uhr)', tomorrow.getTime()], ['3d', 'In 3 Tagen', now + 3 * 86400e3], ['1w', 'In 1 Woche', now + 7 * 86400e3]];
}
function askSnooze(onPick) {
const choices = snoozeChoices();
const sel = h('select', {}, ...choices.map(([k, l]) => h('option', { value: k }, l)), h('option', { value: 'custom' }, 'Zu einem Zeitpunkt …'));
const when = h('input', { type: 'datetime-local', class: 'hidden' });
sel.addEventListener('change', () => when.classList.toggle('hidden', sel.value !== 'custom'));
modal('Auf später legen', h('div', {}, h('p', { class: 'muted' }, 'Das Ticket verschwindet aus den Listen und kommt zum gewählten Zeitpunkt zurück, mit Benachrichtigung. Antwortet der Kunde früher, kommt es sofort zurück.'), h('label', {}, 'Wann?'), sel, when),
[{ label: 'Auf später legen', fn: async (close) => {
let ts;
if (sel.value === 'custom') ts = when.value ? new Date(when.value).getTime() : NaN; else ts = choices.find(([k]) => k === sel.value)[2];
if (!(ts > Date.now() + 30e3)) { toast('Bitte einen Zeitpunkt in der Zukunft wählen.', true); return; }
close(); onPick(new Date(ts).toISOString());
} }]);
}
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 resolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']];
const closeWithReason = (status) => { const code = prompt('Abschlussgrund: ' + resolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed'); if (!resolutionOptions.some(([v]) => v === code)) return; const note = prompt('Lösungsnotiz (optional):', '') || ''; update({ status, resolutionCode: code, resolutionNote: note }, status === 'resolved' ? 'Gelöst' : 'Geschlossen'); };
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) => ['resolved', 'closed'].includes(v) ? closeWithReason(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])], '', () => {});
const sendResolution = mkSelect([['', 'Abschlussgrund wählen'], ...resolutionOptions], '', () => {});
const resolutionNote = h('input', { placeholder: 'Lösungsnotiz (optional)' });
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(/<br\s*\/?>/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('<body>' + 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');
sendResolution.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value));
resolutionNote.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value));
sigBox?.classList.toggle('hidden', mode !== 'reply');
sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern';
ta.classList.toggle('noteedit', mode === 'note');
};
paintTabs(); loadDraft();
sendStatus.addEventListener('change', () => paintTabs());
const send = async () => {
if (!ta.value.trim() || sendBtn.disabled) return;
if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return;
sendBtn.disabled = true;
if (mode === 'reply' && ['resolved', 'closed'].includes(sendStatus.value) && !resolutionOptions.some(([v]) => v === sendResolution.value)) { toast('Bitte Abschlussgrund auswählen.', true); sendBtn.disabled = false; return; }
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, resolutionCode: sendResolution.value || null, resolutionNote: resolutionNote.value }), 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(', '), ' ', tr('– 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;
// Anhänge, Bilder und Formatierung der Original-E-Mail aus dem Postfach nachladen (nur bei E-Mail-Tickets)
const reloadMailBtn = t.source === 'email' && can('reply_ticket') ? h('button', { class: 'btn ghost sm', title: tr('Holt Anhänge, Bilder und die Formatierung der E-Mail aus dem Postfach, soweit sie beim Abruf nicht übernommen wurden'), onclick: () => run(async () => {
const r = await post('ticket_mail_reload.php', { number });
const n = (r.added || 0) + (r.inlineImages || 0);
toast(n > 0 || r.formatting ? tr('Anhänge und Bilder aus der E-Mail nachgeladen.') : tr('Es gab nichts Neues zu laden.'));
if (r.failed?.length) toast(r.failed.join(' | '), true);
reload();
}) }, tr('📎 Anhänge laden')) : 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: () => closeWithReason('resolved') }, 'Lösen') : null,
!closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => closeWithReason('closed') }, 'Schließen') : null,
closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null,
t.snoozedUntil
? h('button', { class: 'btn ghost sm', onclick: () => update({ snoozeUntil: null }, 'Wiedervorlage aufgehoben') }, 'Wiedervorlage aufheben')
: h('button', { class: 'btn ghost sm', title: 'Ticket auf später legen', onclick: () => askSnooze((iso) => update({ snoozeUntil: iso }, 'Wiedervorlage gesetzt')) }, '💤 Auf später'),
h('button', { class: 'btn ghost sm', onclick: () => update({ following: !t.following }, t.following ? 'Du beobachtest das Ticket nicht mehr' : 'Du beobachtest das Ticket jetzt') }, t.following ? 'Nicht mehr beobachten' : '👁 Beobachten'),
h('button', { class: 'btn ghost sm', title: 'Im Browser öffnen: zusammenführen, aufteilen, drucken', onclick: () => openWebPage('/staff/ticket_view.php?n=' + encodeURIComponent(t.number)) }, '🌐 Im Browser'),
spamBtn,
reloadMailBtn,
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)];
// Zufriedenheit des Kunden (Bewertung nach dem Schließen) und Cc-Empfänger / Beobachter
const csatPanel = t.csat ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kundenzufriedenheit'),
t.csat.rating != null
? h('div', {}, h('div', { class: 'stars' }, '★'.repeat(t.csat.rating) + '☆'.repeat(5 - t.csat.rating), ' ', h('span', { class: 'muted' }, `${t.csat.rating} von 5`)),
t.csat.comment ? h('div', { class: 'csat-comment' }, `„${t.csat.comment}“`) : null, h('div', { class: 'muted' }, 'Bewertet am ' + fmtDate(t.csat.answeredAt)))
: h('div', { class: 'muted' }, `Abfrage am ${fmtDate(t.csat.sentAt)} gesendet, noch keine Antwort.`)) : null;
const collabBox = h('div');
const paintCollab = (list) => clear(collabBox).append(...(list.length ? list.map((c) => h('div', { class: 'mini row' },
h('span', { class: 'grow' }, c.name ? `${c.name} <${c.email}>` : c.email),
can('reply_ticket') ? h('button', { class: 'btn ghost sm', title: 'Entfernen', onclick: () => run(async () => { paintCollab((await post('ticket_collaborators.php', { number, action: 'remove', id: c.id })).collaborators); }) }, '✕') : null))
: [h('div', { class: 'muted' }, 'Niemand. Cc-Empfänger bekommen die öffentlichen Antworten per E-Mail in Kopie.')]));
paintCollab(t.collaborators || []);
const ccEmail = h('input', { type: 'email', placeholder: 'Cc-Adresse hinzufügen' });
const addCc = () => { const email = ccEmail.value.trim(); if (!email) return; run(async () => { paintCollab((await post('ticket_collaborators.php', { number, action: 'add', email })).collaborators); ccEmail.value = ''; }, 'Cc hinzugefügt'); };
ccEmail.addEventListener('keydown', (e) => { if (e.key === 'Enter') addCc(); });
const peoplePanel = h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Beobachter'),
(t.followers || []).length ? h('div', {}, (t.followers || []).map((f) => f.name).join(', ')) : h('div', { class: 'muted' }, 'Niemand. Beobachter bekommen Benachrichtigungen, ohne dass ihnen das Ticket zugewiesen ist.'),
h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => update({ following: !t.following }, t.following ? 'Du beobachtest das Ticket nicht mehr' : 'Du beobachtest das Ticket jetzt') }, t.following ? 'Nicht mehr beobachten' : '👁 Beobachten')),
h('div', { class: 'sect' }, 'Cc-Empfänger'), collabBox,
can('reply_ticket') ? h('div', { class: 'row top' }, ccEmail, h('button', { class: 'btn sm', onclick: addCc }, 'Hinzufügen')) : null);
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,
t.snoozedUntil ? h('span', { class: 'pill', color: '#6b5bd6' }, '💤 bis ' + fmtDate(t.snoozedUntil)) : null),
h('h2', {}, t.subject),
h('div', { class: 'sub' }, `${t.requesterName} · ${t.department.name} · erstellt ${fmtDate(t.createdAt)}`),
actions),
// Administratoren dürfen ein von anderen bearbeitetes Ticket lesen und bearbeiten und die Sperre aufheben
t.lockedBy ? h('div', { class: 'warnbox row' }, h('span', { class: 'grow' }, `🔒 Dieses Ticket wird gerade von „${t.lockedBy}“ bearbeitet. Als Administrator kannst du es trotzdem lesen und bearbeiten.`),
t.canUnlock ? h('button', { class: 'btn sm', onclick: () => run(async () => { await post('ticket_unlock.php', { number }); reload(); }, 'Sperre aufgehoben') }, 'Sperre aufheben und übernehmen') : null) : null,
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, sendResolution, resolutionNote, 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),
(t.similar && (t.similar.articles?.length || t.similar.tickets?.length)) ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Dazu passend'),
...(t.similar.articles || []).map((a) => h('div', { class: 'mini' }, h('span', {}, '📘 ' + a.title), ' ', h('button', { class: 'btn ghost sm', onclick: () => { navigator.clipboard?.writeText((state.serverUrl || '') + a.url); toast('Link kopiert'); } }, 'Link kopieren'))),
...(t.similar.tickets || []).map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, x.number + ' · ' + x.subject))) : null,
csatPanel, peoplePanel,
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 }) };
// Ticket-Vorlagen: füllen Betreff, Text, Abteilung und Priorität vor (gepflegt in der Weboberfläche unter Ticket-Vorlagen)
let tpls = [];
const tplSel = h('select', { onchange: () => {
const t = tpls.find((x) => String(x.id) === tplSel.value); if (!t) return;
f.subject.value = t.subject; f.message.value = t.body;
if (t.departmentId && m.departments.some((d) => d.id === t.departmentId)) f.dept.value = t.departmentId;
if (t.priorityId && m.priorities.some((p) => p.id === t.priorityId)) f.prio.value = t.priorityId;
} }, h('option', { value: '' }, '— ohne Vorlage —'));
const tplBox = h('div', { class: 'hidden' }, h('label', {}, 'Vorlage'), tplSel);
api('ticket_templates.php').then((r) => { tpls = r.templates || []; if (!tpls.length) return; tplSel.append(...tpls.map((x) => h('option', { value: x.id }, x.name))); tplBox.classList.remove('hidden'); }).catch(() => {});
main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Tickets'), h('h2', {}, 'Neues Ticket'),
h('div', { class: 'panel' }, tplBox,
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, can('share_views') ? h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar') : null), [{ 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')));
};
// Kundenzufriedenheit (Bewertungen der letzten 30 Tage), nur wenn die Abfrage eingeschaltet ist oder es schon Antworten gibt
const csatBox = h('div');
api('csat.php', { query: { days: 30 } }).then((r) => {
const s = r.stats; if (!(r.enabled || s.sent > 0)) return;
const max = Math.max(1, ...s.dist.map((d) => d.count));
clear(csatBox).append(h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kundenzufriedenheit (letzte 30 Tage)'),
s.sent ? h('div', {},
h('div', { class: 'row' }, h('span', { class: 'stars big' }, s.avg != null ? String(s.avg).replace('.', ',') + ' ★' : '–'), h('span', { class: 'muted' }, `Ø Bewertung · ${s.answered} Antworten von ${s.sent} Abfragen` + (s.rate != null ? ` (${String(s.rate).replace('.', ',')} %)` : ''))),
h('table', {}, ...[...s.dist].reverse().map((d) => h('tr', {}, h('td', { class: 'stars' }, '★'.repeat(d.rating)), h('td', {}, h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round(d.count * 100 / max) }))), h('td', {}, d.count)))),
s.byStaff.length ? h('div', { class: 'muted pad' }, 'Nach Mitarbeiter: ' + s.byStaff.map((x) => `${x.name} ${String(x.avg).replace('.', ',')} ★ (${x.count})`).join(' · ')) : null,
...s.recent.slice(0, 5).map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, h('span', { class: 'stars' }, '★'.repeat(x.rating)), ` #${x.number} ${x.subject}`, x.comment ? h('div', { class: 'muted' }, `„${x.comment}“`) : null)))
: h('div', { class: 'muted' }, 'Noch keine Abfragen verschickt.')));
for (const f of csatBox.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%');
}).catch(() => {});
main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Berichte'), h('div', { class: 'sub' }, 'Frei konfigurierbare Auswertungen nach Erstellungsdatum der Tickets'))), csatBox, 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', {}, can('manage_availability') ? 'Team (du kannst 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 (can('manage_availability')) {
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?'], ['autoAssignMode', 'Neue Tickets automatisch zuweisen', [['none', 'Nicht automatisch zuweisen'], ['staff', 'Immer an einen bestimmten Mitarbeiter'], ['round_robin', 'Reihum an die Mitarbeiter der Abteilung']]], ['autoAssignStaffId', 'Mitarbeiter (nur bei „Immer an einen bestimmten Mitarbeiter“)', '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', perm: 'manage_banlist', 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', 'assistant']],
['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', assistant: 'Antworten, Fragen-Log, Einstellungen, Synonyme',
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 (can('manage_availability')) 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.'))),
...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', tr('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); }
})();