Upload via GUI (74 Dateien)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deskhub",
|
||||
"productName": "DeskHub",
|
||||
"version": "2.10.2",
|
||||
"version": "2.11.0",
|
||||
"description": "DeskHub Desktop-Client für Mitarbeiter",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
@@ -10,14 +10,18 @@
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test": "electron test/run.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js"
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||
"build:linux": "electron-builder --linux",
|
||||
"build:mac": "electron-builder --mac"
|
||||
},
|
||||
"build": {
|
||||
"appId": "de.deskhub.desktop",
|
||||
"productName": "DeskHub",
|
||||
"artifactName": "DeskHub.${ext}",
|
||||
"files": ["src/**/*", "package.json", "LICENSE"],
|
||||
"win": { "target": "dir", "icon": "src/assets/icon.ico" }
|
||||
"win": { "target": "dir", "icon": "src/assets/icon.ico" },
|
||||
"linux": { "target": ["AppImage", "tar.gz"], "icon": "src/assets/icon.png", "extraFiles": [{ "from": "scripts/linux/start-deskhub.sh", "to": "start-deskhub.sh" }], "category": "Office", "artifactName": "DeskHub-${version}-linux-${arch}.${ext}", "synopsis": "DeskHub Desktop-Client für Mitarbeiter" },
|
||||
"mac": { "target": ["dmg", "zip"], "icon": "src/assets/icon.png", "category": "public.app-category.business", "artifactName": "DeskHub-${version}-mac-${arch}.${ext}" }
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^33.2.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Startet DeskHub. Ein unter Windows gebautes Archiv enthält keine Ausführungsrechte; sie werden hier beim ersten Start gesetzt.
|
||||
# Aufruf (auch ohne Ausführungsrecht): sh start-deskhub.sh
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
chmod +x deskhub 2>/dev/null
|
||||
[ -f chrome-sandbox ] && chmod 755 chrome-sandbox 2>/dev/null
|
||||
exec ./deskhub "$@"
|
||||
+21
-5
@@ -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
@@ -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(); });
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user