190 lines
12 KiB
JavaScript
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');
|
|
})();
|