diff --git a/desktop/installer/obj/Debug/net48/DeskHubSetup.assets.cache b/desktop/installer/obj/Debug/net48/DeskHubSetup.assets.cache index db1b6b85..3bf3b5fc 100644 Binary files a/desktop/installer/obj/Debug/net48/DeskHubSetup.assets.cache and b/desktop/installer/obj/Debug/net48/DeskHubSetup.assets.cache differ diff --git a/desktop/installer/obj/DeskHubSetup.csproj.nuget.dgspec.json b/desktop/installer/obj/DeskHubSetup.csproj.nuget.dgspec.json index 1d962f4e..0124db32 100644 --- a/desktop/installer/obj/DeskHubSetup.csproj.nuget.dgspec.json +++ b/desktop/installer/obj/DeskHubSetup.csproj.nuget.dgspec.json @@ -5,7 +5,7 @@ }, "projects": { "C:\\Users\\Viper\\Desktop\\deskhub\\desktop\\installer\\DeskHubSetup.csproj": { - "version": "2.7.0", + "version": "2.9.1", "restore": { "projectUniqueName": "C:\\Users\\Viper\\Desktop\\deskhub\\desktop\\installer\\DeskHubSetup.csproj", "projectName": "DeskHub-Setup", diff --git a/desktop/installer/obj/Release/net48/DeskHub-Setup.exe b/desktop/installer/obj/Release/net48/DeskHub-Setup.exe index df950b33..f3d22e23 100644 Binary files a/desktop/installer/obj/Release/net48/DeskHub-Setup.exe and b/desktop/installer/obj/Release/net48/DeskHub-Setup.exe differ diff --git a/desktop/installer/obj/project.assets.json b/desktop/installer/obj/project.assets.json index bd709539..4eb87182 100644 --- a/desktop/installer/obj/project.assets.json +++ b/desktop/installer/obj/project.assets.json @@ -418,7 +418,7 @@ "C:\\Users\\Viper\\.nuget\\packages\\": {} }, "project": { - "version": "2.7.0", + "version": "2.9.1", "restore": { "projectUniqueName": "C:\\Users\\Viper\\Desktop\\deskhub\\desktop\\installer\\DeskHubSetup.csproj", "projectName": "DeskHub-Setup", diff --git a/desktop/installer/obj/project.nuget.cache b/desktop/installer/obj/project.nuget.cache index fee82798..d6fbb576 100644 --- a/desktop/installer/obj/project.nuget.cache +++ b/desktop/installer/obj/project.nuget.cache @@ -1,6 +1,6 @@ { "version": 2, - "dgSpecHash": "3YPOu5cr0tQ=", + "dgSpecHash": "QAGLIFBHTUQ=", "success": true, "projectFilePath": "C:\\Users\\Viper\\Desktop\\deskhub\\desktop\\installer\\DeskHubSetup.csproj", "expectedPackageFiles": [ diff --git a/desktop/package.json b/desktop/package.json index 6a2ab3b8..da804ae6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "deskhub", "productName": "DeskHub", - "version": "2.7.0", + "version": "2.9.1", "description": "DeskHub Desktop-Client für Mitarbeiter", "main": "src/main.js", "author": "M_Viper", diff --git a/desktop/src/admin-extra.js b/desktop/src/admin-extra.js index 4a90359c..9da280bb 100644 --- a/desktop/src/admin-extra.js +++ b/desktop/src/admin-extra.js @@ -26,7 +26,7 @@ async function adminTeams(body, addBtn) { clear(body).append(table(['Name', 'Leitung', 'Mitglieder', 'Aktiv', 'Aktualisiert', ''], r.items.map((t) => h('tr', { class: 'click', onclick: () => edit(t, r) }, h('td', {}, t.name), h('td', {}, t.leadName || '—'), h('td', {}, t.memberCount), h('td', {}, h('input', { type: 'checkbox', checked: t.isActive, onclick: (e) => e.stopPropagation(), onchange: () => run(async () => { await post('teams.php', { action: 'toggle', id: t.id }); load(); }) })), - h('td', { class: 'muted' }, t.updatedAt ? new Date(t.updatedAt.replace(' ', 'T')).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''), + h('td', { class: 'muted' }, t.updatedAt ? new Date(t.updatedAt.replace(' ', 'T')).toLocaleString(window.I18N?.locale || 'de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''), h('td', {}, delBtn(() => run(async () => { await post('teams.php', { action: 'delete', id: t.id }); load(); }, 'Gelöscht'))))))); addBtn.onclick = () => edit(null, r); }; diff --git a/desktop/src/admin-status.js b/desktop/src/admin-status.js new file mode 100644 index 00000000..390e82e8 --- /dev/null +++ b/desktop/src/admin-status.js @@ -0,0 +1,55 @@ +'use strict'; +// Verwaltungsbereich „Statusseite“: öffentliche Seite für Kunden mit dem Zustand der Dienste und Störungsmeldungen. +// Gleicher Funktionsumfang wie in der Android-App; die Texte der Seite selbst stehen unter /status.php. + +async function adminStatusPage(body) { + const A = 'status.php'; + const COLORS = { operational: '#157a45', maintenance: '#2563eb', degraded: '#b45309', outage: '#dc2626' }; + let st = null; + const act = async (payload, ok) => { const r = await run(() => post(A, payload), ok); if (r) { st = r.status; paint(); } return r; }; + + function paint() { + const s = st; + const comps = h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Dienste'), + s.components.length ? h('table', {}, ...s.components.map((c) => h('tr', {}, + h('td', {}, h('b', {}, c.name), c.description ? h('div', { class: 'muted' }, c.description) : null), + h('td', {}, h('select', { onchange: (e) => act({ action: 'set_component', id: c.id, status: e.target.value }, 'Zustand geändert') }, + ...s.componentStates.map((o) => h('option', { value: o.key, selected: o.key === c.status }, o.label))))))) : h('div', { class: 'muted' }, 'Noch keine Dienste eingetragen.')); + + const title = h('input', { placeholder: 'z. B. E-Mail-Versand verzögert' }), text = h('textarea', { placeholder: 'Was ist passiert? Was tun wir dagegen?' }); + const level = h('select', {}, ...s.incidentStates.map((o) => h('option', { value: o.key }, o.label))); + const comp = h('select', {}, h('option', { value: '' }, '– kein bestimmter Dienst –'), ...s.components.map((c) => h('option', { value: c.id }, c.name))); + const compState = h('select', {}, h('option', { value: '' }, '– nicht ändern –'), ...s.componentStates.map((o) => h('option', { value: o.key }, o.label))); + const create = h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Neue Meldung (Störung oder Wartung)'), + h('label', {}, 'Titel'), title, h('label', {}, 'Beschreibung'), text, h('label', {}, 'Stufe'), level, h('label', {}, 'Betrifft'), comp, h('label', {}, 'Dienst setzen auf'), compState, + h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => act({ action: 'create_incident', title: title.value, body: text.value, status: level.value, componentId: comp.value ? +comp.value : null, componentState: compState.value || null }, 'Meldung veröffentlicht') }, 'Veröffentlichen'))); + + const incidents = h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Meldungen (letzte 14 Tage)'), + s.incidents.length ? null : h('div', { class: 'muted' }, 'Keine Meldungen.'), + ...s.incidents.map((i) => { + const upd = h('input', { placeholder: 'Update für die Kunden' }); + const lv = h('select', {}, ...s.incidentStates.map((o) => h('option', { value: o.key, selected: o.key === i.status }, o.label))); + return h('div', { class: 'incident' }, + h('div', {}, h('b', {}, i.title), ' ', h('span', { class: 'chip' }, i.statusLabel), i.component ? h('span', { class: 'muted' }, ' · ' + i.component) : null, h('span', { class: 'muted' }, ' · ' + fmtDate(i.createdAt))), + ...i.updates.map((u) => h('div', { class: 'muted' }, `${u.statusLabel}${u.body ? ': ' + u.body : ''}`)), + h('div', { class: 'row top' }, i.status === 'resolved' ? null : [lv, upd, h('button', { class: 'btn sm', onclick: () => act({ action: 'update_incident', id: i.id, status: lv.value, body: upd.value }, 'Update veröffentlicht') }, 'Update senden')], + h('button', { class: 'btn danger sm', onclick: () => confirm('Meldung löschen?') && act({ action: 'delete_incident', id: i.id }, 'Gelöscht') }, '✕'))); + })); + + clear(view).append( + h('div', { class: 'panel' }, h('div', { class: 'row' }, + h('span', { class: 'pill', color: s.overall.color }, s.overall.label), + h('label', { class: 'check grow' }, h('input', { type: 'checkbox', checked: s.enabled, onchange: (e) => act({ action: 'set_enabled', enabled: e.target.checked }, e.target.checked ? 'Statusseite eingeschaltet' : 'Statusseite ausgeschaltet') }), 'Statusseite für Kunden anzeigen')), + s.enabled ? h('div', { class: 'muted' }, 'Adresse für Kunden: ' + s.url) : null), + comps, create, incidents); + } + + const view = h('div'); + body.append(view); + const r = await run(() => api(A)); if (!r) return; + st = r.status; paint(); +} + +ADMIN.push({ key: 'statuspage', label: 'Statusseite', admin: true, custom: adminStatusPage }); +ADMIN_GROUPS.find((g) => g[0] === 'System')[1].push('statuspage'); +ADMIN_DESC.statuspage = 'Dienste und Störungsmeldungen für Kunden'; diff --git a/desktop/src/app.js b/desktop/src/app.js index 45a3935a..203cbe19 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -74,7 +74,7 @@ async function run(fn, okMsg) { try { const r = await fn(); if (okMsg) toast(okMsg); return r; } catch (e) { toast(e.message, true); return undefined; } } -const fmtDate = (s) => s ? new Date(s.replace(' ', 'T')).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; +const fmtDate = (s) => s ? new Date(s.replace(' ', 'T')).toLocaleString(window.I18N?.locale || 'de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; const textToHtml = (t) => '
' + t.replace(/&/g, '&').replace(//g, '>').split(/\n{2,}/).map((p) => p.replace(/\n/g, '
')).join('
') + '
'; const statusInfo = (v) => state.meta?.statuses.find((s) => s.value === v) || { label: v, color: '#64748b' }; @@ -111,7 +111,7 @@ async function showLogin(prefillUrl) { state.serverUrl = (await window.deskhub.session())?.serverUrl || ''; await startApp(r.staff, null, state.serverUrl); }); - $app.append(h('div', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mitarbeiter-Anmeldung'), form))); + $app.append(h('div', { class: 'login' }, h('div', { class: 'card' }, h('div', { class: 'login-lang' }, window.I18N.switcher()), h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mitarbeiter-Anmeldung'), form))); (url.value ? email : url).focus(); } @@ -187,6 +187,7 @@ function renderShell() { can('create_ticket') ? h('button', { class: 'btn ok', onclick: () => go('newticket') }, 'Neues Ticket') : null, 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(), h('div', { class: 'who' }, h('div', { class: 'avatar' }, initials(state.staff.name)), h('div', {}, h('div', { class: 'name' }, state.staff.name), h('div', { class: 'role' }, state.staff.role === 'admin' ? 'Administrator' : 'Mitarbeiter'))), h('button', { class: 'btn ghost sm', onclick: doLogout }, 'Abmelden')); @@ -259,7 +260,7 @@ async function pollActivity() { const parts = []; if (a.newReplies) parts.push(`${a.newReplies} Aktualisierung(en) an deinen Tickets`); if (a.newUnassigned) parts.push(`${a.newUnassigned} neue(s) nicht zugewiesene(s) Ticket(s)`); - window.deskhub.notify('DeskHub', parts.join(', ')); + window.deskhub.notify('DeskHub', parts.map((p) => tr(p)).join(', ')); } paintBadge(); } @@ -288,7 +289,7 @@ async function vDashboard(main) { const body = h('div'); const hour = new Date().getHours(), greet = hour < 11 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend'; main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, `${greet}, ${state.staff.name.split(' ')[0]}`), - h('div', { class: 'sub' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }))), + h('div', { class: 'sub' }, new Date().toLocaleDateString(window.I18N?.locale || 'de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }))), h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), body); const load = async () => { const d = await run(() => api('dashboard.php')); if (!d) return; @@ -521,7 +522,7 @@ async function vTicket(main, number) { h('div', { class: 'head' }, h('span', { class: 'who' }, h('span', { class: 'avatar sm' }, initials(m.authorName)), h('b', {}, m.authorName), h('span', { class: 'kind' }, m.isInternalNote ? 'Interne Notiz' : cust ? 'Kunde' : 'Antwort')), h('span', {}, fmtDate(m.createdAt))), h('div', { class: 'body' }, m.message), - m.attachments.length ? h('div', { class: 'muted att' }, '📎 ' + m.attachments.map((a) => `${a.name} (${Math.round(a.size / 1024)} KB)`).join(', ') + ' – Download nur im Web') : null, + m.attachments.length ? h('div', { class: 'muted att' }, '📎 ' + m.attachments.map((a) => `${a.name} (${Math.round(a.size / 1024)} KB)`).join(', '), ' ', tr('– Download nur im Web')) : null, ...m.comments.map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff.name + ': '), c.message)), cust && can('reply_ticket') ? h('div', { class: 'tools' }, h('button', { class: 'btn ghost sm', onclick: () => { mode = 'reply'; paintTabs(); loadDraft(); quote(m); } }, 'Zitieren')) : null); })); @@ -549,6 +550,27 @@ async function vTicket(main, number) { const st = statusInfo(t.status); const prop = (label, ...kids) => [h('dt', {}, label), h('dd', {}, ...kids)]; + // Zufriedenheit des Kunden (Bewertung nach dem Schließen) und Cc-Empfänger / Beobachter + const csatPanel = t.csat ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kundenzufriedenheit'), + t.csat.rating != null + ? h('div', {}, h('div', { class: 'stars' }, '★'.repeat(t.csat.rating) + '☆'.repeat(5 - t.csat.rating), ' ', h('span', { class: 'muted' }, `${t.csat.rating} von 5`)), + t.csat.comment ? h('div', { class: 'csat-comment' }, `„${t.csat.comment}“`) : null, h('div', { class: 'muted' }, 'Bewertet am ' + fmtDate(t.csat.answeredAt))) + : h('div', { class: 'muted' }, `Abfrage am ${fmtDate(t.csat.sentAt)} gesendet, noch keine Antwort.`)) : null; + const collabBox = h('div'); + const paintCollab = (list) => clear(collabBox).append(...(list.length ? list.map((c) => h('div', { class: 'mini row' }, + h('span', { class: 'grow' }, c.name ? `${c.name} <${c.email}>` : c.email), + can('reply_ticket') ? h('button', { class: 'btn ghost sm', title: 'Entfernen', onclick: () => run(async () => { paintCollab((await post('ticket_collaborators.php', { number, action: 'remove', id: c.id })).collaborators); }) }, '✕') : null)) + : [h('div', { class: 'muted' }, 'Niemand. Cc-Empfänger bekommen die öffentlichen Antworten per E-Mail in Kopie.')])); + paintCollab(t.collaborators || []); + const ccEmail = h('input', { type: 'email', placeholder: 'Cc-Adresse hinzufügen' }); + const addCc = () => { const email = ccEmail.value.trim(); if (!email) return; run(async () => { paintCollab((await post('ticket_collaborators.php', { number, action: 'add', email })).collaborators); ccEmail.value = ''; }, 'Cc hinzugefügt'); }; + ccEmail.addEventListener('keydown', (e) => { if (e.key === 'Enter') addCc(); }); + const peoplePanel = h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Beobachter'), + (t.followers || []).length ? h('div', {}, (t.followers || []).map((f) => f.name).join(', ')) : h('div', { class: 'muted' }, 'Niemand. Beobachter bekommen Benachrichtigungen, ohne dass ihnen das Ticket zugewiesen ist.'), + h('div', { class: 'row top' }, 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('div', { class: 'sect' }, 'Cc-Empfänger'), collabBox, + can('reply_ticket') ? h('div', { class: 'row top' }, ccEmail, h('button', { class: 'btn sm', onclick: addCc }, 'Hinzufügen')) : null); + main.append( h('div', { class: 'tkhead' }, h('div', { class: 'tkhead-top' }, h('button', { class: 'btn ghost sm', onclick: () => go('tickets') }, 'Zurück zu Tickets'), h('span', { class: 'chip' }, '#' + t.number), @@ -579,6 +601,7 @@ async function vTicket(main, number) { (t.similar && (t.similar.articles?.length || t.similar.tickets?.length)) ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Dazu passend'), ...(t.similar.articles || []).map((a) => h('div', { class: 'mini' }, h('span', {}, '📘 ' + a.title), ' ', h('button', { class: 'btn ghost sm', onclick: () => { navigator.clipboard?.writeText((state.serverUrl || '') + a.url); toast('Link kopiert'); } }, 'Link kopieren'))), ...(t.similar.tickets || []).map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, x.number + ' · ' + x.subject))) : null, + csatPanel, peoplePanel, h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Weitere Tickets des Kunden'), others)))); // Neue Nachrichten von Kunden/Kollegen erkennen, ohne den Entwurf zu verlieren @@ -599,8 +622,18 @@ async function vNewTicket(main) { prio: h('select', {}, ...m.priorities.map((p) => h('option', { value: p.id }, p.name))), agent: h('select', {}, h('option', { value: '' }, '— niemand —'), ...m.agents.map((a) => h('option', { value: a.id }, a.name))), notify: h('input', { type: 'checkbox', checked: true }) }; + // Ticket-Vorlagen: füllen Betreff, Text, Abteilung und Priorität vor (gepflegt in der Weboberfläche unter Ticket-Vorlagen) + let tpls = []; + const tplSel = h('select', { onchange: () => { + const t = tpls.find((x) => String(x.id) === tplSel.value); if (!t) return; + f.subject.value = t.subject; f.message.value = t.body; + if (t.departmentId && m.departments.some((d) => d.id === t.departmentId)) f.dept.value = t.departmentId; + if (t.priorityId && m.priorities.some((p) => p.id === t.priorityId)) f.prio.value = t.priorityId; + } }, h('option', { value: '' }, '— ohne Vorlage —')); + const tplBox = h('div', { class: 'hidden' }, h('label', {}, 'Vorlage'), tplSel); + api('ticket_templates.php').then((r) => { tpls = r.templates || []; if (!tpls.length) return; tplSel.append(...tpls.map((x) => h('option', { value: x.id }, x.name))); tplBox.classList.remove('hidden'); }).catch(() => {}); main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Tickets'), h('h2', {}, 'Neues Ticket'), - h('div', { class: 'panel' }, + h('div', { class: 'panel' }, tplBox, h('label', {}, 'Name des Kunden'), f.name, h('label', {}, 'E-Mail des Kunden (optional)'), f.email, h('label', {}, 'Betreff'), f.subject, h('label', {}, 'Beschreibung'), f.message, h('label', {}, 'Abteilung'), f.dept, h('label', {}, 'Priorität'), f.prio, h('label', {}, 'Zuweisen an'), f.agent, h('label', { class: 'check' }, f.notify, 'Kunden per E-Mail benachrichtigen'), @@ -727,10 +760,23 @@ async function vReports(main) { h('div', { class: 'checks wide' }, ...metrics), h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run_() }, 'Bericht erstellen'), h('button', { class: 'btn ghost', onclick: () => { const name = h('input', { placeholder: 'z. B. Monatsbericht Support' }), shared = h('input', { type: 'checkbox' }); - modal('Bericht speichern', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('report_builder.php', { action: 'save', name: name.value, isShared: shared.checked, config: { ...cfg } }), 'Bericht gespeichert')) { close(); run_(); } } }]); } }, 'Bericht speichern'), - h('span', { class: 'muted' }, 'CSV-Export: in der Weboberfläche unter Bericht-Baukasten.'))); + modal('Bericht speichern', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('report_builder.php', { action: 'save', name: name.value, isShared: shared.checked, config: { ...cfg } }), 'Bericht gespeichert')) { close(); run_(); } } }]); } }, 'Bericht speichern'))); }; - main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Berichte'), h('div', { class: 'sub' }, 'Frei konfigurierbare Auswertungen nach Erstellungsdatum der Tickets'))), controls, savedBar, out); + // Kundenzufriedenheit (Bewertungen der letzten 30 Tage), nur wenn die Abfrage eingeschaltet ist oder es schon Antworten gibt + const csatBox = h('div'); + api('csat.php', { query: { days: 30 } }).then((r) => { + const s = r.stats; if (!(r.enabled || s.sent > 0)) return; + const max = Math.max(1, ...s.dist.map((d) => d.count)); + clear(csatBox).append(h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kundenzufriedenheit (letzte 30 Tage)'), + s.sent ? h('div', {}, + h('div', { class: 'row' }, h('span', { class: 'stars big' }, s.avg != null ? String(s.avg).replace('.', ',') + ' ★' : '–'), h('span', { class: 'muted' }, `Ø Bewertung · ${s.answered} Antworten von ${s.sent} Abfragen` + (s.rate != null ? ` (${String(s.rate).replace('.', ',')} %)` : ''))), + h('table', {}, ...[...s.dist].reverse().map((d) => h('tr', {}, h('td', { class: 'stars' }, '★'.repeat(d.rating)), h('td', {}, h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round(d.count * 100 / max) }))), h('td', {}, d.count)))), + s.byStaff.length ? h('div', { class: 'muted pad' }, 'Nach Mitarbeiter: ' + s.byStaff.map((x) => `${x.name} ${String(x.avg).replace('.', ',')} ★ (${x.count})`).join(' · ')) : null, + ...s.recent.slice(0, 5).map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, h('span', { class: 'stars' }, '★'.repeat(x.rating)), ` #${x.number} ${x.subject}`, x.comment ? h('div', { class: 'muted' }, `„${x.comment}“`) : null))) + : h('div', { class: 'muted' }, 'Noch keine Abfragen verschickt.'))); + for (const f of csatBox.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); + }).catch(() => {}); + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Berichte'), h('div', { class: 'sub' }, 'Frei konfigurierbare Auswertungen nach Erstellungsdatum der Tickets'))), csatBox, controls, savedBar, out); await run_(); } @@ -817,17 +863,8 @@ async function vAdmin(main, sectionKey) { return tiles.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, title), h('div', { class: 'tiles' }, ...tiles)) : null; }); const rest = [...entries.keys()].filter((k) => !used.has(k)).map((k) => h('div', { class: 'tile', onclick: entries.get(k).open }, h('div', { class: 'tt' }, entries.get(k).label))); - const webBox = h('div'); - main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Verwaltung'), h('div', { class: 'sub' }, 'Nur Bereiche, für die du freigeschaltet bist. Weitere Seiten (Einstellungen, Assistent, Backup …) öffnen sich im Browser, du bist dort automatisch angemeldet.'))), - ...groupsView, webBox, rest.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, 'Weitere'), h('div', { class: 'tiles' }, ...rest)) : null); - // Seiten der Weboberfläche: Der Server liefert nur, was dieser Mitarbeiter öffnen darf - api('web_pages.php').then((r) => { - const groups = new Map(); - for (const p of (r.pages || []).filter((x) => x.key !== 'assistant')) { if (!groups.has(p.group)) groups.set(p.group, []); groups.get(p.group).push(p); } - webBox.append(...[...groups].map(([title, pages]) => h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, title + ' (im Browser)'), - h('div', { class: 'tiles' }, ...pages.map((p) => h('div', { class: 'tile', tabindex: '0', onclick: () => openWebPage(p.path), onkeydown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openWebPage(p.path); } } }, - h('div', { class: 'tt' }, p.label + ' ↗'), h('div', { class: 'td' }, 'Öffnet die Weboberfläche'))))))); - }).catch(() => {}); + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Verwaltung'), h('div', { class: 'sub' }, 'Nur Bereiche, für die du freigeschaltet bist.'))), + ...groupsView, rest.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, 'Weitere'), h('div', { class: 'tiles' }, ...rest)) : null); return; } const s = sections.find((x) => x.key === sectionKey); @@ -924,7 +961,7 @@ async function vSettings(main) { check('notifications', 'Windows-Benachrichtigungen anzeigen', 'Bei neuen Aktualisierungen an deinen Tickets und neuen, nicht zugewiesenen Tickets.'), h('label', {}, 'Abfrage-Intervall'), h('select', { onchange: (e) => save({ pollSeconds: +e.target.value }, 'Gespeichert') }, ...[[30, 'alle 30 Sekunden'], [60, 'jede Minute'], [120, 'alle 2 Minuten'], [300, 'alle 5 Minuten']].map(([v, l]) => h('option', { value: v, selected: Number(p.pollSeconds) === v }, l))), - h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => window.deskhub.notify('DeskHub', 'Test-Benachrichtigung – so sieht eine Meldung aus.') }, 'Test-Benachrichtigung'))), + h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => window.deskhub.notify('DeskHub', tr('Test-Benachrichtigung – so sieht eine Meldung aus.')) }, 'Test-Benachrichtigung'))), h('div', { class: 'panel' }, h('h3', {}, 'Konto'), h('div', {}, `${state.staff.name} (${state.staff.email})`), h('div', { class: 'muted' }, session?.serverUrl || ''), h('div', { class: 'row top' }, h('button', { class: 'btn ghost', onclick: doLogout }, 'Abmelden / Server wechseln'), diff --git a/desktop/src/assets/icon.ico b/desktop/src/assets/icon.ico index a0acb97d..22d852e9 100644 Binary files a/desktop/src/assets/icon.ico and b/desktop/src/assets/icon.ico differ diff --git a/desktop/src/assets/logo.png b/desktop/src/assets/logo.png index bf54cbe9..401e19fb 100644 Binary files a/desktop/src/assets/logo.png and b/desktop/src/assets/logo.png differ diff --git a/desktop/src/i18n.js b/desktop/src/i18n.js new file mode 100644 index 00000000..fa3b37b9 --- /dev/null +++ b/desktop/src/i18n.js @@ -0,0 +1,102 @@ +'use strict'; +/** + * Sprachen des Windows-Programms: Deutsch (Quelltext) und English. + * Der Quelltext ist deutsch; bei English werden alle Texte beim Aufbau der Oberfläche über das Wörterbuch (lang-en.js) übersetzt: + * Textknoten, Attribute (placeholder, title …), textContent, Meldungen (toast), confirm/alert und Benachrichtigungen. + * Schlüssel = deutscher Text; "{}" = beliebiger Teil, "{#}" = Zahl, "{1}"/"{2}" = Reihenfolge in der Übersetzung. Was fehlt, bleibt deutsch. + * Die Wahl steht in localStorage ("lang") und wird nach dem Umschalten (Klick auf eine Flagge) durch Neuladen übernommen. + */ +(function () { + const stored = (() => { try { return localStorage.getItem('lang'); } catch { return null; } })(); + const lang = stored === 'de' || stored === 'en' ? stored : ((navigator.language || 'de').toLowerCase().startsWith('de') ? 'de' : 'en'); + const FLAG = { + de: 'data:image/svg+xml;base64,' + btoa(''), + en: 'data:image/svg+xml;base64,' + btoa(''), + }; + const NAMES = { de: 'Deutsch', en: 'English' }; + + const exact = new Map(); + const patterns = []; + const norm = (s) => String(s).replace(/\s+/g, ' ').trim(); + if (lang === 'en' && window.I18N_EN) { + for (const [k, v] of Object.entries(window.I18N_EN)) { + if (!v || k[0] === '_') continue; + const key = norm(k); + if (key.includes('{}') || key.includes('{#}')) { + const parts = key.split(/(\{#\}|\{\})/); + const re = '^' + parts.map((p, i) => (i % 2 === 0 ? p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : (p === '{#}' ? '(\\d+)' : '(.+?)'))).join('') + '$'; + patterns.push([new RegExp(re, 's'), v]); + } else exact.set(key, v); + } + } + + // Übersetzt einen Text; Leerraum am Rand bleibt erhalten, unbekannte Texte bleiben unverändert + function t(s) { + if (lang !== 'en' || typeof s !== 'string' || s === '') return s; + const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(s); + const key = norm(m[2]); + if (!key) return s; + let r = exact.get(key); + if (r === undefined) { + for (const [re, v] of patterns) { + const mm = re.exec(key); + if (mm) { + let i = 0; + // Eingefügte Teile werden selbst noch einmal übersetzt (zum Beispiel „in 2 Std 5 Min“ in „Lösung fällig: {}“) + r = v.replace(/\{(\d+)\}/g, (_, n) => t(mm[+n] ?? '')).replace(/\{\}/g, () => t(mm[++i] ?? '')); + break; + } + } + } + return r === undefined ? s : m[1] + r + m[3]; + } + + if (lang === 'en') { + // Textknoten, die über createTextNode oder append() entstehen + const ct = Document.prototype.createTextNode; + Document.prototype.createTextNode = function (d) { return ct.call(this, t(String(d))); }; + for (const name of ['append', 'prepend']) { + const orig = Element.prototype[name]; + Element.prototype[name] = function (...a) { return orig.apply(this, a.map((x) => (typeof x === 'string' ? t(x) : x))); }; + } + const sa = Element.prototype.setAttribute; + const attrs = new Set(['placeholder', 'title', 'aria-label', 'alt']); + Element.prototype.setAttribute = function (n, v) { return sa.call(this, n, attrs.has(n) && typeof v === 'string' ? t(v) : v); }; + const td = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); + Object.defineProperty(Node.prototype, 'textContent', { ...td, set(v) { td.set.call(this, typeof v === 'string' ? t(v) : v); } }); + const ph = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'placeholder'); + if (ph) Object.defineProperty(HTMLInputElement.prototype, 'placeholder', { ...ph, set(v) { ph.set.call(this, t(String(v))); } }); + for (const fn of ['confirm', 'alert']) { const o = window[fn].bind(window); window[fn] = (m) => o(t(String(m))); } + document.documentElement.lang = 'en'; + } + + // Sprachwechsler mit Flaggen (gleiche Optik wie im Web) + function switcher() { + const box = document.createElement('span'); + box.className = 'lang-switch'; + box.setAttribute('role', 'group'); + for (const code of ['de', 'en']) { + const a = document.createElement('button'); + a.type = 'button'; + a.className = 'lang-flag' + (code === lang ? ' on' : ''); + a.title = NAMES[code]; + a.setAttribute('aria-label', NAMES[code]); + if (code === lang) a.setAttribute('aria-current', 'true'); + const img = document.createElement('img'); + img.src = FLAG[code]; img.alt = ''; img.width = 24; img.height = 16; + a.appendChild(img); + a.addEventListener('click', () => { if (code !== lang) setLang(code); }); + box.appendChild(a); + } + return box; + } + function setLang(code) { + try { localStorage.setItem('lang', code); } catch {} + try { window.deskhub?.setSettings?.({ language: code }); } catch {} + location.reload(); + } + try { window.deskhub?.setSettings?.({ language: lang }); } catch {} + + window.I18N = { lang, t, switcher, setLang, locale: lang === 'en' ? 'en-GB' : 'de-DE' }; + window.tr = t; +})(); diff --git a/desktop/src/index.html b/desktop/src/index.html index 15eae98c..44e7fc77 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -11,7 +11,10 @@