Upload via GUI (69 Dateien)

This commit is contained in:
2026-10-16 18:53:05 +00:00
parent 7d82ca66ac
commit 2e17e2ecb0
19 changed files with 3355 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
'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 },
);