Upload via GUI (396 Dateien)
This commit is contained in:
@@ -198,6 +198,33 @@
|
|||||||
imgBtn.addEventListener('click', () => fileInput.click());
|
imgBtn.addEventListener('click', () => fileInput.click());
|
||||||
toolbar.appendChild(imgBtn);
|
toolbar.appendChild(imgBtn);
|
||||||
toolbar.appendChild(fileInput);
|
toolbar.appendChild(fileInput);
|
||||||
|
|
||||||
|
// Bild aus der Zwischenablage (Strg + V, zum Beispiel ein Screenshot) oder per Ziehen in das Feld: derselbe Weg wie der Knopf.
|
||||||
|
// Enthält die Zwischenablage auch Text (Kopie aus Word, Webseite), bleibt das normale Einfügen unverändert.
|
||||||
|
const imageFrom = (list) => Array.from(list || []).find((f) => /^image\/(png|jpe?g|gif)$/i.test(f.type));
|
||||||
|
const uploadPicked = (file) => {
|
||||||
|
const ext = file.type.toLowerCase() === 'image/jpeg' ? 'jpg' : file.type.split('/')[1].toLowerCase();
|
||||||
|
const named = /\.(png|jpe?g|gif)$/i.test(file.name || '') ? file : new File([file], 'screenshot-' + Date.now() + '.' + ext, { type: file.type });
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(named);
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
fileInput.dispatchEvent(new Event('change'));
|
||||||
|
};
|
||||||
|
editable.addEventListener('paste', (e) => {
|
||||||
|
const cd = e.clipboardData;
|
||||||
|
if (!cd || Array.from(cd.types || []).some((t) => t === 'text/plain' || t === 'text/html')) return;
|
||||||
|
const img = imageFrom(cd.files);
|
||||||
|
if (!img) return;
|
||||||
|
e.preventDefault();
|
||||||
|
uploadPicked(img);
|
||||||
|
});
|
||||||
|
editable.addEventListener('dragover', (e) => { if (Array.from((e.dataTransfer && e.dataTransfer.types) || []).includes('Files')) e.preventDefault(); });
|
||||||
|
editable.addEventListener('drop', (e) => {
|
||||||
|
const img = imageFrom(e.dataTransfer && e.dataTransfer.files);
|
||||||
|
if (!img) return;
|
||||||
|
e.preventDefault();
|
||||||
|
uploadPicked(img);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
editable.addEventListener('input', () => { textarea.value = editable.innerHTML; });
|
editable.addEventListener('input', () => { textarea.value = editable.innerHTML; });
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Tastenkürzel im Mitarbeiterbereich.
|
||||||
|
*
|
||||||
|
* Ticketliste: j / k = Ticket wählen, Enter oder o = öffnen, r = öffnen und gleich antworten, x = Auswahlfeld, / = Suche, n = neues Ticket
|
||||||
|
* Ticket: j / k = nächste / vorherige Nachricht, r = Antwort schreiben, u = zurück zur Liste, / = Suche in der Liste
|
||||||
|
* Überall: ? = Übersicht, Esc = schließen. Strg + Enter im Antwortfeld sendet die Antwort.
|
||||||
|
*
|
||||||
|
* Die Kürzel gelten nur, wenn kein Eingabefeld aktiv ist und keine Strg-, Alt- oder Meta-Taste gedrückt wird. Wer sie nicht möchte,
|
||||||
|
* schaltet sie in der Übersicht (?) ab; die Wahl bleibt im Browser (localStorage).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (window.__deskhubShortcuts) return;
|
||||||
|
window.__deskhubShortcuts = true;
|
||||||
|
|
||||||
|
var EN = /^en/i.test(document.documentElement.lang || '');
|
||||||
|
// Die Seite wird serverseitig übersetzt, Texte aus diesem Skript nicht: deshalb hier die englische Fassung
|
||||||
|
var TXT = EN ? {
|
||||||
|
title: 'Keyboard shortcuts', nextMsg: 'Next / previous message', nextTicket: 'Next / previous ticket', open: 'Open the selected ticket (list)',
|
||||||
|
reply: 'Reply (in the list: open and select the reply field)', tick: 'Tick the selected ticket (list)', search: 'Search', create: 'New ticket',
|
||||||
|
back: 'Back to the ticket list (in a ticket)', send: 'Send the reply (in the reply field)', help: 'This overview', use: 'Use keyboard shortcuts (this browser only)',
|
||||||
|
enter: 'Enter or o', ctrl: 'Ctrl + Enter'
|
||||||
|
} : {
|
||||||
|
title: 'Tastenkürzel', nextMsg: 'Nächste / vorherige Nachricht', nextTicket: 'Nächstes / vorheriges Ticket', open: 'Gewähltes Ticket öffnen (Liste)',
|
||||||
|
reply: 'Antworten (in der Liste: öffnen und Antwortfeld wählen)', tick: 'Gewähltes Ticket ankreuzen (Liste)', search: 'Suche', create: 'Neues Ticket',
|
||||||
|
back: 'Zurück zur Ticketliste (im Ticket)', send: 'Antwort senden (im Antwortfeld)', help: 'Diese Übersicht', use: 'Tastenkürzel verwenden (nur in diesem Browser)',
|
||||||
|
enter: 'Enter oder o', ctrl: 'Strg + Enter'
|
||||||
|
};
|
||||||
|
var KEY = 'deskhub_shortcuts_off';
|
||||||
|
var path = location.pathname;
|
||||||
|
var onList = /\/staff\/tickets\.php$/.test(path);
|
||||||
|
var onTicket = /\/staff\/ticket_view\.php$/.test(path);
|
||||||
|
var cursor = -1;
|
||||||
|
var help = null;
|
||||||
|
|
||||||
|
function disabled() { try { return localStorage.getItem(KEY) === '1'; } catch (e) { return false; } }
|
||||||
|
function setDisabled(v) { try { if (v) localStorage.setItem(KEY, '1'); else localStorage.removeItem(KEY); } catch (e) { /* ohne Speicher */ } }
|
||||||
|
|
||||||
|
function typing(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
var tag = (el.tagName || '').toLowerCase();
|
||||||
|
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows() { return Array.prototype.slice.call(document.querySelectorAll('table tbody tr')).filter(function (tr) { return tr.querySelector('a.ticket-no'); }); }
|
||||||
|
function messages() { return Array.prototype.slice.call(document.querySelectorAll('.thread-msg')); }
|
||||||
|
|
||||||
|
function mark(list, i) {
|
||||||
|
list.forEach(function (el) { el.classList.remove('kb-active'); });
|
||||||
|
if (i < 0 || i >= list.length) return;
|
||||||
|
var el = list[i];
|
||||||
|
el.classList.add('kb-active');
|
||||||
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(list, delta) {
|
||||||
|
if (!list.length) return;
|
||||||
|
var current = list.findIndex(function (el) { return el.classList.contains('kb-active'); });
|
||||||
|
cursor = current < 0 ? (delta > 0 ? 0 : list.length - 1) : Math.max(0, Math.min(list.length - 1, current + delta));
|
||||||
|
mark(list, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeRow() { return rows().filter(function (tr) { return tr.classList.contains('kb-active'); })[0] || null; }
|
||||||
|
|
||||||
|
function focusReply() {
|
||||||
|
var field = document.getElementById('message-field');
|
||||||
|
if (!field) return false;
|
||||||
|
var wrap = field.parentNode;
|
||||||
|
var editable = wrap && wrap.querySelector('[contenteditable]');
|
||||||
|
var target = editable || field;
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
target.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHelp() { if (help) { help.remove(); help = null; } }
|
||||||
|
|
||||||
|
function showHelp() {
|
||||||
|
if (help) return closeHelp();
|
||||||
|
var items = [
|
||||||
|
['j / k', onTicket ? TXT.nextMsg : TXT.nextTicket],
|
||||||
|
[TXT.enter, TXT.open],
|
||||||
|
['r', TXT.reply],
|
||||||
|
['x', TXT.tick],
|
||||||
|
['/', TXT.search],
|
||||||
|
['n', TXT.create],
|
||||||
|
['u', TXT.back],
|
||||||
|
[TXT.ctrl, TXT.send],
|
||||||
|
['?', TXT.help],
|
||||||
|
];
|
||||||
|
help = document.createElement('div');
|
||||||
|
help.setAttribute('role', 'dialog');
|
||||||
|
help.setAttribute('aria-label', TXT.title);
|
||||||
|
help.style.cssText = 'position:fixed;inset:0;z-index:2147483600;background:rgba(15,23,42,.55);display:flex;align-items:center;justify-content:center;padding:16px';
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.style.cssText = 'background:var(--surface,#fff);color:var(--ink,#0f172a);border-radius:12px;padding:20px 24px;max-width:460px;width:100%;box-shadow:0 20px 50px rgba(0,0,0,.35);font:14px/1.5 system-ui,sans-serif';
|
||||||
|
var h = document.createElement('div');
|
||||||
|
h.style.cssText = 'font-weight:700;font-size:1.05rem;margin-bottom:10px';
|
||||||
|
h.textContent = TXT.title;
|
||||||
|
box.appendChild(h);
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.style.cssText = 'display:flex;gap:12px;padding:4px 0;border-top:1px solid rgba(148,163,184,.25)';
|
||||||
|
var k = document.createElement('kbd');
|
||||||
|
k.style.cssText = 'min-width:104px;font:600 12px ui-monospace,monospace;background:rgba(148,163,184,.2);border-radius:5px;padding:2px 7px;align-self:flex-start';
|
||||||
|
k.textContent = it[0];
|
||||||
|
var d = document.createElement('span');
|
||||||
|
d.textContent = it[1];
|
||||||
|
row.appendChild(k);
|
||||||
|
row.appendChild(d);
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
var label = document.createElement('label');
|
||||||
|
label.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:14px;font-weight:400';
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.style.width = 'auto';
|
||||||
|
cb.checked = !disabled();
|
||||||
|
cb.addEventListener('change', function () { setDisabled(!cb.checked); });
|
||||||
|
label.appendChild(cb);
|
||||||
|
label.appendChild(document.createTextNode(TXT.use));
|
||||||
|
box.appendChild(label);
|
||||||
|
help.appendChild(box);
|
||||||
|
help.addEventListener('click', function (e) { if (e.target === help) closeHelp(); });
|
||||||
|
document.body.appendChild(help);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auffälliger Rahmen für die gewählte Zeile bzw. Nachricht
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = '.kb-active{outline:2px solid var(--primary,#0f6f6a);outline-offset:-2px;background:rgba(15,111,106,.07)}';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
// Strg + Enter im Antwortfeld sendet die öffentliche Antwort (unabhängig von den Einzeltasten)
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && onTicket && !disabled()) {
|
||||||
|
var editable = e.target && e.target.closest ? e.target.closest('.richtext-editable') : null;
|
||||||
|
if (editable) {
|
||||||
|
var form = editable.closest('form');
|
||||||
|
var action = form && form.querySelector('#form-action');
|
||||||
|
if (form && action && action.value === 'reply') {
|
||||||
|
e.preventDefault();
|
||||||
|
var ta = form.querySelector('textarea[data-richtext]');
|
||||||
|
if (ta) ta.value = editable.innerHTML;
|
||||||
|
if (form.requestSubmit) form.requestSubmit(); else form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') { if (help) { closeHelp(); e.preventDefault(); } else if (typing(e.target)) e.target.blur(); return; }
|
||||||
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
if (typing(e.target)) return;
|
||||||
|
if (e.key === '?') { e.preventDefault(); showHelp(); return; }
|
||||||
|
if (disabled() || help) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'j': case 'k':
|
||||||
|
if (onList) { e.preventDefault(); move(rows(), e.key === 'j' ? 1 : -1); }
|
||||||
|
else if (onTicket) { e.preventDefault(); move(messages(), e.key === 'j' ? 1 : -1); }
|
||||||
|
break;
|
||||||
|
case 'Enter': case 'o': {
|
||||||
|
var row = onList ? activeRow() : null;
|
||||||
|
var link = row && row.querySelector('a.ticket-no');
|
||||||
|
if (link) { e.preventDefault(); location.href = link.href; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'x': {
|
||||||
|
var r = onList ? activeRow() : null;
|
||||||
|
var box = r && r.querySelector('input.ticket-check');
|
||||||
|
if (box) { e.preventDefault(); box.checked = !box.checked; box.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'r':
|
||||||
|
if (onTicket) { if (focusReply()) e.preventDefault(); }
|
||||||
|
else if (onList) {
|
||||||
|
var rr = activeRow();
|
||||||
|
var a = rr && rr.querySelector('a.ticket-no');
|
||||||
|
if (a) { e.preventDefault(); location.href = a.href + '#reply'; }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '/': {
|
||||||
|
var search = document.querySelector('input[name="q"]');
|
||||||
|
if (search) { e.preventDefault(); search.focus(); search.select(); }
|
||||||
|
else { e.preventDefault(); location.href = '/staff/tickets.php#search'; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'n':
|
||||||
|
e.preventDefault();
|
||||||
|
location.href = '/staff/ticket_create.php';
|
||||||
|
break;
|
||||||
|
case 'u':
|
||||||
|
if (onTicket) { e.preventDefault(); location.href = '/staff/tickets.php'; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (disabled()) return;
|
||||||
|
if (location.hash === '#reply' && onTicket) setTimeout(focusReply, 150);
|
||||||
|
if (location.hash === '#search' && onList) {
|
||||||
|
var s = document.querySelector('input[name="q"]');
|
||||||
|
if (s) { s.focus(); s.select(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Bildvorschau für Anhänge im Ticket: Ein Klick auf ein Vorschaubild zeigt das Bild groß über der Seite, ohne es herunterzuladen.
|
||||||
|
* Pfeil links/rechts (oder die Knöpfe) blättern durch alle Bilder des Tickets, Esc oder ein Klick daneben schließt. Ohne JavaScript
|
||||||
|
* öffnet der Link das Bild wie bisher in einem neuen Tab.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var links = Array.prototype.slice.call(document.querySelectorAll('a[data-lightbox]'));
|
||||||
|
if (!links.length) return;
|
||||||
|
|
||||||
|
var EN = /^en/i.test(document.documentElement.lang || '');
|
||||||
|
var TXT = EN ? { dialog: 'Image preview', prev: 'Previous image (←)', next: 'Next image (→)', download: '⬇ Download', close: '✕ Close', closeTitle: 'Close (Esc)' }
|
||||||
|
: { dialog: 'Bildvorschau', prev: 'Vorheriges Bild (←)', next: 'Nächstes Bild (→)', download: '⬇ Herunterladen', close: '✕ Schließen', closeTitle: 'Schließen (Esc)' };
|
||||||
|
var overlay = null, img = null, caption = null, counter = null, index = 0;
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', TXT.dialog);
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483500;background:rgba(2,6,23,.88);display:none;flex-direction:column;align-items:center;justify-content:center;padding:16px;gap:10px';
|
||||||
|
img = document.createElement('img');
|
||||||
|
img.alt = '';
|
||||||
|
img.style.cssText = 'max-width:96vw;max-height:80vh;object-fit:contain;border-radius:6px;background:#fff;box-shadow:0 10px 40px rgba(0,0,0,.5)';
|
||||||
|
caption = document.createElement('div');
|
||||||
|
caption.style.cssText = 'color:#e2e8f0;font:14px system-ui,sans-serif;text-align:center;max-width:90vw;word-break:break-all';
|
||||||
|
counter = document.createElement('span');
|
||||||
|
counter.style.cssText = 'color:#94a3b8;margin-left:8px';
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:center';
|
||||||
|
function btn(label, title, fn) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.textContent = label;
|
||||||
|
b.title = title;
|
||||||
|
b.style.cssText = 'background:#1e293b;color:#fff;border:1px solid #475569;border-radius:8px;padding:7px 14px;cursor:pointer;font:600 14px system-ui,sans-serif';
|
||||||
|
b.addEventListener('click', function (e) { e.stopPropagation(); fn(); });
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
var download = document.createElement('a');
|
||||||
|
download.textContent = TXT.download;
|
||||||
|
download.style.cssText = 'background:#1e293b;color:#fff;border:1px solid #475569;border-radius:8px;padding:7px 14px;text-decoration:none;font:600 14px system-ui,sans-serif';
|
||||||
|
download.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
overlay._download = download;
|
||||||
|
bar.appendChild(btn('‹', TXT.prev, function () { show(index - 1); }));
|
||||||
|
bar.appendChild(btn('›', TXT.next, function () { show(index + 1); }));
|
||||||
|
bar.appendChild(download);
|
||||||
|
bar.appendChild(btn(TXT.close, TXT.closeTitle, close));
|
||||||
|
caption.appendChild(document.createTextNode(''));
|
||||||
|
overlay.appendChild(img);
|
||||||
|
overlay.appendChild(caption);
|
||||||
|
overlay.appendChild(bar);
|
||||||
|
overlay.addEventListener('click', close);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(i) {
|
||||||
|
if (!links.length) return;
|
||||||
|
index = (i + links.length) % links.length;
|
||||||
|
var a = links[index];
|
||||||
|
img.src = a.getAttribute('href');
|
||||||
|
img.alt = a.getAttribute('title') || '';
|
||||||
|
caption.textContent = a.getAttribute('title') || '';
|
||||||
|
counter.textContent = links.length > 1 ? '(' + (index + 1) + ' / ' + links.length + ')' : '';
|
||||||
|
caption.appendChild(counter);
|
||||||
|
overlay._download.href = a.getAttribute('data-download') || a.getAttribute('href');
|
||||||
|
overlay._download.setAttribute('download', a.getAttribute('title') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(i) {
|
||||||
|
if (!overlay) build();
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
show(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
img.removeAttribute('src');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
links.forEach(function (a, i) {
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return; // neuer Tab bleibt möglich
|
||||||
|
e.preventDefault();
|
||||||
|
open(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!overlay || overlay.style.display === 'none') return;
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); show(index - 1); }
|
||||||
|
else if (e.key === 'ArrowRight') { e.preventDefault(); show(index + 1); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -67,11 +67,15 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
|
|||||||
// Tickets, die nicht mehr überfällig sind (z.B. weil sie gelöst wurden), zurücksetzen
|
// Tickets, die nicht mehr überfällig sind (z.B. weil sie gelöst wurden), zurücksetzen
|
||||||
$pdo->exec("UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE status IN ('resolved','closed') AND is_overdue = 1");
|
$pdo->exec("UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE status IN ('resolved','closed') AND is_overdue = 1");
|
||||||
|
|
||||||
|
// Erinnerung an Bearbeiter: Ticket wartet seit mehreren Arbeitsstunden auf Antwort, bevor die Frist reißt
|
||||||
|
$waitReminders = wait_reminders_run($pdo, $now);
|
||||||
|
foreach ($waitReminders['log'] as $line) $log[] = $line;
|
||||||
|
|
||||||
set_setting('cron_check_sla_last_run', $now);
|
set_setting('cron_check_sla_last_run', $now);
|
||||||
|
|
||||||
$log[] = "[" . date('Y-m-d H:i:s') . "] SLA-Prüfung abgeschlossen. " . count($overdue) . " überfällige Tickets insgesamt, davon {$newlyOverdue} neu erkannt.";
|
$log[] = "[" . date('Y-m-d H:i:s') . "] SLA-Prüfung abgeschlossen. " . count($overdue) . " überfällige Tickets insgesamt, davon {$newlyOverdue} neu erkannt.";
|
||||||
|
|
||||||
return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'log' => $log];
|
return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'waitReminders' => $waitReminders['sent'], 'log' => $log];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (php_sapi_name() === 'cli') {
|
if (php_sapi_name() === 'cli') {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_respons
|
|||||||
|
|
||||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
require_once __DIR__ . '/../includes/mail_text.php';
|
require_once __DIR__ . '/../includes/mail_text.php';
|
||||||
|
require_once __DIR__ . '/../includes/mail_threading.php';
|
||||||
|
|
||||||
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
|
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
|
||||||
function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void
|
function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void
|
||||||
@@ -194,17 +195,23 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
|||||||
if (trim($body) === '') $body = '(kein Inhalt)';
|
if (trim($body) === '') $body = '(kein Inhalt)';
|
||||||
|
|
||||||
// ── Antwort auf bestehendes Ticket? ──────────────────────────────────
|
// ── Antwort auf bestehendes Ticket? ──────────────────────────────────
|
||||||
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
|
// Zuordnung über die Nummer im Betreff, eine andere Nummer (früheres Ticketsystem) oder die Kopfzeilen In-Reply-To/References
|
||||||
$ticketNumber = $m[1];
|
$replyMatch = mail_find_ticket_for_reply($pdo, $subject, (string) @imap_fetchheader($mbox, $emailNum), (string) $fromEmail);
|
||||||
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
|
if ($replyMatch) {
|
||||||
$stmt->execute([$ticketNumber]);
|
$ticket = $replyMatch['ticket'];
|
||||||
$ticket = $stmt->fetch();
|
$ticketNumber = (string) $ticket['ticket_number'];
|
||||||
|
$replyVia = ['subject' => '', 'legacy' => ' – zugeordnet über die Nummer im Betreff', 'header' => ' – zugeordnet über die Mail-Kopfzeilen'][$replyMatch['via']] ?? '';
|
||||||
|
|
||||||
if ($ticket) {
|
if ($ticket) {
|
||||||
$now = db_now();
|
$now = db_now();
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
|
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
|
||||||
)->execute([$ticket['id'], 'customer', $body, $now]);
|
)->execute([$ticket['id'], 'customer', $body, $now]);
|
||||||
|
$replyThreadId = (int) $pdo->lastInsertId();
|
||||||
|
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
|
||||||
|
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
|
||||||
|
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $replyThreadId]);
|
||||||
|
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $replyThreadId, (int) $ticket['id'], $label, $log, false, $htmlBody !== null);
|
||||||
|
|
||||||
$newStatus = in_array($ticket['status'], ['answered', 'resolved', 'pending']) ? 'open' : $ticket['status'];
|
$newStatus = in_array($ticket['status'], ['answered', 'resolved', 'pending']) ? 'open' : $ticket['status'];
|
||||||
$pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]);
|
$pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]);
|
||||||
@@ -216,7 +223,7 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
|||||||
'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)'
|
'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)'
|
||||||
)->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]);
|
)->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]);
|
||||||
|
|
||||||
$log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail}).";
|
$log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail}){$replyVia}.";
|
||||||
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
|
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
|
||||||
$processed++;
|
$processed++;
|
||||||
continue;
|
continue;
|
||||||
@@ -303,6 +310,11 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
|||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
|
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
|
||||||
)->execute([$ticketId, 'customer', $body, $now]);
|
)->execute([$ticketId, 'customer', $body, $now]);
|
||||||
|
$firstThreadId = (int) $pdo->lastInsertId();
|
||||||
|
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
|
||||||
|
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
|
||||||
|
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $firstThreadId]);
|
||||||
|
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $firstThreadId, $ticketId, $label, $log, false, $htmlBody !== null);
|
||||||
|
|
||||||
log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt');
|
log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt');
|
||||||
$log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail}).";
|
$log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail}).";
|
||||||
@@ -365,7 +377,8 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
|||||||
return ['processed' => $processed, 'log' => $log];
|
return ['processed' => $processed, 'log' => $log];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (php_sapi_name() === 'cli') {
|
// Nur starten, wenn diese Datei selbst aufgerufen wird (Cronjob), nicht wenn sie von einer anderen Datei eingebunden wird (Abruf von Hand, Tests)
|
||||||
|
if (php_sapi_name() === 'cli' && isset($_SERVER['argv'][0]) && realpath((string) $_SERVER['argv'][0]) === realpath(__FILE__)) {
|
||||||
$result = run_fetch_emails(get_db(), true);
|
$result = run_fetch_emails(get_db(), true);
|
||||||
foreach ($result['log'] as $line) {
|
foreach ($result['log'] as $line) {
|
||||||
echo $line . "\n";
|
echo $line . "\n";
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
|
||||||
|
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||||||
|
/**
|
||||||
|
* cron/send_report_schedules.php
|
||||||
|
*
|
||||||
|
* Versendet gespeicherte Berichte nach Zeitplan per E-Mail (Bericht-Baukasten → „Versand per E-Mail“). Läuft alle paar Minuten, z. B.:
|
||||||
|
* * /5 * * * * /usr/bin/php /pfad/zu/deskhub/cron/send_report_schedules.php
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
|
|
||||||
|
/** @return array{sent:int, failed:int, log:string[]} */
|
||||||
|
function run_send_report_schedules(PDO $pdo, bool $verbose = false): array
|
||||||
|
{
|
||||||
|
$r = report_schedules_run_due($pdo);
|
||||||
|
set_setting('cron_send_report_schedules_last_run', db_now());
|
||||||
|
return $r + ['log' => ['[' . date('Y-m-d H:i:s') . "] Berichte per E-Mail: {$r['sent']} gesendet, {$r['failed']} fehlgeschlagen."]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (php_sapi_name() === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||||
|
foreach (run_send_report_schedules(get_db(), true)['log'] as $line) echo $line . "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
|
||||||
|
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||||||
|
/**
|
||||||
|
* cron/send_scheduled_replies.php
|
||||||
|
*
|
||||||
|
* Sendet zeitversetzt geplante Antworten, sobald ihr Zeitpunkt erreicht ist. Läuft jede Minute, z. B. per Cronjob:
|
||||||
|
* * * * * * /usr/bin/php /pfad/zu/deskhub/cron/send_scheduled_replies.php
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
|
|
||||||
|
/** @return array{sent:int, failed:int, log:string[]} */
|
||||||
|
function run_send_scheduled_replies(PDO $pdo, bool $verbose = false): array
|
||||||
|
{
|
||||||
|
$r = scheduled_replies_run_due($pdo);
|
||||||
|
set_setting('cron_send_scheduled_replies_last_run', db_now());
|
||||||
|
return $r + ['log' => ['[' . date('Y-m-d H:i:s') . "] Geplante Antworten: {$r['sent']} gesendet, {$r['failed']} fehlgeschlagen."]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (php_sapi_name() === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||||
|
foreach (run_send_scheduled_replies(get_db(), true)['log'] as $line) echo $line . "\n";
|
||||||
|
}
|
||||||
@@ -1980,6 +1980,132 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E-Mails mit Formatierung und eingebetteten Bildern: HTML-Fassung der Nachricht, Content-ID und Kennzeichnung eingebetteter Bilder
|
||||||
|
if (!isset($done['php_0070_mail_html'])) {
|
||||||
|
try {
|
||||||
|
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
$columns = function (string $table) use ($pdo, $drv): array {
|
||||||
|
$rows = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll();
|
||||||
|
return array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $rows);
|
||||||
|
};
|
||||||
|
if (!in_array('message_html', $columns('ticket_threads'), true)) $pdo->exec('ALTER TABLE ticket_threads ADD COLUMN message_html MEDIUMTEXT NULL');
|
||||||
|
$att = $columns('ticket_attachments');
|
||||||
|
if (!in_array('content_id', $att, true)) $pdo->exec('ALTER TABLE ticket_attachments ADD COLUMN content_id VARCHAR(255) NULL');
|
||||||
|
if (!in_array('is_inline', $att, true)) $pdo->exec('ALTER TABLE ticket_attachments ADD COLUMN is_inline TINYINT NOT NULL DEFAULT 0');
|
||||||
|
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0070_mail_html']);
|
||||||
|
$result['applied']++;
|
||||||
|
if ($verbose) $result['log'][] = ' [OK] php_0070_mail_html';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors'][] = 'php_0070_mail_html: ' . $e->getMessage();
|
||||||
|
if ($verbose) $result['log'][] = ' [FAIL] php_0070_mail_html - ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 0071: verknüpfte Tickets, zeitversetzt gesendete Antworten und Berichte per E-Mail nach Zeitplan.
|
||||||
|
if (!isset($done['php_0071_links_schedules'])) {
|
||||||
|
try {
|
||||||
|
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||||
|
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_links (
|
||||||
|
id $ai,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
linked_ticket_id INT NOT NULL,
|
||||||
|
link_type VARCHAR(20) NOT NULL DEFAULT 'related',
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
UNIQUE (ticket_id, linked_ticket_id)
|
||||||
|
)$eng");
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_replies (
|
||||||
|
id $ai,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
staff_id INT NOT NULL,
|
||||||
|
message MEDIUMTEXT NOT NULL,
|
||||||
|
set_status VARCHAR(20) NOT NULL DEFAULT 'answered',
|
||||||
|
resolution_code VARCHAR(40) NULL,
|
||||||
|
resolution_note TEXT NULL,
|
||||||
|
signature_choice VARCHAR(40) NULL,
|
||||||
|
send_at DATETIME NOT NULL,
|
||||||
|
state VARCHAR(12) NOT NULL DEFAULT 'pending',
|
||||||
|
error TEXT NULL,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
sent_at DATETIME NULL
|
||||||
|
)$eng");
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS report_schedules (
|
||||||
|
id $ai,
|
||||||
|
report_id INT NOT NULL,
|
||||||
|
staff_id INT NOT NULL,
|
||||||
|
recipients TEXT NOT NULL,
|
||||||
|
frequency VARCHAR(10) NOT NULL DEFAULT 'weekly',
|
||||||
|
weekday TINYINT NOT NULL DEFAULT 1,
|
||||||
|
monthday TINYINT NOT NULL DEFAULT 1,
|
||||||
|
send_time VARCHAR(5) NOT NULL DEFAULT '08:00',
|
||||||
|
period VARCHAR(12) NOT NULL DEFAULT 'last_7',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
next_run_at DATETIME NULL,
|
||||||
|
last_sent_at DATETIME NULL,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
created_at DATETIME NULL
|
||||||
|
)$eng");
|
||||||
|
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0071_links_schedules']);
|
||||||
|
$result['applied']++;
|
||||||
|
if ($verbose) $result['log'][] = ' [OK] php_0071_links_schedules';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors'][] = 'php_0071_links_schedules: ' . $e->getMessage();
|
||||||
|
if ($verbose) $result['log'][] = ' [FAIL] php_0071_links_schedules - ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 0072: Merker „Mitarbeiter hat das Ticket gesehen“ für den Zähler in Chrome-Erweiterung und Windows-Programm.
|
||||||
|
if (!isset($done['php_0072_ticket_acks'])) {
|
||||||
|
try {
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_acks (
|
||||||
|
staff_id INT NOT NULL,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
acked_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (staff_id, ticket_id)
|
||||||
|
)" . ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'));
|
||||||
|
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0072_ticket_acks']);
|
||||||
|
$result['applied']++;
|
||||||
|
if ($verbose) $result['log'][] = ' [OK] php_0072_ticket_acks';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors'][] = 'php_0072_ticket_acks: ' . $e->getMessage();
|
||||||
|
if ($verbose) $result['log'][] = ' [FAIL] php_0072_ticket_acks - ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 0073: weniger Berichts-Mails: nur bei Daten, nur bei Änderung, bei „täglich“ nur an Werktagen.
|
||||||
|
if (!isset($done['php_0073_report_schedule_quiet'])) {
|
||||||
|
try {
|
||||||
|
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(report_schedules)' : 'SHOW COLUMNS FROM report_schedules')->fetchAll());
|
||||||
|
foreach (['only_if_data' => 'TINYINT NOT NULL DEFAULT 1', 'skip_unchanged' => 'TINYINT NOT NULL DEFAULT 1', 'weekdays_only' => 'TINYINT NOT NULL DEFAULT 0', 'last_hash' => 'VARCHAR(64) NULL', 'last_note' => 'VARCHAR(190) NULL'] as $col => $def) {
|
||||||
|
if (!in_array($col, $cols, true)) $pdo->exec("ALTER TABLE report_schedules ADD COLUMN {$col} {$def}");
|
||||||
|
}
|
||||||
|
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0073_report_schedule_quiet']);
|
||||||
|
$result['applied']++;
|
||||||
|
if ($verbose) $result['log'][] = ' [OK] php_0073_report_schedule_quiet';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors'][] = 'php_0073_report_schedule_quiet: ' . $e->getMessage();
|
||||||
|
if ($verbose) $result['log'][] = ' [FAIL] php_0073_report_schedule_quiet - ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 0074: Merker für die Erinnerung „Ticket wartet auf Antwort“ (eine Erinnerung je Kundennachricht).
|
||||||
|
if (!isset($done['php_0074_wait_reminder'])) {
|
||||||
|
try {
|
||||||
|
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll());
|
||||||
|
if (!in_array('wait_reminded_at', $cols, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN wait_reminded_at DATETIME NULL');
|
||||||
|
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0074_wait_reminder']);
|
||||||
|
$result['applied']++;
|
||||||
|
if ($verbose) $result['log'][] = ' [OK] php_0074_wait_reminder';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors'][] = 'php_0074_wait_reminder: ' . $e->getMessage();
|
||||||
|
if ($verbose) $result['log'][] = ' [FAIL] php_0074_wait_reminder - ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user