Upload via GUI (23 Dateien)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.')); }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user