Upload via GUI (23 Dateien)

This commit is contained in:
2026-11-01 11:15:09 +00:00
parent c57918c86f
commit ba32f72275
19 changed files with 1073 additions and 73 deletions
+46
View File
@@ -0,0 +1,46 @@
// Neues Ticket anlegen (auch aus dem Rechtsklick-Menü und per Tastenkürzel mit vorbefülltem Text).
import { h, clear, toast, errText } from '../lib/dom.js';
import { apiCall, getMeta, plainToHtml } from '../lib/core.js';
import { spinner, showError } from '../lib/ui.js';
export async function renderCreate(host, ctx) {
host.append(spinner());
let meta;
try { meta = await getMeta(); } catch (e) { return showError(ctx, host, e); }
const { prefill } = await chrome.storage.local.get('prefill');
if (prefill) chrome.storage.local.remove('prefill');
clear(host);
const me = ctx.session.staff?.id;
const f = {
name: h('input', { type: 'text', 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))),
who: h('select', {}, h('option', { value: '' }, 'Nicht zuweisen'), h('option', { value: me, selected: '' }, 'Mir zuweisen'), meta.agents.filter((a) => a.id !== me).map((a) => h('option', { value: a.id }, a.name))),
notify: h('input', { type: 'checkbox' }),
};
const submit = h('button', { class: 'btn primary', type: 'submit' }, 'Ticket anlegen');
const out = h('div');
host.append(h('form', { class: 'stack', 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), assignedStaffId: f.who.value ? Number(f.who.value) : null, notifyCustomer: f.notify.checked } });
const number = data.ticket?.number;
toast(`Ticket #${number} angelegt`);
if (number) ctx.go('ticket', { number });
} catch (e) { if (e.status === 401) return ctx.logoutExpired(); out.append(h('div', { class: 'msg err', role: 'alert' }, errText(e))); }
submit.disabled = false;
} },
h('div', { class: 'card stack' },
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', {}, 'Bearbeiter', f.who),
h('label', { class: 'inline' }, f.notify, 'Kunden per E-Mail benachrichtigen')),
out, submit));
f.name.focus();
}
+44
View File
@@ -0,0 +1,44 @@
// Start: Begrüßung, eigener Status, Kennzahlen, als Nächstes fällige Tickets.
import { h, clear, icon, toast, errText } from '../lib/dom.js';
import { apiCall, AVAILABILITY } from '../lib/core.js';
import { spinner, ticketList, showError, segmented } from '../lib/ui.js';
export async function renderHome(host, ctx) {
host.append(spinner());
try {
const [dash, avail] = await Promise.all([
apiCall('dashboard.php').then((r) => r.data),
apiCall('availability.php').then((r) => r.data).catch(() => null),
]);
clear(host);
const first = (ctx.session.staff?.name || '').split(' ')[0];
const stack = h('div', { class: 'stack' });
stack.append(h('div', {}, h('div', { class: 'muted' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })), h('div', { class: 'hello' }, `Hallo ${first || ''}`.trim())));
if (avail) {
let current = avail.myStatus;
const holder = h('div');
const draw = () => holder.replaceChildren(segmented(Object.entries(AVAILABILITY), current, async (v) => {
const old = current; current = v; draw();
try { await apiCall('availability.php', { method: 'POST', body: { status: v, note: avail.myNote || '' } }); ctx.setStatusDot(v); toast(`Status: ${AVAILABILITY[v]}`); }
catch (e) { current = old; draw(); toast(errText(e), 'err'); }
}));
draw();
ctx.setStatusDot(current);
stack.append(h('div', { class: 'card' }, h('h2', {}, 'Mein Status'), holder));
}
const stat = (value, label, cls, filter) => h('button', { class: `stat ${cls || ''}`, onclick: () => ctx.go('tickets', { filter }) }, h('div', { class: 'v' }, value), h('div', { class: 'k' }, label));
stack.append(h('div', { class: 'stats' },
stat(dash.myOpen ?? 0, 'Meine offenen', '', 'mine'),
stat(dash.overdue ?? 0, 'Überfällig', dash.overdue ? 'warn' : '', 'overdue'),
stat(dash.unassigned ?? 0, 'Nicht zugewiesen', '', 'unassigned'),
stat(dash.unanswered ?? 0, 'Unbeantwortet', '', 'open_status')),
h('div', { class: 'stats' },
h('div', { class: 'stat good' }, h('div', { class: 'v' }, dash.resolvedToday ?? 0), h('div', { class: 'k' }, 'Heute gelöst')),
h('div', { class: 'stat' }, h('div', { class: 'v' }, dash.slaPercent == null ? '–' : `${Math.round(dash.slaPercent)} %`), h('div', { class: 'k' }, 'SLA eingehalten (30 Tage)'))));
stack.append(h('h2', { class: 'sec', style: { margin: '6px 0 0' } }, 'Als Nächstes fällig'), ticketList(dash.recent || [], (t) => ctx.go('ticket', { number: t.number }), 'Alles erledigt. Nichts offen.'));
host.append(stack);
} catch (e) { showError(ctx, host, e); }
}
+37
View File
@@ -0,0 +1,37 @@
// Anmeldung. Kein Berechtigungsschritt mehr: Der Serverzugriff ist im Manifest erlaubt.
import { h, errText } from '../lib/dom.js';
import { login, normalizeServerUrl } from '../lib/core.js';
export async function renderLogin(app, ctx, 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 });
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: 'btn 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) return renderLogin(app, ctx, { 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' }).catch(() => {});
ctx.loggedIn(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.'), h('label', {}, 'Authenticator-Code', code)]
: [h('label', {}, 'Server-Adresse', server), h('label', {}, 'E-Mail-Adresse', email), h('label', {}, 'Passwort', pass)],
msg, button);
app.replaceChildren(h('div', { class: 'content' }, h('div', { class: 'login' },
h('div', { class: 'brand' }, h('img', { src: 'icons/icon128.png', alt: '' }), h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mit deinem Mitarbeiter-Konto anmelden')),
form)));
(start.needTotp ? code : (start.serverUrl ? pass : server)).focus();
}
+43
View File
@@ -0,0 +1,43 @@
// Mehr: Konto, Team, Einstellungen, Weboberfläche, Abmelden.
import { h, clear, icon, toast, errText } from '../lib/dom.js';
import { apiCall, getSettings, setSettings, openWeb, AVAILABILITY } from '../lib/core.js';
import { spinner, segmented, avatar } from '../lib/ui.js';
const toggle = (label, hint, checked, onChange) => h('label', { class: 'listrow', style: { fontWeight: 500 } },
h('span', { class: 'grow' }, h('div', {}, label), hint ? h('div', { class: 'muted' }, hint) : null),
h('span', { class: 'switch' }, h('input', { type: 'checkbox', checked, onchange: (ev) => onChange(ev.target.checked) })));
export async function renderMore(host, ctx) {
const settings = await getSettings();
const s = ctx.session.staff || {};
const save = async (patch) => { Object.assign(settings, patch); await setSettings(settings); ctx.applyTheme(settings.theme); };
const team = h('div', {}, spinner());
host.append(h('div', { class: 'stack' },
h('div', { class: 'card', style: { display: 'flex', gap: '12px', alignItems: 'center' } }, avatar(s.name), h('div', { class: 'grow' }, h('div', { style: { fontWeight: 700 } }, s.name), h('div', { class: 'muted' }, `${s.email || ''} · ${s.role === 'admin' ? 'Administrator' : 'Mitarbeiter'}`), h('div', { class: 'muted' }, new URL(ctx.session.serverUrl).host))),
h('div', { class: 'card' }, h('h2', {}, 'Benachrichtigungen'),
toggle('Meldungen anzeigen', 'Chrome-Benachrichtigung bei Neuem', settings.notifications, (v) => save({ notifications: v })),
toggle('Neue, nicht zugewiesene Tickets', null, settings.notifyUnassigned, (v) => save({ notifyUnassigned: v })),
toggle('Aktualisierungen bei meinen Tickets', null, settings.notifyMine, (v) => save({ notifyMine: v }))),
h('div', { class: 'card' }, h('h2', {}, 'Darstellung'),
segmented([['auto', 'Automatisch'], ['light', 'Hell'], ['dark', 'Dunkel']], settings.theme, async (v) => { await save({ theme: v }); clear(host); renderMore(host, ctx); })),
h('div', { class: 'card' }, h('h2', {}, 'Fenster'),
h('div', { class: 'actions' },
!ctx.isPanel ? h('button', { class: 'btn', onclick: ctx.openPanel }, icon('panel', 15), 'Seitenleiste') : null,
!ctx.isWindow ? h('button', { class: 'btn', onclick: ctx.openWindow }, icon('external', 15), 'Eigenes Fenster') : null,
h('button', { class: 'btn', onclick: () => openWeb('/staff/dashboard.php').catch((e) => toast(errText(e), 'err')) }, icon('home', 15), 'Weboberfläche')),
h('div', { class: 'muted', style: { marginTop: '8px' } }, 'Tastenkürzel: Alt+Shift+N neues Ticket, Alt+Shift+D Seitenleiste. In der Adressleiste: „dh“ + Leertaste + Ticketnummer oder Suchbegriff.')),
h('div', { class: 'card' }, h('h2', {}, 'Team'), team),
h('button', { class: 'btn danger', onclick: async () => { await ctx.logout(); } }, icon('logout', 15), 'Abmelden'),
h('div', { class: 'muted', style: { textAlign: 'center' } }, `DeskHub-Erweiterung ${chrome.runtime.getManifest().version}`)));
try {
const { data } = await apiCall('availability.php');
clear(team).append(...(data.team || []).map((m) => h('div', { class: 'listrow' }, h('span', { class: `statusdot ${m.status}`, style: { boxShadow: 'none' } }), h('span', { class: 'grow' }, m.name, m.note ? h('span', { class: 'muted' }, ` · ${m.note}`) : null), h('span', { class: 'muted' }, AVAILABILITY[m.status] || m.status))));
if (!data.team?.length) clear(team).append(h('div', { class: 'muted' }, 'Keine Daten.'));
} catch { clear(team).append(h('div', { class: 'muted' }, 'Team konnte nicht geladen werden.')); }
}
+63
View File
@@ -0,0 +1,63 @@
// Aufgaben: ansehen, abhaken, neue anlegen.
import { h, clear, icon, toast, errText } from '../lib/dom.js';
import { apiCall, parseServerTime } from '../lib/core.js';
import { spinner, showError } from '../lib/ui.js';
const FILTERS = [['my', 'Meine'], ['open', 'Alle offenen'], ['done', 'Erledigt']];
const memory = { filter: 'my' };
const dueText = (s) => { const d = parseServerTime(s); return d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; };
export async function renderTasks(host, ctx) {
const list = h('div');
const chips = h('div', { class: 'chips' });
const form = h('div');
const drawChips = () => chips.replaceChildren(...FILTERS.map(([k, l]) => h('button', { type: 'button', class: `chip${memory.filter === k ? ' on' : ''}`, onclick: () => { memory.filter = k; drawChips(); load(); } }, l)));
async function load() {
clear(list).append(spinner());
try {
const { data } = await apiCall('tasks.php', { query: { filter: memory.filter } });
ctx.taskStaff = data.staffOptions || [];
clear(list);
if (!data.items.length) return list.append(h('div', { class: 'empty' }, memory.filter === 'done' ? 'Keine erledigten Aufgaben.' : 'Keine Aufgaben. Schön!'));
list.append(h('div', { class: 'stack' }, data.items.map((it) => {
const done = it.status === 'done';
const box = h('input', { type: 'checkbox', checked: done, 'aria-label': 'Erledigt', onchange: async () => {
try { await apiCall('tasks.php', { method: 'POST', body: { action: 'update_status', id: it.id, status: box.checked ? 'done' : 'open' } }); toast(box.checked ? 'Erledigt' : 'Wieder geöffnet'); load(); }
catch (e) { box.checked = !box.checked; toast(errText(e), 'err'); }
} });
return h('div', { class: `card task${done ? ' done' : ''}` }, box, h('div', { class: 'grow' },
h('div', { class: 'tt', style: { fontWeight: 650 } }, it.title),
it.description ? h('div', { class: 'muted' }, it.description) : null,
h('div', { class: 'muted', style: { marginTop: '3px' } }, [it.dueAt ? `fällig ${dueText(it.dueAt)}` : null, it.assignedName ? `für ${it.assignedName}` : null].filter(Boolean).join(' · ')),
it.ticketNumber ? h('button', { class: 'btn small', style: { marginTop: '6px' }, onclick: () => ctx.go('ticket', { number: it.ticketNumber }) }, `#${it.ticketNumber}`) : null),
h('button', { class: 'iconbtn', title: 'Aufgabe löschen', onclick: async () => {
if (!confirm('Aufgabe löschen?')) return;
try { await apiCall('tasks.php', { method: 'POST', body: { action: 'delete', id: it.id } }); load(); } catch (e) { toast(errText(e), 'err'); }
} }, icon('x', 16)));
})));
} catch (e) { showError(ctx, list, e); }
}
function drawForm(open) {
if (!open) { form.replaceChildren(h('button', { class: 'btn', onclick: () => drawForm(true) }, icon('plus', 15), 'Neue Aufgabe')); return; }
const title = h('input', { type: 'text', required: true, maxlength: '200', placeholder: 'Was ist zu tun?' });
const due = h('input', { type: 'datetime-local' });
const ticket = h('input', { type: 'text', placeholder: 'Ticketnummer (optional)' });
const who = h('select', {}, h('option', { value: '' }, 'Niemand'), (ctx.taskStaff || []).map((s) => h('option', { value: s.id, selected: s.id === ctx.session.staff?.id ? '' : undefined }, s.name)));
form.replaceChildren(h('form', { class: 'sheet', onsubmit: async (ev) => {
ev.preventDefault();
try {
await apiCall('tasks.php', { method: 'POST', body: { action: 'create', title: title.value.trim(), dueAt: due.value ? due.value.replace('T', ' ') + ':00' : '', ticketNumber: ticket.value.trim(), assignedToId: who.value ? Number(who.value) : null } });
toast('Aufgabe angelegt'); drawForm(false); load();
} catch (e) { toast(errText(e), 'err'); }
} }, h('label', {}, 'Titel', title), h('div', { class: 'row2' }, h('label', {}, 'Fällig', due), h('label', {}, 'Für', who)), h('label', {}, 'Ticket', ticket),
h('div', { class: 'actions' }, h('button', { class: 'btn primary', type: 'submit' }, 'Anlegen'), h('button', { class: 'btn', type: 'button', onclick: () => drawForm(false) }, 'Abbrechen'))));
title.focus();
}
host.append(h('div', { class: 'stack' }, form, chips, list));
drawChips(); drawForm(false); load();
}
+152
View File
@@ -0,0 +1,152 @@
// Ticket-Ansicht: Verlauf lesen, antworten, interne Notiz, Status, Zuweisung, Wiedervorlage, Beobachten.
import { h, clear, icon, toast, errText } from '../lib/dom.js';
import { apiCall, apiBeacon, getMeta, openWeb, ticketPath, plainToHtml, timeAgo, fmtBytes, RESOLUTION_REASONS, STATUS_LABELS } from '../lib/core.js';
import { spinner, statusPill, prioPill, avatar } from '../lib/ui.js';
const pad = (n) => String(n).padStart(2, '0');
const sqlTime = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
/** Zeitpunkte für die Wiedervorlage (nur künftige). */
export function snoozeChoices(now = new Date()) {
const at = (days, hour) => { const d = new Date(now); d.setDate(d.getDate() + days); d.setHours(hour, 0, 0, 0); return d; };
const list = [['In 1 Stunde', new Date(now.getTime() + 3600 * 1000)], ['Heute 17:00', at(0, 17)], ['Morgen 08:00', at(1, 8)], ['In 3 Tagen', at(3, 8)], ['In einer Woche', at(7, 8)]];
return list.filter(([, d]) => d.getTime() > now.getTime() + 120 * 1000).map(([label, d]) => [label, sqlTime(d)]);
}
export async function renderTicket(host, ctx, { number }) {
host.append(spinner());
let t;
try { ({ data: { ticket: t } } = await apiCall('ticket.php', { query: { number } })); }
catch (e) {
clear(host);
host.append(h('div', { class: 'msg err', role: 'alert' }, errText(e)), h('div', { class: 'actions', style: { marginTop: '10px' } },
h('button', { class: 'btn small', onclick: () => openWeb(ticketPath(number)).catch((x) => toast(errText(x), 'err')) }, icon('external', 14), 'Im Browser öffnen')));
if (e.status === 401) ctx.logoutExpired();
return;
}
// Wer das Ticket geöffnet und dabei die Sperre erhalten hat, gibt sie beim Verlassen wieder frei.
if (!t.lockedBy && !t.lockedByOtherName) ctx.onLeave(() => apiBeacon(ctx.session, 'ticket_unlock.php', { number })); // läuft auch weiter, wenn sich das Popup schließt
const meta = await getMeta().catch(() => ({ agents: [], departments: [], priorities: [] }));
const reload = () => { clear(host); return renderTicket(host, ctx, { number }); };
const perms = t.permissions || {};
const me = ctx.session.staff?.id;
async function update(body, okText) {
try { await apiCall('ticket_update.php', { method: 'POST', body: { number, ...body } }); toast(okText); await reload(); }
catch (e) { toast(errText(e), 'err'); }
}
// ─ Kopf
const head = h('div', { class: 'card tdetail' },
h('h1', {}, t.subject),
h('div', { class: 'facts' }, statusPill(t.status), prioPill(t.priority), h('span', { class: 'pill' }, `#${t.number}`), t.isOverdue ? h('span', { class: 'pill', style: { background: 'var(--bad-bg)', color: 'var(--bad)' } }, t.dueLabel || 'überfällig') : (t.dueLabel ? h('span', { class: 'pill' }, t.dueLabel) : null),
t.snoozedUntil ? h('span', { class: 'pill' }, icon('clock', 12), `Wiedervorlage: ${timeAgo(t.snoozedUntil)}`) : null),
h('div', { class: 'muted', style: { marginTop: '8px' } },
`${t.requesterName}${t.requesterEmail ? ` <${t.requesterEmail}>` : ''}`, h('br'),
`${t.department?.name || ''} · ${t.assignedStaff ? `zugewiesen an ${t.assignedStaff.name}` : 'nicht zugewiesen'} · erstellt ${timeAgo(t.createdAt)}`));
const notices = [];
if (t.lockedBy) notices.push(h('div', { class: 'msg warn' }, `Wird gerade von ${t.lockedBy} bearbeitet (Admin-Zugriff).`));
if (t.lockedByOtherName) notices.push(h('div', { class: 'msg warn' }, `Dieser Chat wird gerade von ${t.lockedByOtherName} bearbeitet. Du kannst zuschauen.`));
if (t.isSpam) notices.push(h('div', { class: 'msg warn' }, 'Als Spam markiert.'));
// ─ Schnellaktionen
const actions = h('div', { class: 'actions' });
if (perms.assign_ticket && t.assignedStaff?.id !== me) actions.append(h('button', { class: 'btn small', onclick: () => update({ assignedStaffId: me }, 'Dir zugewiesen') }, icon('user', 14), 'Mir zuweisen'));
actions.append(h('button', { class: 'btn small', onclick: () => update({ following: !t.following }, t.following ? 'Beobachten beendet' : 'Du beobachtest das Ticket') }, icon('eye', 14), t.following ? 'Nicht mehr beobachten' : 'Beobachten'));
const snoozeSel = h('select', { 'aria-label': 'Wiedervorlage', onchange: (ev) => { const v = ev.target.value; if (!v) return; update({ snoozeUntil: v === 'clear' ? null : v }, v === 'clear' ? 'Wiedervorlage aufgehoben' : 'Wiedervorlage gesetzt'); } },
h('option', { value: '' }, 'Wiedervorlage …'), snoozeChoices().map(([label, v]) => h('option', { value: v }, label)), t.snoozedUntil ? h('option', { value: 'clear' }, 'Aufheben') : null);
actions.append(h('div', { style: { minWidth: '150px' } }, snoozeSel));
actions.append(h('button', { class: 'btn small', onclick: () => openWeb(ticketPath(number)).catch((e) => toast(errText(e), 'err')) }, icon('external', 14), 'Im Browser'));
// ─ Eigenschaften
const statusSel = h('select', { 'aria-label': 'Status' }, Object.entries(STATUS_LABELS).map(([v, l]) => h('option', { value: v, selected: v === t.status ? '' : undefined }, l)));
const reasonSel = h('select', { 'aria-label': 'Abschlussgrund' }, h('option', { value: '' }, 'Abschlussgrund wählen …'), Object.entries(RESOLUTION_REASONS).map(([v, l]) => h('option', { value: v }, l)));
const reasonRow = h('div', { hidden: true }, reasonSel);
const syncReason = () => { reasonRow.hidden = !['resolved', 'closed'].includes(statusSel.value); };
statusSel.addEventListener('change', syncReason);
const assignSel = h('select', { 'aria-label': 'Zuweisen' }, h('option', { value: '' }, 'Niemand'), (meta.agents || []).map((a) => h('option', { value: a.id, selected: a.id === t.assignedStaff?.id ? '' : undefined }, a.name)));
const props = h('div', { class: 'card' }, h('h2', {}, 'Eigenschaften'), h('div', { class: 'stack' },
h('div', { class: 'row2' }, h('label', {}, 'Status', statusSel), perms.assign_ticket ? h('label', {}, 'Zugewiesen an', assignSel) : null),
reasonRow,
h('div', { class: 'actions' }, h('button', { class: 'btn small primary', onclick: async () => {
const body = {};
if (statusSel.value !== t.status) { body.status = statusSel.value; if (['resolved', 'closed'].includes(statusSel.value)) { if (!reasonSel.value) return toast('Bitte einen Abschlussgrund wählen.', 'err'); body.resolutionCode = reasonSel.value; } }
const wanted = assignSel.value === '' ? null : Number(assignSel.value);
if (perms.assign_ticket && wanted !== (t.assignedStaff?.id ?? null)) body.assignedStaffId = wanted;
if (!Object.keys(body).length) return toast('Nichts geändert.');
update(body, 'Gespeichert');
} }, 'Speichern'))));
syncReason();
// ─ Verlauf
const thread = h('div', { class: 'stack' }, (t.threads || []).map((m) => {
const kind = m.isInternalNote ? 'note' : m.authorType === 'system' ? 'system' : m.authorType === 'staff' ? 'staff' : 'cust';
return h('div', { class: 'thread' }, h('div', { class: `bubble ${kind}` },
h('div', { class: 'who' }, kind === 'system' ? null : avatar(m.authorName, kind === 'cust'), h('span', {}, m.authorName), kind === 'note' ? h('span', { class: 'tag' }, 'Interne Notiz') : null, h('span', { class: 'when' }, timeAgo(m.createdAt))),
h('div', { class: 'text' }, m.message),
(m.attachments || []).length ? h('div', { class: 'files' }, m.attachments.map((a) => h('span', { class: 'file' }, icon('clip', 12), `${a.name} (${fmtBytes(a.size)})`))) : null,
(m.comments || []).map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff?.name || ''), ' ', h('span', { class: 'muted' }, timeAgo(c.createdAt)), h('div', {}, c.message)))));
}));
// ─ Antwort schreiben
const canReply = perms.reply_ticket !== false, canNote = perms.post_note !== false;
let mode = canReply ? 'reply' : 'note';
const text = h('textarea', { placeholder: 'Antwort schreiben … (Strg+Enter sendet)', 'aria-label': 'Nachricht' });
// Das Popup schließt sich beim Klick daneben: Der Entwurf bleibt deshalb erhalten, bis er gesendet wird.
const draftKey = `draft:${number}`;
// Der Entwurf merkt sich auch den Modus: Eine interne Notiz darf nach dem Wiederöffnen nicht als Antwort an den Kunden dastehen.
const savedRaw = (await chrome.storage.local.get(draftKey))[draftKey];
const saved = typeof savedRaw === 'string' ? { text: savedRaw, mode: 'note' } : savedRaw; // ältere Entwürfe ohne Modus: sicherheitshalber als Notiz
if (saved?.text) {
text.value = saved.text;
if (saved.mode === 'note' && canNote) mode = 'note'; else if (saved.mode === 'reply' && canReply) mode = 'reply';
}
const saveDraft = () => { if (text.value) chrome.storage.local.set({ [draftKey]: { text: text.value, mode } }); else chrome.storage.local.remove(draftKey); };
text.addEventListener('input', saveDraft);
const afterStatus = h('select', { 'aria-label': 'Status nach dem Senden' }, [['answered', 'Beantwortet'], ['pending', 'Wartet auf Kunde'], ['resolved', 'Gelöst'], ['closed', 'Geschlossen']].map(([v, l]) => h('option', { value: v }, l)));
const sendReason = h('select', { 'aria-label': 'Abschlussgrund beim Senden', hidden: true }, h('option', { value: '' }, 'Abschlussgrund wählen …'), Object.entries(RESOLUTION_REASONS).map(([v, l]) => h('option', { value: v }, l)));
afterStatus.addEventListener('change', () => { sendReason.hidden = !['resolved', 'closed'].includes(afterStatus.value); });
const sig = t.replySignatures || {};
const sigSel = (sig.own?.available || sig.department?.available)
? h('select', { 'aria-label': 'Signatur' }, h('option', { value: '' }, 'Signatur: Standard'), sig.own?.available ? h('option', { value: 'own' }, 'Meine Signatur') : null, sig.department?.available ? h('option', { value: 'dept' }, 'Abteilungs-Signatur') : null, h('option', { value: 'none' }, 'Ohne Signatur'))
: null;
let canned = null;
const cannedSel = h('select', { 'aria-label': 'Textbaustein', onfocus: async () => {
if (canned) return;
try { canned = (await apiCall('canned_responses.php', { query: { departmentId: t.department?.id } })).data.items || []; }
catch { canned = []; }
cannedSel.append(...canned.map((c, i) => h('option', { value: i }, c.title)));
}, onchange: () => { const c = canned?.[Number(cannedSel.value)]; if (c) { text.value = text.value ? `${text.value}\n${c.body}` : c.body; text.focus(); } cannedSel.value = ''; } }, h('option', { value: '' }, 'Textbaustein einfügen …'));
const modeSeg = h('div', { class: 'seg' });
const send = h('button', { class: 'btn primary', type: 'button' }, icon('send', 15), 'Senden');
const replyOpts = h('div', { class: 'stack' }, h('div', { class: 'row2' }, cannedSel, afterStatus), sigSel, sendReason);
function drawMode() {
modeSeg.replaceChildren(
canReply ? h('button', { type: 'button', class: mode === 'reply' ? 'on' : '', onclick: () => { mode = 'reply'; saveDraft(); drawMode(); } }, icon('send', 14), 'Antwort') : null,
canNote ? h('button', { type: 'button', class: mode === 'note' ? 'on' : '', onclick: () => { mode = 'note'; saveDraft(); drawMode(); } }, icon('note', 14), 'Interne Notiz') : null);
replyOpts.hidden = mode !== 'reply';
text.placeholder = mode === 'reply' ? 'Antwort an den Kunden … (Strg+Enter sendet)' : 'Interne Notiz, nur für Mitarbeiter … (Strg+Enter sendet)';
}
async function submit() {
const message = text.value.trim();
if (!message) return toast('Bitte eine Nachricht eingeben.', 'err');
const body = { number, type: mode, message: plainToHtml(message) };
if (mode === 'reply') {
body.setStatus = afterStatus.value;
if (sigSel?.value) body.signature = sigSel.value;
if (['resolved', 'closed'].includes(afterStatus.value)) { if (!sendReason.value) return toast('Bitte einen Abschlussgrund wählen.', 'err'); body.resolutionCode = sendReason.value; }
}
send.disabled = true;
try { await apiCall('ticket_message.php', { method: 'POST', body }); await chrome.storage.local.remove(draftKey); toast(mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); await reload(); }
catch (e) { toast(errText(e), 'err'); send.disabled = false; }
}
send.addEventListener('click', submit);
text.addEventListener('keydown', (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') { ev.preventDefault(); submit(); } });
drawMode();
const composer = (canReply || canNote)
? h('div', { class: 'composer' }, modeSeg, text, replyOpts, h('div', { class: 'actions', style: { justifyContent: 'flex-end' } }, send))
: h('div', { class: 'msg warn' }, 'Für dieses Ticket fehlt das Recht zum Antworten.');
clear(host).append(h('div', { class: 'stack' }, ...notices, head, actions, thread, composer, props));
thread.lastElementChild?.scrollIntoView({ block: 'start' }); // wie im Chat: die neueste Nachricht zuerst
}
+53
View File
@@ -0,0 +1,53 @@
// Ticketliste mit Filtern, Suche und „Mehr laden“.
import { h, clear, icon } from '../lib/dom.js';
import { apiCall } from '../lib/core.js';
import { spinner, ticketCard, showError } from '../lib/ui.js';
const OPEN = 'open,pending,answered,on_hold';
export const FILTERS = [
['mine', 'Meine', { assigned: 'me', status: OPEN }],
['unassigned', 'Nicht zugewiesen', { assigned: 'unassigned', status: OPEN }],
['overdue', 'Überfällig', { sla: 'overdue', status: OPEN }],
['open_status', 'Unbeantwortet', { status: 'open' }],
['following', 'Beobachtet', { following: 1, status: OPEN }],
['snoozed', 'Wiedervorlage', { snoozed: 1, status: OPEN }],
['all', 'Alle offenen', { status: OPEN }],
['done', 'Gelöst/Geschlossen', { status: 'resolved,closed' }],
];
const memory = { filter: 'mine', q: '' };
export async function renderTickets(host, ctx, params = {}) {
if (params.filter) { memory.filter = params.filter; memory.q = ''; }
if (params.q !== undefined) memory.q = params.q;
let page = 1;
const results = h('div');
const input = h('input', { type: 'search', placeholder: 'Betreff, Nummer, Name, Text …', 'aria-label': 'Suche', maxlength: '100', value: memory.q });
const chips = h('div', { class: 'chips', role: 'group' });
const drawChips = () => chips.replaceChildren(...FILTERS.map(([key, label]) => h('button', { class: `chip${!memory.q && memory.filter === key ? ' on' : ''}`, type: 'button', onclick: () => { memory.filter = key; memory.q = ''; input.value = ''; drawChips(); load(true); } }, label)));
async function load(reset) {
if (reset) { page = 1; clear(results).append(spinner()); }
const base = FILTERS.find((f) => f[0] === memory.filter)?.[2] || {};
const query = memory.q ? { q: memory.q, status: 'open,pending,answered,on_hold,resolved,closed' } : base;
try {
const { data } = await apiCall('tickets.php', { query: { ...query, page, perPage: 25 } });
if (reset) clear(results);
else results.querySelector('.more')?.remove();
if (reset) results.append(h('div', { class: 'muted', style: { margin: '2px 2px 8px' } }, `${data.total} Ticket${data.total === 1 ? '' : 's'}`));
const list = results.querySelector('ul.tickets') || results.appendChild(h('ul', { class: 'tickets' }));
if (!data.items.length && reset) { list.remove(); results.append(h('div', { class: 'empty' }, memory.q ? 'Keine Treffer.' : 'Keine Tickets in dieser Ansicht.')); }
for (const t of data.items) list.append(ticketCard(t, (x) => ctx.go('ticket', { number: x.number })));
if (data.hasMore) results.append(h('div', { class: 'more actions', style: { justifyContent: 'center', margin: '10px 0' } }, h('button', { class: 'btn', onclick: () => { page += 1; load(false); } }, 'Mehr laden')));
} catch (e) { showError(ctx, results, e); }
}
host.append(h('div', { class: 'stack' },
h('form', { onsubmit: (ev) => { ev.preventDefault(); const q = input.value.trim(); memory.q = q.length >= 2 ? q : ''; drawChips(); load(true); } },
h('div', { class: 'searchbox' }, icon('search', 16), input)),
chips, results));
drawChips();
chips.querySelector('.chip.on')?.scrollIntoView({ inline: 'center', block: 'nearest' });
load(true);
}