Upload via GUI (72 Dateien)
This commit is contained in:
+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'),
|
||||
|
||||
Reference in New Issue
Block a user