Files
deskhub/chrome-erweiterung/popup.js
T
2026-10-25 20:21:12 +00:00

190 lines
12 KiB
JavaScript

// Oberfläche der Erweiterung: Anmeldung, Übersicht, Suche, Ticket anlegen. Es wird nur mit DOM-Aufrufen gebaut (kein innerHTML mit Serverdaten).
import { apiCall, getSession, getSettings, setSettings, login, logout, openWeb, ticketPath, plainToHtml, normalizeServerUrl } from './lib/core.js';
const app = document.getElementById('app');
const params = new URLSearchParams(location.search);
if (params.get('window')) document.body.classList.add('window');
/** Kleiner Baumeister: h('div', {class:'x', onclick: fn}, 'Text', kind, …) */
function h(tag, attrs = {}, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v === undefined || v === null || v === false) continue;
if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (k === 'value') el.value = v;
else if (k === 'checked') el.checked = !!v;
else el.setAttribute(k, v === true ? '' : String(v));
}
for (const kid of kids.flat()) { if (kid === null || kid === undefined || kid === false) continue; el.append(kid.nodeType ? kid : document.createTextNode(String(kid))); }
return el;
}
const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); };
const show = (...nodes) => { clear(app); app.append(...nodes); };
const errText = (e) => (e && e.message) || 'Es ist ein Fehler aufgetreten.';
// ─── Anmeldung ──────────────────────────────────────────────────────────────
async function renderLogin(prefill = {}) {
const { lastLogin } = await chrome.storage.local.get('lastLogin');
const start = { serverUrl: prefill.serverUrl ?? lastLogin?.serverUrl ?? '', email: prefill.email ?? lastLogin?.email ?? '', needTotp: !!prefill.needTotp, error: prefill.error || '' };
let password = prefill.password || '';
const msg = h('div', { class: 'msg err', hidden: start.error ? undefined : true, role: 'alert' }, start.error);
const server = h('input', { type: 'text', id: 'server', placeholder: 'support.example.de', value: start.serverUrl, autocomplete: 'url', required: true });
const email = h('input', { type: 'email', id: 'email', value: start.email, autocomplete: 'username', required: true });
const pass = h('input', { type: 'password', id: 'pass', autocomplete: 'current-password', required: !start.needTotp, value: start.needTotp ? '' : undefined });
const code = h('input', { type: 'text', id: 'code', inputmode: 'numeric', pattern: '[0-9]{6}', maxlength: '6', autocomplete: 'one-time-code', placeholder: '6-stelliger Code' });
const button = h('button', { class: 'primary', type: 'submit' }, start.needTotp ? 'Bestätigen' : 'Anmelden');
const form = h('form', {
onsubmit: async (ev) => {
ev.preventDefault(); msg.hidden = true; button.disabled = true;
try {
if (!start.needTotp) password = pass.value;
const r = await login(server.value, email.value.trim(), password, start.needTotp ? code.value.trim() : '');
if (r.requiresTotp) { password = pass.value || password; return renderLogin({ serverUrl: server.value, email: email.value, password, needTotp: true }); }
await chrome.storage.local.set({ lastLogin: { serverUrl: normalizeServerUrl(server.value), email: email.value.trim() } });
password = '';
await chrome.runtime.sendMessage({ type: 'session-changed' });
renderMain(r.session);
} catch (e) { msg.textContent = errText(e); msg.hidden = false; button.disabled = false; }
},
},
start.needTotp ? h('p', { class: 'muted' }, 'Dein Konto ist mit einem Authenticator geschützt. Bitte den aktuellen Code eingeben.') : null,
start.needTotp ? null : h('label', {}, 'Server-Adresse', server),
start.needTotp ? null : h('label', {}, 'E-Mail-Adresse', email),
start.needTotp ? null : h('label', {}, 'Passwort', pass),
start.needTotp ? h('label', {}, 'Authenticator-Code', code) : null,
msg, button);
show(
h('header', { class: 'top' }, h('img', { src: 'icons/icon48.png', alt: '' }), h('span', { class: 'name' }, 'DeskHub')),
h('main', {}, h('div', { class: 'card' }, h('h1', {}, 'Anmelden'), h('p', { class: 'muted' }, 'Mit deinem Mitarbeiter-Konto. Die Erweiterung fragt dich einmal nach der Erlaubnis, auf deinen DeskHub-Server zuzugreifen (nur auf diesen, nicht auf andere Webseiten).'), form)));
(start.needTotp ? code : (start.serverUrl ? pass : server)).focus();
}
// ─── Hauptansicht ───────────────────────────────────────────────────────────
async function renderMain(session, tab = 'overview') {
chrome.runtime.sendMessage({ type: 'reset-badge' }).catch(() => {});
const canCreate = session.staff?.permissions?.create_ticket !== false;
if (tab === 'create' && !canCreate) tab = 'overview';
const body = h('div', { class: 'body', style: 'display:contents' });
const tabs = [['overview', 'Übersicht'], ['search', 'Suche'], ...(canCreate ? [['create', 'Neu']] : [])];
const nav = h('nav', { class: 'tabs' }, tabs.map(([key, label]) => h('button', { class: key === tab ? 'on' : '', onclick: () => renderMain(session, key) }, label)));
const settings = await getSettings();
const footer = h('footer', { class: 'bar' },
h('label', { class: 'inline' }, h('input', { type: 'checkbox', checked: settings.notifications, onchange: (ev) => setSettings({ ...settings, notifications: ev.target.checked }) }), 'Benachrichtigungen'),
h('span', {}, new URL(session.serverUrl).host));
show(
h('header', { class: 'top' }, h('img', { src: 'icons/icon48.png', alt: '' }), h('span', { class: 'name' }, session.staff?.name || 'DeskHub'),
h('button', { onclick: async () => { await logout(); await chrome.runtime.sendMessage({ type: 'session-changed' }).catch(() => {}); renderLogin(); } }, 'Abmelden')),
nav, body, footer);
const main = h('main');
body.append(main);
if (tab === 'overview') await renderOverview(main, session);
else if (tab === 'search') renderSearch(main, session);
else await renderCreate(main, session);
}
/** Behandelt Fehler zentral: abgelaufene Anmeldung führt zur Anmeldung zurück. */
function fail(el, e, session) {
if (e.status === 401) { renderLogin({ serverUrl: session?.serverUrl, error: 'Die Anmeldung ist abgelaufen. Bitte neu anmelden.' }); return; }
clear(el); el.append(h('div', { class: 'msg err', role: 'alert' }, errText(e)));
}
async function openTicket(number, host) {
try { await openWeb(ticketPath(number)); }
catch (e) { const old = host.querySelector('.msg.err'); if (old) old.remove(); host.prepend(h('div', { class: 'msg err', role: 'alert' }, errText(e))); }
}
function ticketList(items, host) {
if (!items.length) return h('div', { class: 'empty' }, 'Keine Tickets.');
return h('ul', { class: 'tickets' }, items.map((t) => h('li', {}, h('button', { onclick: () => openTicket(t.number, host), title: `Ticket ${t.number} im Browser öffnen` },
h('span', { class: 'dot', style: t.priority?.color && /^#[0-9a-fA-F]{3,8}$/.test(t.priority.color) ? `background:${t.priority.color}` : null }),
h('span', { class: 't-sub' }, t.subject),
h('span', { class: 't-meta' }, `#${t.number} · ${t.requesterName || '—'} · `, t.isOverdue ? h('span', { class: 'over' }, t.dueLabel || 'überfällig') : (t.dueLabel || t.department?.name || ''))))));
}
async function renderOverview(main, session) {
main.append(h('div', { class: 'empty' }, 'Lade …'));
try {
const { data } = await apiCall('dashboard.php');
clear(main);
main.append(
h('div', { class: 'stats' },
h('div', { class: 'stat' }, h('div', { class: 'v' }, data.myOpen ?? 0), h('div', { class: 'k' }, 'Meine offenen')),
h('div', { class: data.overdue ? 'stat warn' : 'stat' }, h('div', { class: 'v' }, data.overdue ?? 0), h('div', { class: 'k' }, 'Überfällig')),
h('div', { class: 'stat' }, h('div', { class: 'v' }, data.unassigned ?? 0), h('div', { class: 'k' }, 'Nicht zugewiesen'))),
h('div', { class: 'muted' }, 'Als Nächstes fällig (meine und nicht zugewiesene)'),
ticketList(data.recent || [], main));
} catch (e) { fail(main, e, session); }
}
function renderSearch(main, session) {
const results = h('div');
const input = h('input', { type: 'search', placeholder: 'Betreff, Nummer, Name, Text …', 'aria-label': 'Suche', maxlength: '100' });
main.append(h('form', { onsubmit: async (ev) => {
ev.preventDefault();
const q = input.value.trim();
if (q.length < 2) return;
clear(results); results.append(h('div', { class: 'empty' }, 'Suche …'));
try {
const { data } = await apiCall('tickets.php', { query: { q, status: 'open,pending,answered,on_hold,resolved,closed', perPage: 20 } });
clear(results);
results.append(h('div', { class: 'muted' }, `${data.total} Treffer${data.hasMore ? ' (die ersten 20)' : ''}`), ticketList(data.items || [], results));
} catch (e) { fail(results, e, session); }
} }, input, h('button', { class: 'primary', type: 'submit' }, 'Suchen')), results);
input.focus();
}
async function loadMeta() {
const { meta } = await chrome.storage.local.get('meta');
if (meta && Date.now() - meta.at < 10 * 60 * 1000) return meta;
const { data } = await apiCall('metadata.php');
const fresh = { at: Date.now(), departments: (data.departments || []).filter((d) => !/\(deaktiviert\)$/.test(d.name)), priorities: data.priorities || [] };
await chrome.storage.local.set({ meta: fresh });
return fresh;
}
async function renderCreate(main, session) {
main.append(h('div', { class: 'empty' }, 'Lade …'));
let meta;
try { meta = await loadMeta(); } catch (e) { return fail(main, e, session); }
const { prefill } = await chrome.storage.local.get('prefill');
if (prefill) chrome.storage.local.remove('prefill');
clear(main);
const out = h('div');
const f = {
name: h('input', { type: 'text', value: '', required: true, maxlength: '120', placeholder: 'Name des Anfragers' }),
mail: h('input', { type: 'email', placeholder: 'optional' }),
subject: h('input', { type: 'text', value: prefill?.subject || '', required: true, maxlength: '255' }),
message: h('textarea', { required: true, value: prefill?.message || '' }),
dept: h('select', { required: true }, meta.departments.map((d) => h('option', { value: d.id }, d.name))),
prio: h('select', { required: true }, meta.priorities.map((p) => h('option', { value: p.id }, p.name))),
notify: h('input', { type: 'checkbox' }),
};
const submit = h('button', { class: 'primary', type: 'submit' }, 'Ticket anlegen');
main.append(h('form', { onsubmit: async (ev) => {
ev.preventDefault(); clear(out); submit.disabled = true;
try {
const { data } = await apiCall('ticket_create.php', { method: 'POST', body: {
requesterName: f.name.value.trim(), requesterEmail: f.mail.value.trim(), subject: f.subject.value.trim(), message: plainToHtml(f.message.value.trim()),
departmentId: Number(f.dept.value), priorityId: Number(f.prio.value), notifyCustomer: f.notify.checked } });
const number = data.ticket?.number;
out.append(h('div', { class: 'msg ok', role: 'status' }, `Ticket #${number} wurde angelegt. `, h('button', { class: 'link', type: 'button', onclick: () => openTicket(number, out) }, 'Im Browser öffnen')));
for (const k of ['name', 'mail', 'subject', 'message']) f[k].value = '';
} catch (e) { if (e.status === 401) return fail(main, e, session); out.append(h('div', { class: 'msg err', role: 'alert' }, errText(e))); }
submit.disabled = false;
} },
h('label', {}, 'Anfrager (Name)', f.name), h('label', {}, 'Anfrager (E-Mail)', f.mail),
h('label', {}, 'Betreff', f.subject), h('label', {}, 'Beschreibung', f.message),
h('div', { class: 'row2' }, h('label', {}, 'Abteilung', f.dept), h('label', {}, 'Priorität', f.prio)),
h('label', { class: 'inline' }, f.notify, 'Kunden per E-Mail benachrichtigen'),
out, submit));
f.name.focus();
}
// ─── Start ──────────────────────────────────────────────────────────────────
(async () => {
const session = await getSession();
if (!session) return renderLogin();
renderMain(session, params.get('create') ? 'create' : 'overview');
})();