Upload via GUI (74 Dateien)

This commit is contained in:
2026-11-04 07:58:10 +00:00
parent 8bf6bb92ba
commit 4520d435e0
6 changed files with 349 additions and 276 deletions
+49 -7
View File
@@ -40,6 +40,8 @@ async function api(path, opts = {}) {
e.code = r.error.code; e.status = r.error.status; e.offline = down; throw e;
}
if (state.offline) setOffline(false);
// Änderungen an Tickets (antworten, Status, Zuweisung): Zähler bald neu holen, damit er sofort stimmt
if (/^(ticket_message|ticket_update|ticket_spam|ticket_collaborators)\.php/.test(path) && opts.method === 'POST' && typeof pollSoon === 'function') pollSoon();
return r.data;
}
@@ -96,6 +98,16 @@ async function installUpdate() {
if (r.error) { state.update = { ...state.update, busy: false }; paintBanners(); toast(r.error.message, true); }
}
window.deskhub.onUpdateProgress((pct) => { if (state.update?.busy) { state.update.pct = pct; paintBanners(); } });
/** E-Mail-Abruf von Hand (wie der Knopf in der Ticketliste der Weboberfläche). */
async function fetchMailNow(btn) {
const old = btn.textContent;
btn.disabled = true; btn.textContent = tr('⏳ Abruf läuft …');
try {
const r = await post('mail_fetch.php', {});
toast(r.message, r.level === 'error');
} catch (e) { toast(e.message, true); }
finally { btn.disabled = false; btn.textContent = old; }
}
async function run(fn, okMsg) {
try { const r = await fn(); if (okMsg) toast(okMsg); return r; }
catch (e) { toast(e.message, true); return undefined; }
@@ -172,7 +184,7 @@ const metaKey = (url) => 'meta:' + url;
async function startApp(staff, cachedMeta, serverUrl) {
state.staff = staff;
checkUpdate(); clearInterval(state.updateTimer); state.updateTimer = setInterval(checkUpdate, 6 * 3600 * 1000);
state.since = ''; state.badge = 0;
state.since = ''; state.badge = 0; state.knownIds = null;
if (!state.prefs) state.prefs = await window.deskhub.settings();
applyTheme();
const fresh = (async () => {
@@ -212,6 +224,7 @@ function renderShell() {
const top = h('header', { class: 'topbar' },
h('div', { class: 'brand' }, h('span', { class: 'logo' }, 'D'), 'DeskHub'),
can('create_ticket') ? h('button', { class: 'btn ok', onclick: () => go('newticket') }, 'Neues Ticket') : null,
h('button', { class: 'btn ghost sm', title: tr('Ruft neue E-Mails aus den Postfächern ab'), onclick: (e) => fetchMailNow(e.currentTarget) }, tr('📥 E-Mails abrufen')),
h('button', { class: 'searchbtn', onclick: openPalette }, h('span', { class: 'nl' }, icon('search'), 'Ticket, Kunde oder Bereich suchen…'), h('kbd', {}, 'Strg K')),
h('div', { class: 'spacer' }),
window.I18N.switcher(),
@@ -244,15 +257,16 @@ function go(page, arg) {
main.scrollTop = 0;
const view = VIEWS_BY_PAGE()[page];
if (!view) return;
Promise.resolve(view(main, arg)).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message)));
Promise.resolve(view(main, arg)).then(() => { if (page === 'ticket') pollSoon(); }).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message)));
}
window.addEventListener('focus', () => { if (state.staff) pollActivity(); }); // beim Zurückkehren zum Fenster sofort den aktuellen Stand holen
function every(ms, fn) { const id = setInterval(fn, ms); state.timers.push({ id, page: true }); }
async function doLogout() {
clearTimeout(state.pollTimer);
await window.deskhub.logout();
state.timers.forEach((t) => clearInterval(t.id ?? t)); state.timers = []; state.staff = null;
window.deskhub.badge(0, null);
window.deskhub.badge(0, null); state.knownIds = null;
showLogin();
}
@@ -270,7 +284,8 @@ function paintBadge() {
if (b) { b.textContent = state.badge; b.classList.toggle('hidden', !state.badge); }
window.deskhub.badge(state.badge, state.badge ? badgeImage(state.badge) : null);
}
function resetBadge() { state.badge = 0; paintBadge(); }
// Der Zähler folgt dem Stand auf dem Server (siehe pollActivity) und wird nicht mehr beim Öffnen der Ticketliste geleert; nach einer Aktion fragen wir bald neu ab
function pollSoon() { if (state.staff) schedulePoll(1200); }
function schedulePoll(delay) {
clearTimeout(state.pollTimer);
@@ -278,10 +293,28 @@ function schedulePoll(delay) {
state.pollTimer = setTimeout(async () => { await pollActivity(); if (state.staff) schedulePoll(seconds * 1000); }, delay ?? seconds * 1000);
}
/**
* Der Zähler zeigt, wie viele Tickets jetzt Aufmerksamkeit brauchen (offen, dir zugewiesen oder noch niemandem, seit der letzten
* Kundennachricht nicht geöffnet): Er sinkt von selbst, wenn ein Ticket geöffnet, beantwortet oder übernommen wird, auch anderswo
* (Weboberfläche, Chrome-Erweiterung). Benachrichtigt wird nur für Tickets, die neu dazukommen. Ältere Server ohne „attention“ liefern nur
* „seit dem letzten Mal“: Dafür bleibt die frühere, aufsummierende Zählung.
*/
async function pollActivity() {
try {
const a = await api('activity.php', { query: { since: state.since } });
if (state.since && (a.newReplies || a.newUnassigned)) {
if (a.attention && Array.isArray(a.attention.unassigned) && Array.isArray(a.attention.mine)) {
const known = state.knownIds;
const freshU = known ? a.attention.unassigned.filter((id) => !known.has(id)).length : 0;
const freshM = known ? a.attention.mine.filter((id) => !known.has(id)).length : 0;
state.knownIds = new Set([...a.attention.unassigned, ...a.attention.mine]);
if (state.badge !== state.knownIds.size) { state.badge = state.knownIds.size; paintBadge(); }
if ((freshU || freshM) && state.prefs?.notifications !== false) {
const parts = [];
if (freshM) parts.push(`${freshM} Aktualisierung(en) an deinen Tickets`);
if (freshU) parts.push(`${freshU} neue(s) nicht zugewiesene(s) Ticket(s)`);
window.deskhub.notify('DeskHub', parts.map((p) => tr(p)).join(', '));
}
} else if (state.since && (a.newReplies || a.newUnassigned)) {
state.badge += a.newReplies + a.newUnassigned;
if (state.prefs?.notifications !== false) {
const parts = [];
@@ -346,7 +379,7 @@ const VIEWS = {
overdue: ['Überfällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'overdue' }],
atrisk: ['Bald fällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'at_risk' }],
paused: ['Pausiert', { status: 'on_hold', assigned: '', sla: '' }],
snoozed: ['💤 Wiedervorlage', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '', snoozed: '1' }],
snoozed: ['Wiedervorlage', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '', snoozed: '1' }],
following: ['👁 Beobachtet', { status: 'open,pending,answered,on_hold', assigned: '', sla: '', following: '1' }],
closed: ['Erledigt', { status: 'resolved,closed', assigned: '', sla: '' }],
all: ['Alle', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '' }],
@@ -430,7 +463,7 @@ async function vTickets(main, preset) {
tabs, viewsBar, h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => { f.q = search.value; f.page = 1; load(); } }, 'Suchen'),
sel('departmentId', [['', 'Alle Abteilungen'], ...state.meta.departments.map((d) => [d.id, d.name])]), sel('priorityId', [['', 'Alle Prioritäten'], ...state.meta.priorities.map((p) => [p.id, p.name])])),
bulk, list);
paintTabs(); paintViews(); resetBadge();
paintTabs(); paintViews();
await load();
every(30000, () => { if (!selected.size && !document.querySelector('.modal-bg')) load(true); });
}
@@ -569,6 +602,14 @@ async function vTicket(main, number) {
}));
const spamBtn = can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => run(async () => { await post('ticket_spam.php', { number, action: t.isSpam ? 'unspam' : 'spam' }); reload(); }, t.isSpam ? 'Kein Spam' : 'Als Spam markiert') }, t.isSpam ? 'Kein Spam' : 'Spam') : null;
// Anhänge, Bilder und Formatierung der Original-E-Mail aus dem Postfach nachladen (nur bei E-Mail-Tickets)
const reloadMailBtn = t.source === 'email' && can('reply_ticket') ? h('button', { class: 'btn ghost sm', title: tr('Holt Anhänge, Bilder und die Formatierung der E-Mail aus dem Postfach, soweit sie beim Abruf nicht übernommen wurden'), onclick: () => run(async () => {
const r = await post('ticket_mail_reload.php', { number });
const n = (r.added || 0) + (r.inlineImages || 0);
toast(n > 0 || r.formatting ? tr('Anhänge und Bilder aus der E-Mail nachgeladen.') : tr('Es gab nichts Neues zu laden.'));
if (r.failed?.length) toast(r.failed.join(' | '), true);
reload();
}) }, tr('📎 Anhänge laden')) : null;
const others = h('div', { class: 'muted' }, 'Lade…');
if (t.requesterEmail) api('customer.php', { query: { email: t.requesterEmail } }).then((r) => {
const rest = r.tickets.filter((x) => x.number !== number).slice(0, 6);
@@ -586,6 +627,7 @@ async function vTicket(main, number) {
h('button', { class: 'btn ghost sm', onclick: () => update({ following: !t.following }, t.following ? 'Du beobachtest das Ticket nicht mehr' : 'Du beobachtest das Ticket jetzt') }, t.following ? 'Nicht mehr beobachten' : '👁 Beobachten'),
h('button', { class: 'btn ghost sm', title: 'Im Browser öffnen: zusammenführen, aufteilen, drucken', onclick: () => openWebPage('/staff/ticket_view.php?n=' + encodeURIComponent(t.number)) }, '🌐 Im Browser'),
spamBtn,
reloadMailBtn,
h('button', { class: 'btn ghost sm', title: 'Ticketnummer kopieren', onclick: () => { navigator.clipboard?.writeText(t.number); toast('Nummer kopiert'); } }, 'Nummer kopieren'),
h('button', { class: 'btn ghost sm', onclick: reload }, 'Aktualisieren'));
const st = statusInfo(t.status);