Upload via GUI (74 Dateien)

This commit is contained in:
2026-10-26 21:20:34 +00:00
parent 5ffdb4fc1b
commit 7dac91fa35
6 changed files with 59 additions and 13 deletions
+21 -5
View File
@@ -1,4 +1,6 @@
'use strict';
// Mac und Linux: normaler Fensterrahmen statt eigener Titelleiste (nur Windows hat sie)
if (!/Windows/i.test(navigator.userAgent)) document.documentElement.classList.add('native-frame');
// append() soll null/false/Arrays wie h() behandeln (sonst erscheint der Text "null")
const _append = Element.prototype.append;
Element.prototype.append = function (...a) { return _append.apply(this, a.flat(Infinity).filter((x) => x != null && x !== false)); };
@@ -393,7 +395,13 @@ async function vTickets(main, preset) {
const paintBulk = () => {
bulk.classList.toggle('hidden', selected.size === 0); clear(bulk);
if (!selected.size) return;
const bulkResolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']];
const act = async (patch, label) => {
if (['resolved', 'closed'].includes(patch.status)) {
const code = prompt('Abschlussgrund: ' + bulkResolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed');
if (!bulkResolutionOptions.some(([v]) => v === code)) return;
patch = { ...patch, resolutionCode: code, resolutionNote: prompt('Lösungsnotiz (optional):', '') || '' };
}
let ok = 0; const fails = [];
for (const number of [...selected]) { try { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); ok++; } catch (e) { fails.push(`${number}: ${e.message}`); } }
toast(`${label}: ${ok} Ticket(s)` + (fails.length ? ` · ${fails.length} Fehler (${fails[0]})` : ''), !!fails.length);
@@ -463,11 +471,13 @@ async function vTicket(main, number) {
const can = (p) => (t.permissions ? t.permissions[p] === true : (state.staff.role === 'admin' || state.staff.permissions?.[p] === true));
const reload = () => go('ticket', number);
const update = (patch, msg) => run(async () => { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); reload(); }, msg);
const resolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']];
const closeWithReason = (status) => { const code = prompt('Abschlussgrund: ' + resolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed'); if (!resolutionOptions.some(([v]) => v === code)) return; const note = prompt('Lösungsnotiz (optional):', '') || ''; update({ status, resolutionCode: code, resolutionNote: note }, status === 'resolved' ? 'Gelöst' : 'Geschlossen'); };
const mkSelect = (opts, val, onchange, disabled) => h('select', { disabled, onchange: (e) => onchange(e.target.value) }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val) }, l)));
const mine = t.assignedStaff?.id === state.staff.id;
const closed = ['resolved', 'closed'].includes(t.status);
const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket'));
const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => ['resolved', 'closed'].includes(v) ? closeWithReason(v) : update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket'));
const assignSel = mkSelect([['', '— niemand —'], ...state.meta.agents.map((a) => [a.id, a.name])], t.assignedStaff?.id ?? '', (v) => update({ assignedStaffId: v === '' ? null : Number(v) }, 'Zuweisung geändert'), !can('assign_ticket'));
// Antwort-Editor mit Entwurf
@@ -478,6 +488,8 @@ async function vTicket(main, number) {
ta.addEventListener('input', () => ta.value ? store.set(draftKey(mode), ta.value) : store.del(draftKey(mode)));
const tabs = h('div', { class: 'tabs' });
const sendStatus = mkSelect([['', 'Status: automatisch'], ...state.meta.statuses.map((s) => [s.value, 'Setzen auf: ' + s.label])], '', () => {});
const sendResolution = mkSelect([['', 'Abschlussgrund wählen'], ...resolutionOptions], '', () => {});
const resolutionNote = h('input', { placeholder: 'Lösungsnotiz (optional)' });
let cannedList = [];
const canned = h('select', { onchange: (e) => { const c = cannedList.find((x) => String(x.id) === e.target.value); if (c) { ta.value += (ta.value ? '\n' : '') + c.body.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '').trim(); ta.dispatchEvent(new Event('input')); ta.focus(); } e.target.value = ''; } }, h('option', { value: '' }, 'Textbaustein…'));
// Nur Textbausteine der Abteilung des Tickets und die für alle Abteilungen (ältere Server liefern alle: dann hier filtern)
@@ -524,16 +536,20 @@ async function vTicket(main, number) {
if (can('reply_ticket')) tabs.append(h('button', { class: 'tab' + (mode === 'reply' ? ' active' : ''), onclick: () => { mode = 'reply'; loadDraft(); paintTabs(); } }, 'Antwort an Kunden'));
if (can('post_note')) tabs.append(h('button', { class: 'tab' + (mode === 'note' ? ' active' : ''), onclick: () => { mode = 'note'; loadDraft(); paintTabs(); } }, 'Interne Notiz'));
sendStatus.classList.toggle('hidden', mode !== 'reply');
sendResolution.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value));
resolutionNote.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value));
sigBox?.classList.toggle('hidden', mode !== 'reply');
sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern';
ta.classList.toggle('noteedit', mode === 'note');
};
paintTabs(); loadDraft();
sendStatus.addEventListener('change', () => paintTabs());
const send = async () => {
if (!ta.value.trim() || sendBtn.disabled) return;
if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return;
sendBtn.disabled = true;
const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert');
if (mode === 'reply' && ['resolved', 'closed'].includes(sendStatus.value) && !resolutionOptions.some(([v]) => v === sendResolution.value)) { toast('Bitte Abschlussgrund auswählen.', true); sendBtn.disabled = false; return; }
const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined, resolutionCode: sendResolution.value || null, resolutionNote: resolutionNote.value }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert');
sendBtn.disabled = false;
if (ok) { store.del(draftKey(mode)); reload(); }
};
@@ -561,8 +577,8 @@ async function vTicket(main, number) {
const actions = h('div', { class: 'tkactions' },
can('assign_ticket') && !mine ? h('button', { class: 'btn sm', onclick: () => update({ assignedStaffId: state.staff.id }, 'Dir zugewiesen') }, 'Mir zuweisen') : null,
!closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => update({ status: 'resolved' }, 'Gelöst') }, 'Lösen') : null,
!closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => update({ status: 'closed' }, 'Geschlossen') }, 'Schließen') : null,
!closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => closeWithReason('resolved') }, 'Lösen') : null,
!closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => closeWithReason('closed') }, 'Schließen') : null,
closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null,
t.snoozedUntil
? h('button', { class: 'btn ghost sm', onclick: () => update({ snoozeUntil: null }, 'Wiedervorlage aufgehoben') }, 'Wiedervorlage aufheben')
@@ -609,7 +625,7 @@ async function vTicket(main, number) {
t.canUnlock ? h('button', { class: 'btn sm', onclick: () => run(async () => { await post('ticket_unlock.php', { number }); reload(); }, 'Sperre aufgehoben') }, 'Sperre aufheben und übernehmen') : null) : null,
t.lockedByOtherName ? h('div', { class: 'warnbox' }, `Dieser Chat wird gerade von „${t.lockedByOtherName}“ bearbeitet – nur Ansicht.`) : null,
h('div', { class: 'split' },
h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null),
h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, sendResolution, resolutionNote, h('span', { class: 'grow' }), sendBtn)) : null),
h('div', {},
h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'),
h('dl', { class: 'props' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+21 -5
View File
@@ -3,6 +3,9 @@ const path = require('path');
const fs = require('fs');
app.setAppUserModelId('de.deskhub.desktop');
// Windows hat eine eigene Titelleiste und ein .ico; Mac und Linux nutzen den normalen Fensterrahmen und ein PNG
const IS_WIN = process.platform === 'win32', IS_MAC = process.platform === 'darwin', IS_LINUX = process.platform === 'linux';
const ICON = path.join(__dirname, 'assets', IS_WIN ? 'icon.ico' : 'icon.png');
let win = null;
const sessionFile = () => path.join(app.getPath('userData'), 'session.bin');
@@ -18,7 +21,7 @@ function loadSession() {
function saveSession() {
if (!session) { try { fs.unlinkSync(sessionFile()); } catch {} return; }
const json = JSON.stringify(session);
fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json);
fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json, { mode: 0o600 });
}
function normalizeUrl(input) {
@@ -97,6 +100,7 @@ function isNewer(remote, local) {
}
let pendingUpdate = null;
ipcMain.handle('update:check', async () => {
if (!IS_WIN) return { data: { available: false, packaged: false, current: app.getVersion(), unsupported: true } }; // das veröffentlichte Setup ist nur für Windows
try {
const r = await callApi({ path: 'app_update.php', query: { platform: 'windows' } });
const d = r.data;
@@ -106,6 +110,7 @@ ipcMain.handle('update:check', async () => {
} catch (e) { return { error: { message: e.message || String(e) } }; }
});
ipcMain.handle('update:install', async () => {
if (!IS_WIN) return { error: { message: 'Das Update ist nur für Windows verfügbar.' } };
if (!pendingUpdate || !session) return { error: { message: 'Keine Update-Information vorhanden.' } };
try {
const url = new URL(`${session.serverUrl}/api/staff/v1/app_update.php`);
@@ -163,6 +168,17 @@ function loadSettings() { try { settings = { ...DEFAULTS, ...JSON.parse(fs.readF
function saveSettings() { try { fs.writeFileSync(settingsFile(), JSON.stringify(settings)); } catch {} }
function applyAutostart() {
if (!app.isPackaged) return;
if (IS_LINUX) { // Electron kennt dort kein setLoginItemSettings: ~/.config/autostart/deskhub.desktop (bei AppImage zeigt APPIMAGE auf die Datei)
const file = path.join(app.getPath('home'), '.config', 'autostart', 'deskhub.desktop');
try {
if (settings.autostart) {
const exe = String(process.env.APPIMAGE || process.execPath).replace(/["\r\n]/g, '');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `[Desktop Entry]\nType=Application\nName=DeskHub\nExec="${exe}" --hidden\nIcon=deskhub\nX-GNOME-Autostart-enabled=true\n`);
} else fs.rmSync(file, { force: true });
} catch { /* ohne Schreibrecht kein Autostart */ }
return;
}
app.setLoginItemSettings({ openAtLogin: !!settings.autostart, args: settings.autostart ? ['--hidden'] : [] });
}
ipcMain.handle('settings:get', () => ({ ...settings, version: app.getVersion(), packaged: app.isPackaged }));
@@ -191,7 +207,7 @@ function trayMenu() {
]);
}
function createTray() {
const img = nativeImage.createFromPath(path.join(__dirname, 'assets', 'icon.ico'));
const img = nativeImage.createFromPath(ICON);
tray = new Tray(img.resize({ width: 16, height: 16 }));
tray.setToolTip('DeskHub');
tray.setContextMenu(trayMenu());
@@ -220,8 +236,8 @@ function createWindow() {
const b = settings.bounds || {};
win = new BrowserWindow({
width: b.width || 1320, height: b.height || 840, x: b.x, y: b.y, minWidth: 960, minHeight: 620,
show: false, titleBarStyle: 'hidden', titleBarOverlay: overlayFor(resolvedTheme()), backgroundColor: resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d', title: 'DeskHub', autoHideMenuBar: true,
icon: path.join(__dirname, 'assets', 'icon.ico'),
show: false, ...(IS_WIN ? { titleBarStyle: 'hidden', titleBarOverlay: overlayFor(resolvedTheme()) } : {}), backgroundColor: resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d', title: 'DeskHub', autoHideMenuBar: true,
icon: ICON,
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false },
});
if (b.maximized) win.maximize();
@@ -244,6 +260,6 @@ if (!lock) app.quit();
else {
app.on('second-instance', () => showWindow());
app.on('before-quit', () => { quitting = true; });
app.whenReady().then(() => { Menu.setApplicationMenu(null); loadSettings(); loadSession(); createWindow(); setImmediate(() => { createTray(); applyAutostart(); }); });
app.whenReady().then(() => { Menu.setApplicationMenu(IS_MAC ? Menu.buildFromTemplate([{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]) : null); loadSettings(); loadSession(); createWindow(); setImmediate(() => { createTray(); applyAutostart(); }); });
app.on('window-all-closed', () => { if (quitting || !settings.closeToTray) app.quit(); });
}
+3
View File
@@ -250,3 +250,6 @@ tr.totalrow td { font-weight: 700; background: var(--panel2); border-top: 2px so
.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; }
/* Mac und Linux: normaler Fensterrahmen, keine eigene Titelleiste und kein Platz für die Windows-Fensterknöpfe */
html.native-frame #titlebar { display: none; } html.native-frame #app { height: 100%; margin-top: 0; } html.native-frame .topbar { padding-right: 18px; }