Upload via GUI (72 Dateien)
This commit is contained in:
Binary file not shown.
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "3YPOu5cr0tQ=",
|
||||
"dgSpecHash": "QAGLIFBHTUQ=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\deskhub\\desktop\\installer\\DeskHubSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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 |
@@ -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;
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
+19
-2
@@ -22,10 +22,27 @@ const routes = {
|
||||
return { dbReady: true, settings: { enabled: true, mode: 'offline_only', name: '', greeting: '', noAnswer: '', departmentId: 0, priorityId: 0, logDays: 90, synonyms: 'Zugang, Einladung, beitreten' }, defaults: { name: 'Digitaler Assistent', greeting: 'Hallo!', noAnswer: 'Keine Antwort.' }, state: { activeNow: true, liveChatEnabled: false, kbEnabled: true, helpdeskOffline: false }, departments: [{ id: 1, name: 'Technik' }], priorities: [{ id: 1, name: 'Normal' }] };
|
||||
},
|
||||
'tickets.php': () => ({ items: tickets, page: 1, perPage: 30, total: 4, hasMore: false }),
|
||||
'ticket.php': () => ({ ticket: { ...tickets[0], source: 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', similar: { articles: [{ title: 'Drucker einrichten', url: '/kb_article.php?slug=drucker' }], tickets: [{ number: 'T-77', subject: 'Alter Druckerfall' }] }, lockedByOtherName: null, lockedBy: window.__lockedBy || null, canUnlock: !!window.__lockedBy, customFields: [{ label: 'Titel der Geschichte', type: 'text', value: 'Der kleine Bär' }, { label: 'Nutzerbedingungen', type: 'checkbox', value: 'Ja' }], replySignatures: { default: 'own', own: { available: true, text: 'Viele Grüße\nMax persönlich', html: '<p>Viele Grüße</p><div>Max persönlich</div>', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '<p>Technik-Team Support</p><img src="https://example.org/logo.png" width="120" onerror="alert(1)" style="width:999px"><a href="javascript:alert(1)">Link</a><scr' + 'ipt>alert(2)</scr' + 'ipt>', hasImage: true } }, threads: [
|
||||
'ticket.php': () => ({ ticket: { ...tickets[0], collaborators: window.__collabs || [{ id: 1, email: 'cc@firma.de', name: 'Cc Person' }], followers: [{ id: 2, name: 'Eva Beispiel' }], csat: { rating: 4, comment: 'Schnell und freundlich', sentAt: '2026-10-12 09:00:00', answeredAt: '2026-10-12 09:30:00' }, source: 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', similar: { articles: [{ title: 'Drucker einrichten', url: '/kb_article.php?slug=drucker' }], tickets: [{ number: 'T-77', subject: 'Alter Druckerfall' }] }, lockedByOtherName: null, lockedBy: window.__lockedBy || null, canUnlock: !!window.__lockedBy, customFields: [{ label: 'Titel der Geschichte', type: 'text', value: 'Der kleine Bär' }, { label: 'Nutzerbedingungen', type: 'checkbox', value: 'Ja' }], replySignatures: { default: 'own', own: { available: true, text: 'Viele Grüße\nMax persönlich', html: '<p>Viele Grüße</p><div>Max persönlich</div>', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '<p>Technik-Team Support</p><img src="https://example.org/logo.png" width="120" onerror="alert(1)" style="width:999px"><a href="javascript:alert(1)">Link</a><scr' + 'ipt>alert(2)</scr' + 'ipt>', hasImage: true } }, threads: [
|
||||
{ id: 1, authorType: 'user', authorName: 'Kunde 1', message: 'Mein Drucker druckt nichts mehr.\nBitte helft.', isInternalNote: false, createdAt: '2026-10-12 08:00:00', attachments: [{ id: 1, name: 'foto.png', size: 20480 }], comments: [] },
|
||||
{ id: 2, authorType: 'staff', authorName: 'Max Muster', message: 'Wir schauen uns das an.', isInternalNote: false, createdAt: '2026-10-12 08:30:00', attachments: [], comments: [] },
|
||||
{ id: 3, authorType: 'staff', authorName: 'Max Muster', message: 'Treiber prüfen', isInternalNote: true, createdAt: '2026-10-12 08:35:00', attachments: [], comments: [] }] } }),
|
||||
'ticket_collaborators.php': (req) => {
|
||||
window.__collabs = window.__collabs || [{ id: 1, email: 'cc@firma.de', name: 'Cc Person' }];
|
||||
const b = req.body || {};
|
||||
if (b.action === 'add') window.__collabs.push({ id: 10 + window.__collabs.length, email: b.email, name: '' });
|
||||
if (b.action === 'remove') window.__collabs = window.__collabs.filter((c) => c.id !== b.id);
|
||||
return { collaborators: window.__collabs };
|
||||
},
|
||||
'ticket_templates.php': () => ({ templates: [{ id: 1, name: 'Neuer Mitarbeiter', description: '', subject: 'Neuer Mitarbeiter: ', body: 'Name:\nStartdatum:', departmentId: 1, helpTopicId: null, priorityId: 1, isPublic: true }] }),
|
||||
'csat.php': () => ({ enabled: true, days: 30, stats: { sent: 10, answered: 6, avg: 4.2, rate: 60, dist: [1, 2, 3, 4, 5].map((r) => ({ rating: r, count: r === 5 ? 3 : r === 4 ? 2 : r === 1 ? 1 : 0 })), byStaff: [{ id: 1, name: 'Max Muster', count: 4, avg: 4.5 }], recent: [{ rating: 5, comment: 'Top', answeredAt: '2026-10-12 09:30:00', number: 'T-1001', subject: 'Drucker druckt nicht' }] } }),
|
||||
'status.php': (req) => {
|
||||
window.__status = window.__status || { enabled: false, title: 'Systemstatus', url: 'https://demo.example.de/status.php', overall: { key: 'operational', label: 'Alle Systeme laufen', color: '#157a45' },
|
||||
componentStates: [{ key: 'operational', label: 'In Betrieb' }, { key: 'maintenance', label: 'Wartung' }, { key: 'degraded', label: 'Eingeschränkt' }, { key: 'outage', label: 'Ausfall' }],
|
||||
incidentStates: [{ key: 'investigating', label: 'Wird untersucht' }, { key: 'resolved', label: 'Behoben' }],
|
||||
components: [{ id: 1, name: 'Webseite', description: 'Kundenportal', status: 'operational', statusLabel: 'In Betrieb' }], incidents: [] };
|
||||
if (req.method === 'POST') { (window.__statusPosts = window.__statusPosts || []).push(req.body); const b = req.body; if (b.action === 'set_enabled') window.__status.enabled = b.enabled; }
|
||||
return { status: window.__status, canManage: true };
|
||||
},
|
||||
'ticket_unlock.php': () => { window.__lockedBy = null; return { ok: true, unlocked: true }; },
|
||||
'departments.php': () => ({ staffOptions: [{ id: 1, name: 'Max Muster' }, { id: 2, name: 'Eva Beispiel' }], emailOptions: [], slaPlans: [], items: [{ id: 1, name: 'Technik', email: 'technik@example.de', isDefault: true, signature: '', isActive: true, slaPlanId: null, managerId: 1, managerName: 'Max Muster', autoAssignMode: 'staff', autoAssignStaffId: 2, notes: '', members: [{ id: 1, name: 'Max Muster', isPrimary: true }] }] }),
|
||||
'queues.php': () => ({ items: [{ id: 1, name: 'Meine überfälligen', isShared: false, mine: true, ownerName: 'Max Muster', filters: { status: 'open,pending', assigned: 'me', sla: 'overdue' } }, { id: 2, name: 'Team-Überblick', isShared: true, mine: false, ownerName: 'Eva Beispiel', filters: { status: 'open,pending,answered' } }] }),
|
||||
@@ -52,5 +69,5 @@ window.deskhub = {
|
||||
onSessionExpired() {}, onNavigate() {},
|
||||
};
|
||||
</script>
|
||||
<script src="../src/app.js"></script><script src="../src/admin-extra.js"></script>
|
||||
<script src="../src/lang-en.js"></script><script src="../src/i18n.js"></script><script src="../src/app.js"></script><script src="../src/admin-extra.js"></script><script src="../src/admin-status.js"></script>
|
||||
</body></html>
|
||||
|
||||
+46
-3
@@ -47,6 +47,13 @@ app.whenReady().then(async () => {
|
||||
check('Ticketdetail zeigt Eigenschaften und Aktionen', await run(`['eigenschaften', 'schließen'].every(x => document.getElementById('main').innerText.toLowerCase().includes(x))`));
|
||||
check('Ticketdetail: Wiedervorlage- und Beobachten-Knopf sind da', await run(`(() => { const t = document.getElementById('main').innerText; return t.includes('Auf später') && t.includes('Beobachten'); })()`));
|
||||
check('Ticketdetail: „Dazu passend“ zeigt Artikel und ähnliches Ticket', await run(`(() => { const t = document.getElementById('main').innerText; return t.toLowerCase().includes('dazu passend') && t.includes('Drucker einrichten') && t.includes('T-77'); })()`));
|
||||
check('Ticketdetail: Bewertung des Kunden (Sterne und Kommentar)', await run(`(() => { const t = document.getElementById('main').textContent; return t.includes('Kundenzufriedenheit') && t.includes('★★★★☆') && t.includes('4 von 5') && t.includes('Schnell und freundlich'); })()`));
|
||||
check('Ticketdetail: Beobachter und Cc-Empfänger werden gezeigt', await run(`(() => { const t = document.getElementById('main').innerText; return t.includes('Eva Beispiel') && t.includes('Cc Person') && t.includes('cc@firma.de'); })()`));
|
||||
await run(`window.__bodies = {}; (() => { const i = [...document.querySelectorAll('#main input')].find((x) => x.placeholder === 'Cc-Adresse hinzufügen'); i.value = 'neu@kunde.de'; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Hinzufügen').click(); })()`);
|
||||
check('Cc hinzufügen sendet die Adresse an den Server und zeigt sie an', await waitFor(`(window.__bodies['ticket_collaborators.php'] || {}).action === 'add' && window.__bodies['ticket_collaborators.php'].email === 'neu@kunde.de' && document.getElementById('main').innerText.includes('neu@kunde.de')`));
|
||||
await run(`window.__bodies = {}; [...document.querySelectorAll('#main .mini.row button')][0].click()`);
|
||||
check('Cc entfernen sendet die Kennung an den Server', await waitFor(`(window.__bodies['ticket_collaborators.php'] || {}).action === 'remove' && window.__bodies['ticket_collaborators.php'].id === 1 && !document.getElementById('main').innerText.includes('Cc Person')`));
|
||||
await run(`window.__collabs = null`);
|
||||
await run(`window.__bodies = {}; [...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Beobachten') && !b.innerText.includes('Nicht')).click()`);
|
||||
await run(`window.__web = ''; [...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Im Browser')).click()`);
|
||||
check('Ticket: „Im Browser“ öffnet das Ticket in der Weboberfläche', await waitFor(`String(window.__web).startsWith('/staff/ticket_view.php?n=')`));
|
||||
@@ -76,6 +83,11 @@ app.whenReady().then(async () => {
|
||||
check('Sperre aufheben ruft den Server auf und der Hinweis verschwindet', await waitFor(`window.__calls.includes('ticket_unlock.php') && !document.getElementById('main').innerText.includes('„Florian“ bearbeitet')`));
|
||||
await run(`go('reports')`);
|
||||
check('Berichte laden Tabelle mit Summen', await waitFor(`['Technik', 'Vertrieb', '59'].every(x => document.getElementById('main').innerText.includes(x))`));
|
||||
check('Berichte: Kundenzufriedenheit mit Durchschnitt, Rücklauf und Kommentar', await waitFor(`(() => { const t = document.getElementById('main').textContent; return t.includes('Kundenzufriedenheit') && t.includes('4,2 ★') && t.includes('6 Antworten von 10') && t.includes('Max Muster 4,5') && t.includes('Top'); })()`));
|
||||
await run(`go('newticket')`);
|
||||
check('Neues Ticket: Vorlagenauswahl erscheint', await waitFor(`(() => { const s = [...document.querySelectorAll('#main select')][0]; return !!s && [...s.options].some((o) => o.text === 'Neuer Mitarbeiter'); })()`));
|
||||
await run(`(() => { const s = [...document.querySelectorAll('#main select')][0]; s.value = '1'; s.dispatchEvent(new Event('change')); })()`);
|
||||
check('Vorlage füllt Betreff, Text und Priorität vor', await run(`(() => { const inp = [...document.querySelectorAll('#main input')].map((x) => x.value); const ta = document.querySelector('#main textarea').value; const sel = [...document.querySelectorAll('#main select')].map((x) => x.value); return inp.includes('Neuer Mitarbeiter: ') && ta === 'Name:\\nStartdatum:' && sel.includes('1'); })()`));
|
||||
|
||||
console.log('Verwaltung');
|
||||
await run(`state.staff.role = 'admin'`);
|
||||
@@ -105,6 +117,15 @@ app.whenReady().then(async () => {
|
||||
await tabBtn('Frage ausprobieren');
|
||||
await run(`window.__assistantPosts = []; document.querySelector('#main input').value = 'Wann kommt das Paket?'; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Testen').click()`);
|
||||
check('Assistent: Ausprobieren zeigt die Antwort', await waitFor(`document.getElementById('main').innerText.includes('Zwei bis drei Werktage.')`));
|
||||
// Statusseite: Zustand der Dienste, Einschalten, Meldung veröffentlichen
|
||||
await run(`go('admin', 'statuspage')`);
|
||||
check('Statusseite: Dienste und Gesamtzustand werden gezeigt', await waitFor(`(() => { const t = document.getElementById('main').textContent; return t.includes('Alle Systeme laufen') && t.includes('Webseite') && t.includes('Neue Meldung'); })()`));
|
||||
await run(`window.__statusPosts = []; document.querySelector('#main input[type=checkbox]').click()`);
|
||||
check('Statusseite: Einschalten sendet set_enabled', await waitFor(`(window.__statusPosts[0] || {}).action === 'set_enabled' && window.__statusPosts[0].enabled === true`));
|
||||
await run(`window.__statusPosts = []; (() => { const t = [...document.querySelectorAll('#main input')].find((x) => x.placeholder.includes('E-Mail-Versand')); t.value = 'Portal langsam'; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Veröffentlichen').click(); })()`);
|
||||
check('Statusseite: Meldung veröffentlichen sendet Titel und Stufe', await waitFor(`(window.__statusPosts[0] || {}).action === 'create_incident' && window.__statusPosts[0].title === 'Portal langsam' && window.__statusPosts[0].status === 'investigating'`));
|
||||
await run(`window.__statusPosts = []; (() => { const s = [...document.querySelectorAll('#main select')][0]; s.value = 'outage'; s.dispatchEvent(new Event('change')); })()`);
|
||||
check('Statusseite: Zustand eines Dienstes ändern sendet set_component', await waitFor(`(window.__statusPosts[0] || {}).action === 'set_component' && window.__statusPosts[0].status === 'outage' && window.__statusPosts[0].id === 1`));
|
||||
// Rechte: ohne das Recht ist der Bereich nicht in der Verwaltung
|
||||
await run(`state.staff.role = 'agent'; state.staff.permissions = {}`);
|
||||
check('Assistent: ohne Recht nicht in den Verwaltungsbereichen', await run(`!adminSections().some((s) => s.key === 'assistant')`));
|
||||
@@ -114,9 +135,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
// Abteilung: automatische Zuweisung neuer Tickets einstellen
|
||||
await run(`go('admin')`);
|
||||
check('Verwaltung zeigt die Weboberfläche-Seiten, die der Server freigibt', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Backup') && t.includes('Einstellungen') && t.includes('im Browser'); })()`));
|
||||
await run(`[...document.querySelectorAll('#main .tile')].find((x) => x.innerText.includes('Backup')).click()`);
|
||||
check('Klick öffnet die Seite über die einmalige Anmeldung (nur /staff/-Pfad)', await waitFor(`window.__web === '/staff/backup.php'`));
|
||||
check('Verwaltung zeigt nur Bereiche der App, keine Seiten der Weboberfläche mehr', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Abteilungen') && !t.includes('(im Browser)') && !t.includes('Backup') && !document.querySelector('#main .tile[data-web]'); })()`));
|
||||
check('Verwaltung ruft die Seiten der Weboberfläche nicht mehr ab', await run(`!window.__calls.includes('web_pages.php')`));
|
||||
await run(`go('admin', 'departments')`);
|
||||
check('Abteilungen: Liste lädt', await waitFor(`document.getElementById('main').innerText.includes('Technik')`));
|
||||
await run(`document.querySelector('#main tr.click').click()`);
|
||||
@@ -143,6 +163,29 @@ app.whenReady().then(async () => {
|
||||
await run(`window.__health = [{ level: 'error', code: 'mailbox_down', message: 'Postfach „support@example.de“ ist nicht erreichbar.' }]; pollActivity()`);
|
||||
check('Serverstörung (Mailabruf) wird als Banner gezeigt', await waitFor(`document.getElementById('banners').innerText.includes('support@example.de')`, 6000));
|
||||
|
||||
console.log('Sprache');
|
||||
check('Standard (Windows-Sprache im Test: Deutsch): Flaggen sind in der Kopfleiste', await run(`document.querySelectorAll('.topbar .lang-flag').length === 2 && document.querySelector('.topbar .lang-flag.on').title === 'Deutsch'`));
|
||||
check('Deutsch: Seitenleiste deutsch', await run(`document.querySelector('.side').innerText.includes('Übersicht')`));
|
||||
await run(`localStorage.setItem('lang', 'en')`);
|
||||
await win.loadFile(path.join(__dirname, 'mock.html'));
|
||||
check('English: App startet', await waitFor(`!!document.querySelector('.side') && !!document.getElementById('main')`));
|
||||
check('English: Seitenleiste und Kopfleiste übersetzt', await run(`(() => { const t = document.querySelector('.side').innerText + document.querySelector('.topbar').innerText; return t.includes('Overview') && t.includes('Tasks') && t.includes('Sign out') && !t.includes('Abmelden'); })()`));
|
||||
check('English: Flagge English ist aktiv, <html lang> passt', await run(`document.querySelector('.topbar .lang-flag.on').title === 'English' && document.documentElement.lang === 'en'`));
|
||||
await run(`go('ticket', 1)`);
|
||||
check('English: Ticket übersetzt (Eigenschaften, Aktionen, Texte mit Zahlen)', await waitFor(`(() => { const t = document.getElementById('main').textContent; return t.includes('Properties') && t.includes('Resolve') && t.includes('Customer satisfaction') && t.includes('Mein Drucker druckt nichts mehr') && t.includes('Drucker druckt nicht'); })()`));
|
||||
check('English: Kundendaten (Ticketinhalt) bleiben unübersetzt', await run(`document.getElementById('main').textContent.includes('Mein Drucker druckt nichts mehr')`));
|
||||
await run(`go('newticket')`);
|
||||
check('English: Neues Ticket (Beschriftungen und Platzhalter)', await waitFor(`(() => { const t = document.getElementById('main').textContent; return t.includes('New ticket') && t.includes('Customer name') && t.includes('Subject') && t.includes('Create ticket'); })()`));
|
||||
await run(`go('tickets')`);
|
||||
check('English: Ticketliste hat Reiter und Suche auf English', await waitFor(`(() => { const t = document.getElementById('main').textContent; return t.includes('Watched') || t.includes('Snoozed') || t.includes('Open'); })()`));
|
||||
check('English: Datumsformat englisch', await run(`fmtDate('2026-10-24 19:42:00').includes('24/10/2026')`));
|
||||
await run(`state.staff.role = 'admin'; go('admin')`);
|
||||
check('English: Verwaltung (Kacheln, Hinweis ohne Web-Verweis)', await waitFor(`(() => { const t = document.getElementById('main').textContent; return t.includes('Administration') && t.includes('Departments') && t.includes('Only areas you are authorized for.'); })()`));
|
||||
await run(`[...document.querySelectorAll('.topbar .lang-flag')].find((b) => b.title === 'Deutsch').click()`);
|
||||
await waitFor(`localStorage.getItem('lang') === 'de'`);
|
||||
check('Klick auf die deutsche Flagge speichert die Sprache', await run(`localStorage.getItem('lang') === 'de'`));
|
||||
await run(`localStorage.removeItem('lang')`);
|
||||
|
||||
check('Keine JavaScript-Fehler in der Konsole', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
|
||||
} catch (e) {
|
||||
failures.push('Testlauf abgebrochen: ' + e.message);
|
||||
|
||||
Reference in New Issue
Block a user