Files
deskhub/desktop/src/admin-extra.js
T
2026-10-16 18:53:05 +00:00

185 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Zusätzliche Verwaltungsbereiche (wie in der Android-App): Teams, Rollen, Eskalationen,
// Geschäftszeiten, E-Mail-Vorlagen und Ticket-Regeln. Jeder Bereich rendert sich selbst in `body`.
const PERM_LABELS = {
reply_ticket: 'Tickets beantworten', close_ticket: 'Tickets lösen/schließen', delete_ticket: 'Tickets löschen', transfer_ticket: 'Tickets verschieben',
post_note: 'Interne Notizen', assign_ticket: 'Tickets zuweisen', manage_canned: 'Textbausteine verwalten', view_reports: 'Berichte ansehen',
create_ticket: 'Tickets erstellen', manage_kb: 'Wissensdatenbank', manage_departments: 'Abteilungen', manage_priorities: 'Prioritäten / SLA',
manage_help_topics: 'Hilfe-Themen', manage_email_addresses: 'E-Mail-Adressen', manage_email_templates: 'E-Mail-Vorlagen', manage_organizations: 'Organisationen',
manage_teams: 'Teams', manage_roles: 'Rollen', manage_staff: 'Mitarbeiter', view_audit_log: 'Admin-Protokoll', manage_escalations: 'Eskalationsregeln',
view_time_entries: 'Zeiterfassung', manage_business_hours: 'Geschäftszeiten',
};
const WEEKDAYS = { 1: 'Montag', 2: 'Dienstag', 3: 'Mittwoch', 4: 'Donnerstag', 5: 'Freitag', 6: 'Samstag', 0: 'Sonntag' };
const opt = (arr, val, empty) => [empty ? h('option', { value: '' }, empty) : null, ...arr.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val ?? '') }, l))];
const named = (list) => list.map((x) => [x.id, x.name]);
const numOrNull = (v) => (v === '' || v == null ? null : Number(v));
const row = (...kids) => h('div', { class: 'row' }, ...kids);
const delBtn = (fn) => h('button', { class: 'btn danger sm', onclick: (e) => { e.stopPropagation(); if (confirm('Wirklich löschen?')) fn(); } }, '✕');
const table = (heads, rows) => h('div', { class: 'panel' }, rows.length ? h('table', {}, h('tr', {}, ...heads.map((t) => h('th', {}, t))), ...rows) : h('div', { class: 'muted' }, 'Keine Einträge.'));
// ─── Teams ───────────────────────────────────────────────────────────────────
async function adminTeams(body, addBtn) {
const load = async () => {
const r = await run(() => api('teams.php')); if (!r) return;
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', {}, delBtn(() => run(async () => { await post('teams.php', { action: 'delete', id: t.id }); load(); }, 'Gelöscht')))))));
addBtn.onclick = () => edit(null, r);
};
function edit(t, r) {
const name = h('input', { value: t?.name || '' }), notes = h('textarea', { value: t?.notes || '' });
const lead = h('select', {}, ...opt(named(r.staffOptions), t?.leadId, '— keine —'));
const active = h('input', { type: 'checkbox', checked: t ? t.isActive : true }), noAlerts = h('input', { type: 'checkbox', checked: !!t?.noAlerts });
const members = r.staffOptions.map((s) => ({ s, cb: h('input', { type: 'checkbox', checked: t?.memberIds?.includes(s.id) }) }));
modal(t ? 'Team bearbeiten' : 'Neues Team', h('div', {}, h('label', {}, 'Name'), name, h('label', {}, 'Leitung'), lead, h('label', { class: 'check' }, active, 'Aktiv'),
h('label', { class: 'check' }, noAlerts, 'Benachrichtigung bei Ticketzuweisung deaktivieren'), h('label', {}, 'Administratornotizen (intern)'), notes,
h('label', {}, 'Mitglieder'), h('div', { class: 'checks' }, ...members.map(({ s, cb }) => h('label', { class: 'check' }, cb, s.name)))),
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post('teams.php', { action: 'save', id: t?.id ?? null, name: name.value, leadId: numOrNull(lead.value), isActive: active.checked, noAlerts: noAlerts.checked, notes: notes.value || null, memberIds: members.filter((m) => m.cb.checked).map((m) => m.s.id) }), 'Gespeichert')) { close(); load(); } } }]);
}
load();
}
// ─── Rollen ──────────────────────────────────────────────────────────────────
async function adminRoles(body, addBtn) {
const load = async () => {
const r = await run(() => api('roles.php')); if (!r) return;
clear(body).append(table(['Rolle', 'Status', 'Zugewiesen', 'Rechte', ''], r.items.map((x) => h('tr', { class: 'click', onclick: () => edit(x, r) },
h('td', {}, x.name), h('td', {}, x.isActive ? 'Aktiv' : 'Deaktiviert'), h('td', {}, x.staffCount), h('td', { class: 'muted' }, Object.values(x.permissions).filter(Boolean).length + ' von ' + r.permissionKeys.length),
h('td', {}, delBtn(() => run(async () => { await post('roles.php', { action: 'delete', id: x.id }); load(); }, 'Gelöscht')))))));
addBtn.onclick = () => edit(null, r);
};
function edit(x, r) {
const name = h('input', { value: x?.name || '' }), notes = h('textarea', { value: x?.notes || '' });
const active = h('input', { type: 'checkbox', checked: x ? x.isActive : true });
const perms = (x?.permissions) || r.defaultAgentPermissions || {};
const boxes = r.permissionKeys.map((k) => ({ k, cb: h('input', { type: 'checkbox', checked: !!perms[k] }) }));
modal(x ? 'Rolle bearbeiten' : 'Neue Rolle', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, active, 'Aktiv (deaktivierte Rolle wird ignoriert)'), h('label', {}, 'Administratornotizen (intern)'), notes, h('label', {}, 'Rechte'),
h('div', { class: 'checks' }, ...boxes.map(({ k, cb }) => h('label', { class: 'check' }, cb, PERM_LABELS[k] || k)))),
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post('roles.php', { action: 'save', id: x?.id ?? null, name: name.value, isActive: active.checked, notes: notes.value || null, permissions: Object.fromEntries(boxes.map(({ k, cb }) => [k, cb.checked])) }), 'Gespeichert')) { close(); load(); } } }]);
}
load();
}
// ─── Eskalationsregeln ───────────────────────────────────────────────────────
const TRIGGERS = [['no_reply', 'Keine Antwort seit …'], ['no_update', 'Keine Aktualisierung seit …'], ['overdue', 'Überfällig seit …'], ['open_too_long', 'Zu lange offen seit …']];
async function adminEscalations(body, addBtn) {
const load = async () => {
const r = await run(() => api('escalation_rules.php')); if (!r) return;
clear(body).append(table(['Regel', 'Auslöser', 'Angewendet', 'Aktiv', ''], r.rules.map((x) => h('tr', { class: 'click', onclick: () => edit(x, r) },
h('td', {}, x.name), h('td', {}, `${(TRIGGERS.find((t) => t[0] === x.triggerType) || [, x.triggerType])[1].replace('…', x.triggerHours + ' Std.')}`), h('td', {}, x.appliedCount),
h('td', {}, h('input', { type: 'checkbox', checked: x.isActive, onclick: (e) => e.stopPropagation(), onchange: () => run(async () => { await post('escalation_rules.php', { action: 'toggle', id: x.id }); load(); }) })),
h('td', {}, delBtn(() => run(async () => { await post('escalation_rules.php', { action: 'delete', id: x.id }); load(); }, 'Gelöscht')))))));
addBtn.onclick = () => edit(null, r);
};
function edit(x, r) {
const f = {
name: h('input', { value: x?.name || '' }), active: h('input', { type: 'checkbox', checked: x?.isActive ?? true }),
trig: h('select', {}, ...opt(TRIGGERS, x?.triggerType || 'no_reply')), hours: h('input', { type: 'number', min: 1, value: x?.triggerHours ?? 24 }),
cStatus: h('select', {}, ...opt(state.meta.statuses.map((s) => [s.value, s.label]), x?.conditionStatus, 'Beliebig')),
cPrio: h('select', {}, ...opt(named(r.priorities), x?.conditionPriorityId, 'Beliebig')), cDept: h('select', {}, ...opt(named(r.departments), x?.conditionDeptId, 'Beliebig')),
aPrio: h('select', {}, ...opt(named(r.priorities), x?.actionSetPriorityId, '— nicht ändern —')), aStaff: h('select', {}, ...opt(named(r.staffOptions), x?.actionAssignStaffId, '— nicht ändern —')),
aStatus: h('select', {}, ...opt([['open', 'Offen'], ['pending', 'Wartend'], ['on_hold', 'Zurückgestellt']], x?.actionSetStatus, '— nicht ändern —')),
nAdmin: h('input', { type: 'checkbox', checked: x?.actionNotifyAdmin ?? true }), nStaff: h('input', { type: 'checkbox', checked: !!x?.actionNotifyStaff }),
};
modal(x ? 'Regel bearbeiten' : 'Neue Eskalationsregel', h('div', {}, h('label', {}, 'Name'), f.name, h('label', { class: 'check' }, f.active, 'Aktiv'),
h('label', {}, 'Auslöser'), f.trig, h('label', {}, 'Nach Stunden'), f.hours,
h('h3', {}, 'Bedingungen'), h('label', {}, 'Status'), f.cStatus, h('label', {}, 'Priorität'), f.cPrio, h('label', {}, 'Abteilung'), f.cDept,
h('h3', {}, 'Aktionen'), h('label', {}, 'Priorität setzen'), f.aPrio, h('label', {}, 'Zuweisen an'), f.aStaff, h('label', {}, 'Status setzen'), f.aStatus,
h('label', { class: 'check' }, f.nAdmin, 'Admins benachrichtigen'), h('label', { class: 'check' }, f.nStaff, 'Zuständigen benachrichtigen')),
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post('escalation_rules.php', { action: 'save', id: x?.id ?? null, name: f.name.value, isActive: f.active.checked, triggerType: f.trig.value, triggerHours: +f.hours.value || 24,
conditionStatus: f.cStatus.value || null, conditionPriorityId: numOrNull(f.cPrio.value), conditionDeptId: numOrNull(f.cDept.value), actionSetPriorityId: numOrNull(f.aPrio.value),
actionAssignStaffId: numOrNull(f.aStaff.value), actionSetStatus: f.aStatus.value || null, actionNotifyAdmin: f.nAdmin.checked, actionNotifyStaff: f.nStaff.checked, sortOrder: x?.sortOrder ?? 0 }), 'Gespeichert')) { close(); load(); } } }]);
}
load();
}
// ─── Geschäftszeiten ─────────────────────────────────────────────────────────
async function adminBusinessHours(body, addBtn) {
addBtn.classList.add('hidden');
const load = async () => {
const r = await run(() => api('business_hours.php')); if (!r) return;
const enabled = h('input', { type: 'checkbox', checked: r.enabled });
const rows = r.hours.map((x) => ({ x, open: h('input', { type: 'checkbox', checked: x.isOpen }), from: h('input', { type: 'time', value: x.openTime }), to: h('input', { type: 'time', value: x.closeTime }) }));
const hDate = h('input', { type: 'date' }), hName = h('input', { placeholder: 'Bezeichnung (z. B. Neujahr)' });
clear(body).append(h('div', { class: 'panel' }, h('label', { class: 'check' }, enabled, 'SLA-Uhr nur innerhalb der Geschäftszeiten laufen lassen'),
h('table', {}, ...rows.map(({ x, open, from, to }) => h('tr', {}, h('td', {}, WEEKDAYS[x.weekday]), h('td', {}, open), h('td', {}, from), h('td', {}, to)))),
row(h('button', { class: 'btn', onclick: () => run(() => post('business_hours.php', { action: 'save_hours', enabled: enabled.checked,
hours: rows.map(({ x, open, from, to }) => ({ weekday: x.weekday, isOpen: open.checked, openTime: from.value || '09:00', closeTime: to.value || '17:00' })) }), 'Gespeichert') }, 'Speichern'))),
h('h3', {}, 'Feiertage / Schließtage'),
h('div', { class: 'panel' }, row(hDate, hName, h('button', { class: 'btn', onclick: () => hDate.value && hName.value && run(async () => { await post('business_hours.php', { action: 'add_holiday', date: hDate.value, name: hName.value }); load(); }) }, 'Hinzufügen')),
...r.holidays.map((hd) => row(h('span', { class: 'grow' }, `${hd.date} – ${hd.name}`), delBtn(() => run(async () => { await post('business_hours.php', { action: 'delete_holiday', id: hd.id }); load(); }))))));
};
load();
}
// ─── E-Mail-Vorlagen ─────────────────────────────────────────────────────────
async function adminEmailTemplates(body, addBtn) {
addBtn.classList.add('hidden');
const load = async () => {
const r = await run(() => api('email_templates.php')); if (!r) return;
clear(body).append(table(['Vorlage', 'Angepasst'], r.items.map((t) => h('tr', { class: 'click', onclick: () => edit(t) }, h('td', {}, t.label), h('td', {}, t.isCustomized ? '✓' : '')))));
};
function edit(t) {
const subject = h('input', { value: t.subject }), text = h('textarea', { value: t.body, class: 'tall' });
modal(t.label, h('div', {}, h('label', {}, 'Betreff'), subject, h('label', {}, 'Text'), text,
h('div', { class: 'muted' }, 'Platzhalter: ' + t.placeholders.join(' '))),
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post('email_templates.php', { action: 'save', key: t.key, subject: subject.value, body: text.value }), 'Gespeichert')) { close(); load(); } } },
...(t.isCustomized ? [{ label: 'Zurücksetzen', cls: 'ghost', fn: async (close) => { if (confirm('Auf Standard zurücksetzen?') && await run(() => post('email_templates.php', { action: 'reset', key: t.key }), 'Zurückgesetzt')) { close(); load(); } } }] : [])]);
}
load();
}
// ─── Ticket-Regeln ───────────────────────────────────────────────────────────
async function adminTicketRules(body, addBtn) {
const load = async () => {
const r = await run(() => api('ticket_rules.php')); if (!r) return;
const label = (list, v) => (list.find((o) => o.value === v) || { label: v }).label;
const acEnabled = h('input', { type: 'checkbox', checked: r.autoCloseEnabled }), acDays = h('input', { type: 'number', min: 1, value: r.autoCloseDays, class: 'narrow' });
clear(body).append(
h('div', { class: 'panel' }, h('b', {}, 'Automatisch schließen'), row(h('label', { class: 'check' }, acEnabled, 'Beantwortete Tickets nach'), acDays, h('span', {}, 'Tagen schließen'),
h('button', { class: 'btn sm', onclick: () => run(() => post('ticket_rules.php', { action: 'save_auto_close', enabled: acEnabled.checked, days: +acDays.value || 7 }), 'Gespeichert') }, 'Speichern'))),
table(['Regel', 'Bedingungen', 'Aktionen', 'Aktiv', ''], r.rules.map((x) => h('tr', { class: 'click', onclick: () => edit(x, r) },
h('td', {}, x.name), h('td', { class: 'muted' }, x.conditions.map((c) => `${label(r.fieldOptions, c.field)} ${label(r.operatorOptions, c.operator)} „${c.value}“`).join(x.matchType === 'any' ? ' ODER ' : ' UND ')),
h('td', { class: 'muted' }, x.actions.map((a) => label(r.actionOptions, a.type) + (a.valueLabel ? `: ${a.valueLabel}` : '')).join(', ')),
h('td', {}, h('input', { type: 'checkbox', checked: x.isActive, onclick: (e) => e.stopPropagation(), onchange: () => run(async () => { await post('ticket_rules.php', { action: 'toggle', id: x.id }); load(); }) })),
h('td', {}, delBtn(() => run(async () => { await post('ticket_rules.php', { action: 'delete', id: x.id }); load(); }, 'Gelöscht')))))));
addBtn.onclick = () => edit(null, r);
};
function edit(x, r) {
const name = h('input', { value: x?.name || '' }), match = h('select', {}, ...opt([['all', 'Alle Bedingungen müssen zutreffen'], ['any', 'Mindestens eine Bedingung']], x?.matchType || 'all'));
const active = h('input', { type: 'checkbox', checked: x?.isActive ?? true }), sort = h('input', { type: 'number', value: x?.sortOrder ?? 0 });
const conds = h('div'), acts = h('div');
const valueSource = { set_department: r.departments, set_priority: r.priorities, set_help_topic: r.helpTopics, assign_staff: r.staffOptions, auto_reply: r.cannedResponses };
const addCond = (c) => { const rowEl = h('div', { class: 'row rule' }, h('select', { 'data-f': 'field' }, ...opt(r.fieldOptions.map((o) => [o.value, o.label]), c?.field)),
h('select', { 'data-f': 'operator' }, ...opt(r.operatorOptions.map((o) => [o.value, o.label]), c?.operator)), h('input', { 'data-f': 'value', value: c?.value || '', placeholder: 'Wert' }),
h('button', { class: 'btn ghost sm', onclick: () => rowEl.remove() }, '✕')); conds.append(rowEl); };
const addAct = (a) => {
const rowEl = h('div', { class: 'row rule' }), valHolder = h('span', { class: 'grow' });
const type = h('select', { 'data-f': 'type', onchange: () => paint(type.value, '') }, ...opt(r.actionOptions.map((o) => [o.value, o.label]), a?.type));
const paint = (t, v) => { clear(valHolder); if (t === 'reject') return; const src = valueSource[t];
valHolder.append(src ? h('select', { 'data-f': 'value' }, ...opt(named(src), v)) : h('input', { 'data-f': 'value', value: v || '', placeholder: 'Wert' })); };
rowEl.append(type, valHolder, h('button', { class: 'btn ghost sm', onclick: () => rowEl.remove() }, '✕')); acts.append(rowEl); paint(type.value, a?.value);
};
(x?.conditions?.length ? x.conditions : [null]).forEach(addCond); (x?.actions?.length ? x.actions : [null]).forEach(addAct);
const collect = (box) => [...box.children].map((el) => Object.fromEntries([...el.querySelectorAll('[data-f]')].map((i) => [i.dataset.f, i.value])));
modal(x ? 'Regel bearbeiten' : 'Neue Ticket-Regel', h('div', {}, h('label', {}, 'Name'), name, h('label', {}, 'Verknüpfung'), match, h('label', { class: 'check' }, active, 'Aktiv'), h('label', {}, 'Reihenfolge'), sort,
h('h3', {}, 'Wenn …'), conds, h('button', { class: 'btn ghost sm', onclick: () => addCond(null) }, '+ Bedingung'), h('h3', {}, 'Dann …'), acts, h('button', { class: 'btn ghost sm', onclick: () => addAct(null) }, '+ Aktion')),
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post('ticket_rules.php', { action: 'save', id: x?.id ?? null, name: name.value, matchType: match.value, isActive: active.checked, sortOrder: +sort.value || 0,
conditions: collect(conds).map((c) => ({ field: c.field, operator: c.operator, value: c.value || '' })), actions: collect(acts).map((a) => ({ type: a.type, value: a.value || '' })) }), 'Gespeichert')) { close(); load(); } } }]);
}
load();
}
ADMIN.push(
{ key: 'teams', label: 'Teams', perm: 'manage_teams', custom: adminTeams, add: true },
{ key: 'roles', label: 'Rollen', perm: 'manage_roles', custom: adminRoles, add: true },
{ key: 'escalations', label: 'Eskalationsregeln', perm: 'manage_escalations', custom: adminEscalations, add: true },
{ key: 'business_hours', label: 'Geschäftszeiten', perm: 'manage_business_hours', custom: adminBusinessHours },
{ key: 'email_templates', label: 'E-Mail-Vorlagen', perm: 'manage_email_templates', custom: adminEmailTemplates },
{ key: 'ticket_rules', label: 'Ticket-Regeln', admin: true, custom: adminTicketRules, add: true },
);