Upload via GUI (74 Dateien)

This commit is contained in:
2026-10-26 21:20:34 +00:00
parent 5ffdb4fc1b
commit 7dac91fa35
6 changed files with 59 additions and 13 deletions
+21 -5
View File
@@ -1,4 +1,6 @@
'use strict';
// Mac und Linux: normaler Fensterrahmen statt eigener Titelleiste (nur Windows hat sie)
if (!/Windows/i.test(navigator.userAgent)) document.documentElement.classList.add('native-frame');
// 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)); };
@@ -393,7 +395,13 @@ async function vTickets(main, preset) {
const paintBulk = () => {
bulk.classList.toggle('hidden', selected.size === 0); clear(bulk);
if (!selected.size) return;
const bulkResolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']];
const act = async (patch, label) => {
if (['resolved', 'closed'].includes(patch.status)) {
const code = prompt('Abschlussgrund: ' + bulkResolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed');
if (!bulkResolutionOptions.some(([v]) => v === code)) return;
patch = { ...patch, resolutionCode: code, resolutionNote: prompt('Lösungsnotiz (optional):', '') || '' };
}
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);
@@ -463,11 +471,13 @@ async function vTicket(main, number) {
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 resolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']];
const closeWithReason = (status) => { const code = prompt('Abschlussgrund: ' + resolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed'); if (!resolutionOptions.some(([v]) => v === code)) return; const note = prompt('Lösungsnotiz (optional):', '') || ''; update({ status, resolutionCode: code, resolutionNote: note }, status === 'resolved' ? 'Gelöst' : 'Geschlossen'); };
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 statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => ['resolved', 'closed'].includes(v) ? closeWithReason(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
@@ -478,6 +488,8 @@ async function vTicket(main, number) {
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])], '', () => {});
const sendResolution = mkSelect([['', 'Abschlussgrund wählen'], ...resolutionOptions], '', () => {});
const resolutionNote = h('input', { placeholder: 'Lösungsnotiz (optional)' });
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…'));
// Nur Textbausteine der Abteilung des Tickets und die für alle Abteilungen (ältere Server liefern alle: dann hier filtern)
@@ -524,16 +536,20 @@ async function vTicket(main, number) {
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');
sendResolution.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value));
resolutionNote.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value));
sigBox?.classList.toggle('hidden', mode !== 'reply');
sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern';
ta.classList.toggle('noteedit', mode === 'note');
};
paintTabs(); loadDraft();
sendStatus.addEventListener('change', () => paintTabs());
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, signature: mode === 'reply' && sigs ? sigChoice : undefined }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert');
if (mode === 'reply' && ['resolved', 'closed'].includes(sendStatus.value) && !resolutionOptions.some(([v]) => v === sendResolution.value)) { toast('Bitte Abschlussgrund auswählen.', true); sendBtn.disabled = false; return; }
const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined, resolutionCode: sendResolution.value || null, resolutionNote: resolutionNote.value }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert');
sendBtn.disabled = false;
if (ok) { store.del(draftKey(mode)); reload(); }
};
@@ -561,8 +577,8 @@ async function vTicket(main, number) {
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('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => closeWithReason('resolved') }, 'Lösen') : null,
!closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => closeWithReason('closed') }, 'Schließen') : null,
closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null,
t.snoozedUntil
? h('button', { class: 'btn ghost sm', onclick: () => update({ snoozeUntil: null }, 'Wiedervorlage aufgehoben') }, 'Wiedervorlage aufheben')
@@ -609,7 +625,7 @@ async function vTicket(main, number) {
t.canUnlock ? h('button', { class: 'btn sm', onclick: () => run(async () => { await post('ticket_unlock.php', { number }); reload(); }, 'Sperre aufgehoben') }, 'Sperre aufheben und übernehmen') : null) : null,
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, sigBox, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null),
h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, sendResolution, resolutionNote, h('span', { class: 'grow' }), sendBtn)) : null),
h('div', {},
h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'),
h('dl', { class: 'props' },