Files
deskhub/desktop/src/app.js
T
2026-10-16 18:53:05 +00:00

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