Upload via GUI (72 Dateien)

This commit is contained in:
2026-10-24 20:15:40 +00:00
parent e7a6ba0b86
commit 64d6cf1afb
18 changed files with 325 additions and 42 deletions
+1 -1
View File
@@ -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);
};
+55
View File
@@ -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';
+58 -21
View File
@@ -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) => '<p>' + t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').split(/\n{2,}/).map((p) => p.replace(/\n/g, '<br>')).join('</p><p>') + '</p>';
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'),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+102
View File
@@ -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('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#FFCE00"/><rect width="5" height="2" fill="#DD0000"/><rect width="5" height="1"/></svg>'),
en: 'data:image/svg+xml;base64,' + btoa('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30"><clipPath id="s"><path d="M0,0 v30 h60 v-30 z"/></clipPath><clipPath id="t"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/></clipPath><g clip-path="url(#s)"><path d="M0,0 v30 h60 v-30 z" fill="#012169"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 L60,30 M60,0 L0,30" clip-path="url(#t)" stroke="#C8102E" stroke-width="4"/><path d="M30,0 v30 M0,15 h60" stroke="#fff" stroke-width="10"/><path d="M30,0 v30 M0,15 h60" stroke="#C8102E" stroke-width="6"/></g></svg>'),
};
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;
})();
+3
View File
@@ -11,7 +11,10 @@
<div id="titlebar"><span class="logo sm">D</span>DeskHub</div>
<div id="app"></div>
<div id="toast" class="toast hidden"></div>
<script src="lang-en.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
<script src="admin-extra.js"></script>
<script src="admin-status.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+17 -10
View File
@@ -110,8 +110,11 @@ ipcMain.handle('notify', (_e, { title, body }) => {
});
// ─── Einstellungen (settings.json im Benutzerordner) ────────────────────────
const DEFAULTS = { theme: 'system', autostart: false, closeToTray: true, notifications: true, pollSeconds: 60, startMinimized: false, bounds: null };
const DEFAULTS = { theme: 'system', autostart: false, closeToTray: true, notifications: true, pollSeconds: 60, startMinimized: false, bounds: null, language: null };
let settings = { ...DEFAULTS };
// Sprache für Tray-Menü und Meldungen (Auswahl im Programm, sonst die von Windows)
const lang = () => (settings.language === 'de' || settings.language === 'en' ? settings.language : ((app.getLocale() || 'de').toLowerCase().startsWith('de') ? 'de' : 'en'));
const L = (de, en) => (lang() === 'en' ? en : de);
const settingsFile = () => path.join(app.getPath('userData'), 'settings.json');
function loadSettings() { try { settings = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) }; } catch { settings = { ...DEFAULTS }; } }
function saveSettings() { try { fs.writeFileSync(settingsFile(), JSON.stringify(settings)); } catch {} }
@@ -124,6 +127,7 @@ ipcMain.handle('settings:set', (_e, patch) => {
for (const k of Object.keys(DEFAULTS)) if (k in patch && k !== 'bounds') settings[k] = patch[k];
saveSettings(); applyAutostart();
applyNativeTheme();
try { tray?.setContextMenu(trayMenu()); } catch {}
return { ...settings, version: app.getVersion(), packaged: app.isPackaged };
});
@@ -134,24 +138,27 @@ function showWindow(page) {
win.show(); if (win.isMinimized()) win.restore(); win.focus();
if (page) win.webContents.send('navigate', page);
}
function trayMenu() {
return Menu.buildFromTemplate([
{ label: L('DeskHub öffnen', 'Open DeskHub'), click: () => showWindow() },
{ label: 'Tickets', click: () => showWindow('tickets') },
{ label: L('Neues Ticket', 'New ticket'), click: () => showWindow('newticket') },
{ type: 'separator' },
{ label: L('Beenden', 'Quit'), click: () => { quitting = true; app.quit(); } },
]);
}
function createTray() {
const img = nativeImage.createFromPath(path.join(__dirname, 'assets', 'icon.ico'));
tray = new Tray(img.resize({ width: 16, height: 16 }));
tray.setToolTip('DeskHub');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'DeskHub öffnen', click: () => showWindow() },
{ label: 'Tickets', click: () => showWindow('tickets') },
{ label: 'Neues Ticket', click: () => showWindow('newticket') },
{ type: 'separator' },
{ label: 'Beenden', click: () => { quitting = true; app.quit(); } },
]));
tray.setContextMenu(trayMenu());
tray.on('click', () => showWindow());
}
ipcMain.handle('badge', (_e, { count, dataUrl }) => {
if (!win) return;
try {
win.setOverlayIcon(count > 0 && dataUrl ? nativeImage.createFromDataURL(dataUrl) : null, count > 0 ? `${count} neu` : '');
tray?.setToolTip(count > 0 ? `DeskHub – ${count} neu` : 'DeskHub');
win.setOverlayIcon(count > 0 && dataUrl ? nativeImage.createFromDataURL(dataUrl) : null, count > 0 ? `${count} ${L('neu', 'new')}` : '');
tray?.setToolTip(count > 0 ? `DeskHub – ${count} ${L('neu', 'new')}` : 'DeskHub');
} catch {}
});
+17 -1
View File
@@ -76,7 +76,8 @@ h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase; letter-spac
.shell { display: grid; grid-template-columns: 232px 1fr; grid-template-rows: 54px 1fr; height: 100%; }
.topbar { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; padding: 0 154px 0 18px; background: var(--chrome); color: var(--chrome-text); border-bottom: 1px solid var(--chrome-line); }
.topbar .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; letter-spacing: -.01em; min-width: 196px; }
.logo { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(135deg, #3aa7f2, #1769b8); color: #fff; display: grid; place-items: center; font-size: 16px; font-weight: 700; }
/* Das Zeichen („D“ weiß, „H“ orange) kommt aus assets/logo.png; der Buchstabe im Markup bleibt als Ersatz unsichtbar */
.logo { width: 30px; height: 30px; border-radius: 8px; background: url(assets/logo.png) center / cover no-repeat; color: transparent; display: grid; place-items: center; font-size: 0; font-weight: 700; overflow: hidden; }
.topbar .btn.ok { background: #2fa05a; color: #fff; border-color: transparent; }
.topbar .btn.ok:hover { background: #278a4d; }
.searchbtn { display: flex; justify-content: space-between; align-items: center; gap: 10px; width: 360px; max-width: 38vw; background: #fff; color: #6b7785; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 7px 11px; cursor: pointer; font-weight: 400; }
@@ -234,3 +235,18 @@ tr.totalrow td { font-weight: 700; background: var(--panel2); border-top: 2px so
.sigpreview.text { white-space: pre-wrap; }
.sigpreview p { margin: 0 0 6px; } .sigpreview p:last-child { margin-bottom: 0; }
.sigpreview img { max-width: 100%; height: auto; vertical-align: middle; }
/* Bewertungen (Zufriedenheitsabfrage) und Störungsmeldungen */
.stars { color: #f5b301; letter-spacing: 1px; }
.stars.big { font-size: 1.6rem; font-weight: 700; margin-right: 10px; }
.csat-comment { margin: 4px 0; }
.incident { padding: 10px 0; border-top: 1px solid var(--line, rgba(128,128,128,.25)); }
.incident:first-of-type { border-top: 0; }
/* Sprachwechsler (Flaggen) */
.lang-switch { display: inline-flex; gap: 6px; align-items: center; margin-right: 12px; -webkit-app-region: no-drag; }
.lang-flag { border: 0; background: none; padding: 2px; border-radius: 5px; opacity: .6; cursor: pointer; line-height: 0; }
.lang-flag:hover { opacity: .9; }
.lang-flag.on { opacity: 1; outline: 2px solid var(--accent, #14b8a6); outline-offset: 1px; }
.lang-flag img { display: block; width: 24px; height: 16px; object-fit: cover; border-radius: 3px; }
.login-lang { text-align: right; margin: -4px 0 8px; }