Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:30 +00:00
parent b207fa4944
commit 65d42108db
9 changed files with 1172 additions and 768 deletions
+1
View File
@@ -2,5 +2,6 @@
</div>
<script src="/assets/js/richtext.js?v=<?= @filemtime(__DIR__ . '/../assets/js/richtext.js') ?: time() ?>"></script>
<script src="/assets/js/live-tickets.js?v=<?= @filemtime(__DIR__ . '/../assets/js/live-tickets.js') ?: time() ?>"></script>
<script src="/assets/js/staff-shortcuts.js?v=<?= @filemtime(__DIR__ . '/../assets/js/staff-shortcuts.js') ?: time() ?>"></script>
</body>
</html>
+1
View File
@@ -56,6 +56,7 @@ if ('serviceWorker' in navigator) {
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
<a href="/staff/my_week.php" class="<?= $activePage==='my_week'?'active':'' ?>">Meine Woche</a>
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a>
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
+97
View File
@@ -0,0 +1,97 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php';
$staff = require_staff_login();
$pdo = get_db();
$staffId = (int) $staff['id'];
$key = (string) ($_GET['p'] ?? 'week');
if (!isset(my_week_periods()[$key])) $key = 'week';
$range = my_week_range($key);
$now = my_week_stats($pdo, $staffId, $range['from'], $range['to']);
$before = my_week_stats($pdo, $staffId, $range['prev_from'], $range['prev_to']);
$live = my_week_now($pdo, $staffId);
$perDay = my_week_per_day($pdo, $staffId, $range['from'], $range['to']);
$maxDay = max(1, ...array_values($perDay ?: [0]));
$csatOn = csat_enabled();
$cards = [
['Gelöste Tickets', (string) $now['resolved'], my_week_delta((float) $now['resolved'], (float) $before['resolved']), 'Tickets, die dir zugewiesen waren und in diesem Zeitraum gelöst oder geschlossen wurden'],
['Gesendete Antworten', (string) $now['replies'], my_week_delta((float) $now['replies'], (float) $before['replies']), 'Öffentliche Antworten an Kunden (ohne interne Notizen)'],
['Ø Zeit bis zur ersten Antwort', my_week_fmt_duration($now['first_response_seconds']),
my_week_delta($now['first_response_seconds'] !== null ? $now['first_response_seconds'] / 60 : null, $before['first_response_seconds'] !== null ? $before['first_response_seconds'] / 60 : null, true, ' Min'),
'Nur Tickets, bei denen du als Erste/r öffentlich geantwortet hast' . ($now['first_response_count'] ? ' (' . $now['first_response_count'] . ' Tickets)' : '') . '; in Arbeitszeit, wenn Geschäftszeiten eingerichtet sind'],
['Pünktlich gelöst', $now['on_time'] !== null ? $now['on_time'] . ' %' : '–', my_week_delta($now['on_time'] !== null ? (float) $now['on_time'] : null, $before['on_time'] !== null ? (float) $before['on_time'] : null, false, ' %-Punkte'), 'Anteil der gelösten Tickets, die vor Ablauf der Lösungsfrist fertig waren'],
];
if ($csatOn) {
$cards[] = ['Zufriedenheit', $now['csat_avg'] !== null ? number_format($now['csat_avg'], 1, ',', '') . ' ★' : '–',
my_week_delta($now['csat_avg'], $before['csat_avg'], false, ' ★'),
$now['csat_count'] ? $now['csat_count'] . ' Bewertung' . ($now['csat_count'] === 1 ? '' : 'en') . ', davon ' . $now['csat_positive'] . ' % mit 4 oder 5 Sternen' : 'Noch keine Bewertungen in diesem Zeitraum'];
}
$oldest = $pdo->prepare("SELECT ticket_number, subject, created_at, resolution_due_at, status FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY created_at ASC LIMIT 5");
$oldest->execute([$staffId]);
$oldest = $oldest->fetchAll();
$pageTitle = 'Meine Woche';
$activePage = 'my_week';
require __DIR__ . '/_header.php';
?>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h1 style="margin:0">Meine Woche</h1>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<?php foreach (my_week_periods() as $k => $l): ?>
<a href="?p=<?= e($k) ?>" class="btn <?= $k === $key ? '' : 'secondary' ?>" style="padding:5px 14px;font-size:.84rem"><?= e($l) ?></a>
<?php endforeach; ?>
</div>
</div>
<p class="muted">Nur für dich sichtbar: <strong><?= e($range['label']) ?></strong> (<?= e(format_dt($range['from'])) ?> bis <?= e(format_dt($range['to'])) ?>), verglichen mit dem gleich langen Zeitraum davor.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:16px 0">
<?php foreach ($cards as [$label, $value, $delta, $hint]): ?>
<div class="card" style="margin:0" title="<?= e($hint) ?>">
<div class="muted" style="font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em"><?= e($label) ?></div>
<div style="font-size:2rem;font-weight:800;margin:6px 0 2px"><?= e($value) ?></div>
<div style="font-size:.8rem;color:<?= $delta['trend'] > 0 ? '#15803d' : ($delta['trend'] < 0 ? '#b91c1c' : 'var(--muted)') ?>"><?= e($delta['text']) ?></div>
<div class="muted" style="font-size:.74rem;margin-top:8px"><?= e($hint) ?></div>
</div>
<?php endforeach; ?>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px">
<div class="card" style="margin:0">
<h3 style="margin-top:0">Gelöst pro Tag</h3>
<div style="display:flex;align-items:flex-end;gap:6px;height:130px">
<?php foreach ($perDay as $day => $n): ?>
<div style="flex:1;min-width:0;text-align:center" title="<?= e(date('d.m.Y', strtotime($day))) ?>: <?= $n ?>">
<div style="font-size:.74rem;height:16px"><?= $n ?: '' ?></div>
<div style="height:<?= (int) round(90 * $n / $maxDay) ?>px;min-height:<?= $n ? 3 : 1 ?>px;background:<?= $n ? 'var(--primary,#0f6f6a)' : 'var(--border,#e2e8f0)' ?>;border-radius:4px 4px 0 0"></div>
<div class="muted" style="font-size:.68rem;margin-top:3px"><?= e(count($perDay) > 10 ? date('d.', strtotime($day)) : ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][(int) date('w', strtotime($day))]) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="card" style="margin:0">
<h3 style="margin-top:0">Gerade bei dir</h3>
<div style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px">
<div><div style="font-size:1.6rem;font-weight:800"><?= $live['open'] ?></div><div class="muted" style="font-size:.78rem">offen</div></div>
<div><div style="font-size:1.6rem;font-weight:800"><?= $live['waiting'] ?></div><div class="muted" style="font-size:.78rem">warten auf deine Antwort</div></div>
<div><div style="font-size:1.6rem;font-weight:800;color:<?= $live['overdue'] ? '#b91c1c' : 'inherit' ?>"><?= $live['overdue'] ?></div><div class="muted" style="font-size:.78rem">überfällig</div></div>
</div>
<?php if ($oldest): ?>
<div style="font-size:.8rem;font-weight:700;margin-bottom:4px">Deine ältesten offenen Tickets</div>
<?php foreach ($oldest as $t): $late = $t['resolution_due_at'] && $t['resolution_due_at'] < db_now(); ?>
<div style="padding:5px 0;border-top:1px solid var(--border-soft);font-size:.86rem">
<a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>"><strong>#<?= e($t['ticket_number']) ?></strong> · <?= e(mb_strimwidth((string) $t['subject'], 0, 60, '…')) ?></a>
<span class="muted" style="font-size:.76rem">seit <?= e(date('d.m.Y', strtotime($t['created_at']))) ?><?= $late ? ' · <span style="color:#b91c1c">überfällig</span>' : '' ?></span>
</div>
<?php endforeach; ?>
<?php else: ?>
<p class="muted" style="margin:0">Keine offenen Tickets. 🎉</p>
<?php endif; ?>
</div>
</div>
<?php if (!$csatOn): ?><p class="muted" style="font-size:.82rem;margin-top:14px">Die Zufriedenheitsabfrage ist ausgeschaltet; deshalb gibt es hier keine Bewertungen. Ein Administrator schaltet sie unter Einstellungen → Allgemein ein.</p><?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>
+91
View File
@@ -35,16 +35,54 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$row->execute([(int) $_POST['id']]);
if (($r = $row->fetch()) && ((int) $r['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin')) {
$pdo->prepare('DELETE FROM saved_reports WHERE id = ?')->execute([$r['id']]);
$pdo->prepare('DELETE FROM report_schedules WHERE report_id = ?')->execute([$r['id']]);
flash('success', 'Bericht gelöscht.');
}
redirect('/staff/report_builder.php');
}
if (in_array($action, ['schedule_add', 'schedule_delete', 'schedule_toggle', 'schedule_test', 'schedule_flag'], true)) {
$sid = (int) ($_POST['schedule_id'] ?? 0);
$own = $pdo->prepare('SELECT * FROM report_schedules WHERE id = ?');
$own->execute([$sid]);
$sch = $own->fetch();
$mine = $sch && ((int) $sch['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin');
if ($action === 'schedule_add') {
$err = report_schedule_create($pdo, $staff, $_POST);
$err ? flash('error', $err) : flash('success', 'Zeitplan angelegt. Der Bericht wird zum nächsten Termin per E-Mail gesendet.');
} elseif (!$mine) {
flash('error', 'Zeitplan nicht gefunden.');
} elseif ($action === 'schedule_delete') {
$pdo->prepare('DELETE FROM report_schedules WHERE id = ?')->execute([$sid]);
flash('success', 'Zeitplan gelöscht.');
} elseif ($action === 'schedule_flag') {
$flag = (string) ($_POST['flag'] ?? '');
if (!in_array($flag, ['only_if_data', 'skip_unchanged', 'weekdays_only'], true)) { flash('error', 'Unbekannte Option.'); }
else {
$new = (int) $sch[$flag] === 1 ? 0 : 1;
$upd = $sch; $upd[$flag] = $new;
$pdo->prepare("UPDATE report_schedules SET {$flag} = ?, next_run_at = ? WHERE id = ?")->execute([$new, (int) $sch['enabled'] === 1 ? report_schedule_next($upd) : $sch['next_run_at'], $sid]);
flash('success', 'Option gespeichert.');
}
} elseif ($action === 'schedule_toggle') {
$on = (int) $sch['enabled'] === 1 ? 0 : 1;
$pdo->prepare('UPDATE report_schedules SET enabled = ?, next_run_at = ? WHERE id = ?')->execute([$on, $on ? report_schedule_next($sch) : $sch['next_run_at'], $sid]);
flash('success', $on ? 'Zeitplan aktiviert.' : 'Zeitplan pausiert.');
} else {
$r = report_schedule_send($pdo, $sch);
$r['error'] ? flash('error', $r['error']) : flash('success', 'Testversand an ' . $r['sent'] . ' Empfänger gesendet.');
}
redirect('/staff/report_builder.php#versand');
}
}
// ── Konfiguration: aus gespeichertem Bericht oder aus der URL ───────────────
$saved = $pdo->prepare('SELECT r.*, s.name AS owner FROM saved_reports r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.staff_id = ? OR r.is_shared = 1 ORDER BY r.name');
$saved->execute([$staff['id']]);
$savedReports = $saved->fetchAll();
$schedStmt = $pdo->prepare("SELECT sc.*, r.name AS report_name FROM report_schedules sc JOIN saved_reports r ON r.id = sc.report_id WHERE sc.staff_id = ? OR ? = 'admin' ORDER BY r.name, sc.id");
$schedStmt->execute([$staff['id'], $staff['role']]);
$schedules = $schedStmt->fetchAll();
$ownReports = array_values(array_filter($savedReports, fn($r) => (int) $r['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin'));
$cfgIn = $_GET;
if (isset($_GET['saved'])) {
foreach ($savedReports as $sr) {
@@ -175,4 +213,57 @@ require __DIR__ . '/_header.php';
</form>
</details>
<details class="card" style="margin-top:14px" id="versand" <?= $schedules ? 'open' : '' ?>>
<summary style="cursor:pointer;font-weight:600">Versand per E-Mail (Zeitplan)</summary>
<p class="muted" style="font-size:.84rem">Ein gespeicherter Bericht wird regelmäßig neu berechnet und als Tabelle an die Empfänger gemailt. Der Zeitraum ist relativ (z. B. „Letzte Kalenderwoche“), die Datumsangaben im gespeicherten Bericht werden dafür ersetzt. Berechnet wird mit Ihren Abteilungsrechten.</p>
<?php if ($schedules): ?>
<table style="margin-bottom:14px">
<thead><tr><th>Bericht</th><th>Rhythmus</th><th>Zeitraum</th><th>Empfänger</th><th>Weniger Mails</th><th>Nächster Versand</th><th>Zuletzt</th><th></th></tr></thead>
<tbody>
<?php foreach ($schedules as $sc): ?>
<tr style="<?= (int) $sc['enabled'] ? '' : 'opacity:.55' ?>">
<td><strong><?= e($sc['report_name']) ?></strong></td>
<td><?= e(report_schedule_frequencies()[$sc['frequency']] ?? '') ?><?= $sc['frequency'] === 'weekly' ? ', ' . e(report_schedule_weekdays()[(int) $sc['weekday']] ?? '') : ($sc['frequency'] === 'monthly' ? ', am ' . (int) $sc['monthday'] . '.' : '') ?>, <?= e($sc['send_time']) ?></td>
<td><?= e(report_schedule_periods()[$sc['period']] ?? '') ?></td>
<td style="max-width:240px;word-break:break-all"><?= e($sc['recipients']) ?></td>
<td style="white-space:nowrap">
<?php foreach (['only_if_data' => 'Nur bei Daten', 'skip_unchanged' => 'Nur bei Änderung', 'weekdays_only' => 'Nur Mo–Fr'] as $flag => $flagLabel):
if ($flag === 'weekdays_only' && $sc['frequency'] !== 'daily') continue; ?>
<form method="post" style="display:block;margin:0 0 3px"><?= csrf_field() ?><input type="hidden" name="action" value="schedule_flag"><input type="hidden" name="schedule_id" value="<?= (int) $sc['id'] ?>"><input type="hidden" name="flag" value="<?= e($flag) ?>"><button class="btn secondary" type="submit" style="padding:1px 8px;font-size:.74rem" title="Umschalten"><?= !empty($sc[$flag]) ? '☑' : '☐' ?> <?= e($flagLabel) ?></button></form>
<?php endforeach; ?>
</td>
<td class="muted"><?= (int) $sc['enabled'] ? e(format_dt((string) $sc['next_run_at'])) : 'pausiert' ?></td>
<td class="muted"><?= $sc['last_error'] ? '<span style="color:var(--danger)">' . e($sc['last_error']) . '</span>' : ($sc['last_sent_at'] ? e(format_dt((string) $sc['last_sent_at'])) : '–') ?><?= !empty($sc['last_note']) ? '<div style="font-size:.74rem">' . e($sc['last_note']) . '</div>' : '' ?></td>
<td style="white-space:nowrap">
<?php foreach (['schedule_test' => 'Test senden', 'schedule_toggle' => (int) $sc['enabled'] ? 'Pausieren' : 'Aktivieren', 'schedule_delete' => 'Löschen'] as $act => $lbl): ?>
<form method="post" style="display:inline" <?= $act === 'schedule_delete' ? 'onsubmit="return confirm(\'Zeitplan löschen?\')"' : '' ?>><?= csrf_field() ?><input type="hidden" name="action" value="<?= $act ?>"><input type="hidden" name="schedule_id" value="<?= (int) $sc['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 9px;font-size:.76rem"><?= e($lbl) ?></button></form>
<?php endforeach; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($ownReports): ?>
<form method="post" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;align-items:end;max-width:900px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="schedule_add">
<div><label for="sc_report" style="margin-top:0">Bericht</label><select id="sc_report" name="report_id"><?php foreach ($ownReports as $r): ?><option value="<?= (int) $r['id'] ?>"><?= e($r['name']) ?></option><?php endforeach; ?></select></div>
<div><label for="sc_freq" style="margin-top:0">Rhythmus</label><select id="sc_freq" name="frequency"><?php foreach (report_schedule_frequencies() as $k => $l): ?><option value="<?= e($k) ?>" <?= $k === 'weekly' ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select></div>
<div><label for="sc_wd" style="margin-top:0">Wochentag (wöchentlich)</label><select id="sc_wd" name="weekday"><?php foreach (report_schedule_weekdays() as $k => $l): ?><option value="<?= $k ?>"><?= e($l) ?></option><?php endforeach; ?></select></div>
<div><label for="sc_md" style="margin-top:0">Tag im Monat (monatlich)</label><select id="sc_md" name="monthday"><?php for ($d = 1; $d <= 28; $d++): ?><option value="<?= $d ?>"><?= $d ?>.</option><?php endfor; ?></select></div>
<div><label for="sc_time" style="margin-top:0">Uhrzeit</label><input type="time" id="sc_time" name="send_time" value="08:00"></div>
<div style="grid-column:1 / -1;display:flex;gap:6px 20px;flex-wrap:wrap">
<input type="hidden" name="quiet_form" value="1">
<label style="display:flex;align-items:center;gap:6px;font-weight:400;margin:0"><input type="checkbox" name="only_if_data" value="1" style="width:auto" checked> Nur senden, wenn der Bericht Daten enthält</label>
<label style="display:flex;align-items:center;gap:6px;font-weight:400;margin:0"><input type="checkbox" name="skip_unchanged" value="1" style="width:auto" checked> Nur senden, wenn sich etwas geändert hat</label>
<label style="display:flex;align-items:center;gap:6px;font-weight:400;margin:0"><input type="checkbox" name="weekdays_only" value="1" style="width:auto" checked> Bei „Täglich“ nur Montag bis Freitag</label>
</div>
<div><label for="sc_period" style="margin-top:0">Zeitraum</label><select id="sc_period" name="period"><?php foreach (report_schedule_periods() as $k => $l): ?><option value="<?= e($k) ?>" <?= $k === 'last_week' ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select></div>
<div style="grid-column:1 / -1"><label for="sc_to" style="margin-top:0">Empfänger (E-Mail-Adressen, getrennt durch Komma oder Zeilenumbruch)</label><textarea id="sc_to" name="recipients" rows="2" required placeholder="leitung@firma.de"></textarea><?php if ($staff['role'] !== 'admin'): ?><div class="muted" style="font-size:.76rem">Nur Adressen aktiver Mitarbeiter; externe Empfänger kann ein Administrator eintragen.</div><?php endif; ?></div>
<div><button type="submit" class="btn">Zeitplan anlegen</button></div>
</form>
<?php else: ?><p class="muted">Speichern Sie zuerst einen Bericht (unten), um einen Versand einzurichten.</p><?php endif; ?>
</details>
<?php require __DIR__ . '/_footer.php'; ?>
+31
View File
@@ -23,6 +23,8 @@ $cronJobs = [
'remind_customers' => ['label' => 'Kundenerinnerung', 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
'send_csat' => ['label' => 'Zufriedenheitsabfrage', 'file' => 'send_csat.php', 'fn' => 'run_send_csat'],
'status_checks' => ['label' => 'Statusprüfungen', 'file' => 'status_checks.php', 'fn' => 'run_status_checks'],
'send_scheduled_replies' => ['label' => 'Geplante Antworten', 'file' => 'send_scheduled_replies.php', 'fn' => 'run_send_scheduled_replies'],
'send_report_schedules' => ['label' => 'Berichte per E-Mail', 'file' => 'send_report_schedules.php', 'fn' => 'run_send_report_schedules'],
'run_plugins' => ['label' => 'Plugins (Hintergrundaufgaben)', 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
];
@@ -53,6 +55,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_setting('auto_close_enabled', !empty($_POST['auto_close_enabled']) ? '1' : '0');
$autoCloseDays = max(1, min(90, (int) ($_POST['auto_close_days'] ?? 7)));
set_setting('auto_close_days', (string) $autoCloseDays);
set_setting('wait_reminder_enabled', !empty($_POST['wait_reminder_enabled']) ? '1' : '0');
set_setting('wait_reminder_hours', (string) max(1, min(72, (int) ($_POST['wait_reminder_hours'] ?? 4))));
set_setting('wait_reminder_email', !empty($_POST['wait_reminder_email']) ? '1' : '0');
set_setting('remind_enabled', !empty($_POST['remind_enabled']) ? '1' : '0');
set_setting('remind_after_days', (string) max(1, min(60, (int) ($_POST['remind_after_days'] ?? 3))));
csat_set_enabled(!empty($_POST['csat_enabled']), (int) ($_POST['csat_delay_minutes'] ?? 30));
@@ -71,6 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_setting('retention_enabled', $enabled);
set_setting('retention_months', (string)$months);
set_setting('ip_anonymize', $ipAnon);
set_setting('gdpr_tools_enabled', !empty($_POST['gdpr_tools_enabled']) ? '1' : '0');
audit_log($staff['id'], 'Datenschutzeinstellungen geändert');
flash('success', 'Datenschutzeinstellungen gespeichert.');
redirect('/staff/settings.php?tab=datenschutz');
@@ -337,6 +343,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$extList = array_filter(array_map('trim', explode(',', strtolower($_POST['upload_allowed_extensions'] ?? ''))));
$extList = array_diff($extList, $dangerous);
set_setting('upload_allowed_extensions', implode(', ', $extList));
$mailExt = array_diff(array_filter(array_map(fn($e) => ltrim(trim($e), '.'), explode(',', strtolower($_POST['mail_attachment_extra_extensions'] ?? '')))), $dangerous);
set_setting('mail_attachment_extra_extensions', implode(', ', $mailExt));
set_setting('upload_max_size_mb', max(1, min(100, (int)($_POST['upload_max_size_mb'] ?? 10))));
audit_log($staff['id'], 'Upload-Einstellungen geändert');
flash('success', 'Upload-Einstellungen gespeichert.');
@@ -467,6 +475,7 @@ $footerImprintLabel = get_setting('footer_imprint_label', 'Impressum');
$footerImprintUrl = get_setting('footer_imprint_url', '');
$footerImprintHtml = get_setting('footer_imprint_html', '');
$uploadExtensions = get_setting('upload_allowed_extensions', implode(', ', config()['uploads']['allowed_extensions']));
$mailExtraExtensions = get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT);
$uploadMaxSize = get_setting('upload_max_size_mb', '10');
$customThemes = list_custom_themes();
$activeCustomTheme = get_setting('active_custom_theme', '');
@@ -573,6 +582,17 @@ require __DIR__ . '/_header.php';
<input type="number" name="auto_close_days" value="<?= (int) get_setting('auto_close_days','7') ?>" min="1" max="90" style="width:90px">
<span class="muted" style="font-size:.82rem">Tage</span>
</div>
<?php $waitOn = wait_reminder_enabled(); ?>
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
<input type="checkbox" name="wait_reminder_enabled" value="1" style="width:auto" id="wait_reminder_enabled" onchange="document.getElementById('wait-reminder-row').style.display=this.checked?'block':'none'" <?= $waitOn ? 'checked' : '' ?>>
Bearbeiter erinnern, wenn ein Ticket zu lange auf ihre Antwort wartet (Hinweis vor Ablauf der SLA-Frist)
</label>
<div id="wait-reminder-row" style="display:<?= $waitOn ? 'block' : 'none' ?>;margin-top:8px;margin-left:26px">
<label style="font-size:.82rem">Nach wie vielen Arbeitsstunden ohne Antwort</label><br>
<input type="number" name="wait_reminder_hours" value="<?= wait_reminder_hours() ?>" min="1" max="72" style="width:90px">
<span class="muted" style="font-size:.82rem">Stunden (zählt nur die Geschäftszeiten, wenn sie in den SLA-Einstellungen eingerichtet sind)</span>
<label style="display:flex;align-items:center;gap:8px;margin-top:6px;font-weight:400;cursor:pointer"><input type="checkbox" name="wait_reminder_email" value="1" style="width:auto" <?= wait_reminder_email() ? 'checked' : '' ?>> Zusätzlich per E-Mail (sonst nur Push in der App und im Browser)</label>
</div>
<?php $remindEnabled = customer_reminder_enabled(); ?>
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
<input type="checkbox" name="remind_enabled" value="1" style="width:auto" id="remind_enabled" onchange="document.getElementById('remind-days-row').style.display=this.checked?'block':'none'" <?= $remindEnabled?'checked':'' ?>>
@@ -996,6 +1016,9 @@ require __DIR__ . '/_header.php';
<?= csrf_field() ?><input type="hidden" name="action" value="save_uploads">
<label>Erlaubte Datei-Endungen <span class="muted">(kommagetrennt)</span></label>
<input type="text" name="upload_allowed_extensions" value="<?= e($uploadExtensions) ?>" placeholder="png, jpg, pdf, docx, xlsx, zip">
<label style="margin-top:14px;display:block">Zusätzlich erlaubt für Anhänge eingehender E-Mails <span class="muted">(kommagetrennt)</span></label>
<input type="text" name="mail_attachment_extra_extensions" value="<?= e($mailExtraExtensions) ?>" placeholder="doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg">
<div class="muted" style="font-size:.78rem;margin-top:4px">Kunden schicken oft Büro-Dateien per Mail, die im Formular nicht erlaubt sind. Diese Endungen gelten zusätzlich zur Liste oben, aber nur für Mails. Gefährliche Typen bleiben gesperrt.</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
<?php foreach (explode(',', $uploadExtensions) as $ext): $ext = trim($ext); if (!$ext) continue; ?>
<span style="background:var(--primary-lt);color:var(--primary);border-radius:20px;padding:3px 10px;font-size:.78rem;font-weight:600"><?= e($ext) ?></span>
@@ -1086,6 +1109,12 @@ require __DIR__ . '/_header.php';
IP-Adressen sofort beim Speichern kürzen (letztes Oktett entfernen)
</label>
<label style="display:flex;align-items:center;gap:10px;font-weight:500;cursor:pointer;margin-bottom:6px">
<input type="checkbox" name="gdpr_tools_enabled" value="1" style="width:auto" <?= gdpr_tools_enabled() ? 'checked' : '' ?>>
Auskunft und Löschung auf Anfrage im Kundenprofil anbieten (nur Administratoren)
</label>
<p class="muted" style="font-size:.78rem;margin:0 0 18px 28px">Zeigt im Kundenprofil die Knöpfe „Alle Daten exportieren“ und „Kundendaten löschen“. Jede Nutzung wird im Audit-Log protokolliert.</p>
<div class="btn-row">
<button type="submit" class="btn">Speichern</button>
</div>
@@ -1178,6 +1207,8 @@ require __DIR__ . '/_header.php';
'run_plugins' => $cronPlugins,
'send_csat' => $cronCsat,
'status_checks' => cron_last_run_label(get_setting('cron_status_checks_last_run')),
'send_scheduled_replies' => cron_last_run_label(get_setting('cron_send_scheduled_replies_last_run')),
'send_report_schedules' => cron_last_run_label(get_setting('cron_send_report_schedules_last_run')),
];
?>
<table style="width:100%">
+2
View File
@@ -11,6 +11,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_setting('status_page_enabled', !empty($_POST['enabled']) ? '1' : '0');
$title = trim(mb_substr((string) ($_POST['title'] ?? ''), 0, 80));
set_setting('status_page_title', $title !== '' ? $title : 'Systemstatus');
set_setting('status_notify_admins', !empty($_POST['notify_admins']) ? '1' : '0');
audit_log($admin['id'], 'Statusseite: Einstellungen geändert');
flash('success', 'Einstellungen gespeichert.');
} elseif ($action === 'component_save') {
@@ -137,6 +138,7 @@ require __DIR__ . '/_header.php';
<?= csrf_field() ?><input type="hidden" name="action" value="settings">
<h3>Einstellungen</h3>
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" value="1" style="width:auto" <?= status_page_enabled() ? 'checked' : '' ?>> Statusseite für Kunden anzeigen (Link „Status“ im Kundenbereich)</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:8px"><input type="checkbox" name="notify_admins" value="1" style="width:auto" <?= status_notify_admins_enabled() ? 'checked' : '' ?>> Administratoren per E-Mail und Push benachrichtigen, wenn ein Dienst ausfällt oder wieder läuft (auch bei internen Diensten)</label>
<label for="title" style="margin-top:8px">Überschrift</label>
<input type="text" id="title" name="title" maxlength="80" value="<?= e((string) get_setting('status_page_title', 'Systemstatus')) ?>" style="max-width:340px">
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
+1 -1
View File
@@ -130,7 +130,7 @@ $siteName = get_setting('display_name', config()['site']['name'] ?? 'DeskHub');
$isStaff = $msg['author_type'] === 'staff';
$author = $isStaff ? ($msg['staff_name'] ?? 'Mitarbeiter') : ($msg['user_name'] ?? $ticket['requester_name']);
$body = $msg['message'] ?? '';
$isHtml = preg_match('/<[a-zA-Z][^>]*>/i', $body);
$isHtml = message_is_html($body);
$attStmt->execute([$msg['id']]);
$atts = $attStmt->fetchAll();
+167 -10
View File
@@ -2,10 +2,19 @@
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/ticket_extras.php'; // falls bootstrap.php noch die alte Version ist
require_once __DIR__ . '/../includes/imap_spam.php';
require_once __DIR__ . '/../includes/mail_reload.php';
$staff = require_staff_login();
$pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
// Bilder von fremden Webseiten in E-Mails: Standard ist "nicht laden" (Datenschutz). Auf Wunsch (Knopf über der Nachricht) lädt nur
// dieser eine Seitenaufruf sie; dazu wird die Sicherheitsrichtlinie der Seite für diesen Aufruf erweitert und kein Verweis mitgeschickt.
$loadRemoteImages = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET' && ($_GET['remote_images'] ?? '') === '1';
if ($loadRemoteImages) {
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';");
header('Referrer-Policy: no-referrer');
}
$number = $_GET['n'] ?? '';
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
@@ -77,6 +86,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
}
$lockedByOther = null; // wird ab jetzt nicht mehr gebraucht (kein Banner-Modus mehr)
if ($_SERVER['REQUEST_METHOD'] === 'GET') ticket_ack($pdo, (int) $ticket['id'], (int) $staff['id']); // gesehen: verschwindet aus dem Zähler der Apps
// Für den "Zeiterfassung läuft"-Hinweis in der Titelzeile
$myWorkSessionStmt = $pdo->prepare('SELECT started_at FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?');
@@ -87,6 +97,8 @@ $collabStmt = $pdo->prepare('SELECT * FROM ticket_collaborators WHERE ticket_id
$collabStmt->execute([$ticket['id']]);
$collaborators = $collabStmt->fetchAll();
$ticketProblems = ticket_problems_for_ticket($pdo, (int) $ticket['id']);
$scheduledReplies = scheduled_replies_pending($pdo, (int) $ticket['id']);
$ticketLinks = ticket_links_for($pdo, (int) $ticket['id'], staff_department_ids($staff));
$openProblems = ticket_problems($pdo, true);
$resolutionReasons = ticket_resolution_reasons();
@@ -199,10 +211,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
};
if ($action === 'reply') $requirePerm('reply_ticket');
if ($action === 'reply' || $action === 'schedule_reply' || $action === 'cancel_scheduled_reply') $requirePerm('reply_ticket');
if ($action === 'note') $requirePerm('post_note');
if ($action === 'delete_ticket') $requirePerm('delete_ticket');
if ($action === 'mark_spam' || $action === 'restore_spam') $requirePerm('close_ticket');
if ($action === 'reload_mail_attachments') $requirePerm('reply_ticket');
if ($action === 'add_comment') {
$threadId = (int)($_POST['thread_id'] ?? 0);
@@ -235,6 +248,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
if ($action === 'schedule_reply') {
$message = sanitize_staff_html(resolve_canned_vars(trim($_POST['message'] ?? ''), $ticket, $staff));
$err = null;
if (!empty($_FILES['attachments']['name'][0])) $err = 'Anhänge sind bei zeitversetztem Senden nicht möglich. Bitte ohne Anhang planen oder sofort senden.';
$status = (string) ($_POST['set_status'] ?? 'answered');
if (!$err && in_array($status, ['resolved', 'closed'], true)) $requirePerm('close_ticket');
$sendAt = scheduled_reply_parse_time((string) ($_POST['send_at'] ?? ''));
$err = $err ?: scheduled_reply_create($pdo, $ticket, (int) $staff['id'], $message, $sendAt, [
'set_status' => $status, 'resolution_code' => (string) ($_POST['resolution_code'] ?? ''), 'resolution_note' => (string) ($_POST['resolution_note'] ?? ''),
'signature_choice' => isset($_POST['signature_choice']) ? (string) $_POST['signature_choice'] : null,
]);
if ($err) { flash('error', $err); }
else {
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Antwort eingeplant für ' . format_dt($sendAt));
flash('success', 'Antwort eingeplant für ' . format_dt($sendAt) . '. Sie wird dann automatisch gesendet.');
}
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
if ($action === 'cancel_scheduled_reply') {
if (scheduled_reply_cancel($pdo, (int) ($_POST['scheduled_id'] ?? 0), (int) $ticket['id'], $staff)) {
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Geplante Antwort abgebrochen');
flash('success', 'Geplante Antwort abgebrochen.');
} else {
flash('error', 'Die geplante Antwort konnte nicht abgebrochen werden (schon gesendet oder nicht von Ihnen).');
}
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
if ($action === 'reply' || $action === 'note') {
$raw = trim($_POST['message'] ?? '');
// Variablen serverseitig auflösen (Fallback falls JS deaktiviert)
@@ -337,6 +379,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect($action === 'restore_spam' ? '/staff/ticket_view.php?n=' . urlencode($number) : '/staff/tickets.php');
}
if ($action === 'reload_mail_attachments') {
$r = reload_ticket_mail_attachments($pdo, $ticket, (int) $staff['id']);
if ($r['error']) {
flash('error', $r['error']);
} else {
$n = $r['added'] + $r['inline'];
$msg = ($n > 0 || $r['html'] > 0) ? ($r['added'] > 0 ? $r['added'] . ' Anhang/Anhänge' : 'Nichts als Anhang') . ($r['inline'] > 0 ? ', ' . $r['inline'] . ' Bild(er) im Text' : '') . ($r['html'] > 0 ? ' und die Formatierung' : '') . ' aus der E-Mail nachgeladen.' : 'Es gab nichts Neues zu laden.';
if ($r['existing'] > 0) $msg .= ' ' . $r['existing'] . ' war(en) schon im Ticket.';
if ($r['skipped'] > 0) $msg .= ' ' . $r['skipped'] . ' nicht übernommen (Typ nicht erlaubt oder zu groß, siehe Verlauf).';
if ($r['failed']) flash('error', $msg . ' ' . implode(' | ', $r['failed']));
else flash(($r['added'] + $r['inline'] + $r['html']) > 0 ? 'success' : 'info', $msg);
}
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
if ($action === 'delete_ticket') {
delete_ticket($pdo, $ticket, $staff['id']);
flash('success', "Ticket #{$number} wurde gelöscht.");
@@ -525,6 +582,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
if ($action === 'link_add' || $action === 'link_remove') {
$requirePerm('reply_ticket');
if ($action === 'link_add') {
$err = ticket_link_add($pdo, (int) $ticket['id'], (string) ($_POST['link_number'] ?? ''), (string) ($_POST['link_type'] ?? 'related'), (int) $staff['id'], staff_department_ids($staff));
if ($err) flash('error', $err);
else {
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Mit Ticket #' . ltrim(trim((string) $_POST['link_number']), '#') . ' verknüpft');
flash('success', 'Tickets verknüpft.');
}
} elseif (ticket_link_remove($pdo, (int) ($_POST['link_id'] ?? 0), (int) $ticket['id'])) {
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Ticketverknüpfung entfernt');
flash('success', 'Verknüpfung entfernt.');
}
redirect('/staff/ticket_view.php?n=' . urlencode($number));
}
if ($action === 'problem_link') {
if ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Tickets mit Problemen verknüpfen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); }
$problemIdToLink = (int) ($_POST['problem_id'] ?? 0);
@@ -751,6 +824,13 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<button type="submit" class="btn secondary" style="font-size:.8rem;padding:5px 12px">↩️ Spam-Markierung aufheben</button>
</form>
<?php endif; ?>
<?php if (($ticket['source'] ?? '') === 'email' && function_exists('imap_open') && has_permission($staff, 'reply_ticket', (int) $ticket['department_id'])): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Anhänge, Bilder und Formatierung der Original-E-Mail aus dem Postfach nachladen und ins Ticket übernehmen?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="reload_mail_attachments">
<button type="submit" class="btn secondary" style="font-size:.8rem;padding:5px 12px" title="Holt Anhänge, Bilder und die Formatierung der E-Mail aus dem Postfach, soweit sie beim Abruf nicht übernommen wurden">📎 Anhänge und Bilder aus E-Mail laden</button>
</form>
<?php endif; ?>
<a href="/staff/ticket_print.php?n=<?= urlencode($ticket['ticket_number']) ?>" target="_blank" class="btn secondary" style="font-size:.8rem;padding:5px 12px">🖨 Drucken</a>
</div>
</div>
@@ -869,7 +949,7 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
$isStaffMsg = $m['author_type'] === 'staff';
$who = $isStaffMsg ? ($m['staff_name'] ?? 'Mitarbeiter') : ($m['user_name'] ?? $ticket['requester_name']);
$body = (string) ($m['message'] ?? '');
$bodyIsHtml = preg_match('/<[a-zA-Z][^>]*>/i', $body);
$bodyIsHtml = message_is_html($body);
$attStmt->execute([$m['id']]);
$msgAttachments = $attStmt->fetchAll();
?>
@@ -905,6 +985,10 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
if ($ticket['source'] === 'chat' && !$msg['is_internal_note']) continue; // s.o., schon im Chatprotokoll gezeigt
$attStmt->execute([$msg['id']]);
$attachments = $attStmt->fetchAll();
// Eingebettete Bilder einer Mail (Logos, Fotos im Text) gehören zur Darstellung der Nachricht und stehen nicht in der Anhangliste
$cidMap = [];
foreach ($attachments as $a) if (!empty($a['is_inline']) && !empty($a['content_id'])) $cidMap[strtolower((string) $a['content_id'])] = '/download.php?id=' . (int) $a['id'] . '&preview=1';
$attachments = array_values(array_filter($attachments, fn($a) => empty($a['is_inline'])));
$isStaff = $msg['author_type'] === 'staff';
$authorName = $isStaff
? ($msg['staff_name'] ?? 'Mitarbeiter')
@@ -938,7 +1022,10 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
?>
<?php
// Quoting-Blöcke einklappen (> ... Linien)
$isHtml = preg_match('/<[a-zA-Z][^>]*>/i', $msgBody);
// E-Mails mit Formatierung: bereinigte HTML-Fassung (message_html), sonst nur HTML, wenn die Nachricht selbst welches enthält
$mailHtml = trim((string) ($msg['message_html'] ?? ''));
$htmlSource = $mailHtml !== '' ? $mailHtml : (message_is_html($msgBody) ? $msgBody : null);
$isHtml = $htmlSource !== null;
$msgId = $msg['id'];
?>
<!-- Quelltext-Toggle -->
@@ -958,14 +1045,26 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<?php if ($isHtml): ?>
<?php
// HTML-E-Mail sicher rendern
$safe = sanitize_staff_html($msgBody);
$safe = sanitize_staff_html((string) $htmlSource, $mailHtml !== '' ? ['remote_images' => $loadRemoteImages ? 'allow' : 'placeholder', 'cid_map' => $cidMap] : []);
// Zitat-Blöcke einklappbar machen
$safe = preg_replace(
'/<blockquote([^>]*)>(.*?)<\/blockquote>/is',
'<div class="email-quote-wrap"><button type="button" class="email-quote-toggle" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.nextElementSibling.style.display===\'none\'?\'▶ Zitat anzeigen\':\'▼ Zitat ausblenden\'">▶ Zitat anzeigen</button><blockquote\1 style="display:none">\2</blockquote></div>',
$safe
);
// Zitierter Verlauf früherer Mails (Outlook: Linie und Mailkopf) wird eingeklappt
if (empty($msg['is_internal_note'])) {
[$safeNew, $safeQuoted] = mail_split_quoted_html($safe);
if ($safeQuoted !== '') $safe = $safeNew . mail_quote_wrap($safeQuoted);
}
?>
<?php if ($mailHtml !== '' && $loadRemoteImages && str_contains($safe, '<img')): ?>
<div class="muted" style="font-size:.74rem;margin-bottom:8px">🌐 Bilder von fremden Webseiten sind für diese Ansicht geladen. <a href="/staff/ticket_view.php?n=<?= urlencode((string) $ticket['ticket_number']) ?>">Wieder ausblenden</a></div>
<?php endif; ?>
<?php if ($mailHtml !== '' && str_contains($safe, 'data-blocked-remote')): ?>
<div class="muted" style="font-size:.74rem;margin-bottom:8px">🔒 Bilder von fremden Webseiten wurden aus Datenschutzgründen nicht geladen.
<a href="/staff/ticket_view.php?n=<?= urlencode((string) $ticket['ticket_number']) ?>&remote_images=1" title="Lädt die Bilder nur für diese Ansicht. Der Absender kann dann sehen, dass und wann die Mail geöffnet wurde." style="margin-left:6px">Bilder jetzt laden</a></div>
<?php endif; ?>
<div class="email-html-body" style="font-size:.87rem;line-height:1.7;overflow-x:auto">
<?= $safe ?>
</div>
@@ -973,15 +1072,23 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<?php
$text = trim($msgBody);
$text = str_replace(["\r\n", "\r"], "\n", $text);
$parts = preg_split('/\n{2,}/', $text);
$quotedText = '';
if (empty($msg['is_internal_note'])) [$text, $quotedText] = mail_split_quoted_text($text);
$renderText = function (string $t): string {
$out = '';
$parts = preg_split('/\n{2,}/', $t);
if (count($parts) > 1) {
foreach ($parts as $p) {
$p = trim($p);
if ($p !== '') echo '<p style="margin:0 0 12px;line-height:1.7">' . nl2br(htmlspecialchars($p, ENT_QUOTES, 'UTF-8')) . '</p>';
if ($p !== '') $out .= '<p style="margin:0 0 12px;line-height:1.7">' . nl2br(htmlspecialchars($p, ENT_QUOTES, 'UTF-8')) . '</p>';
}
} else {
echo '<p style="margin:0;line-height:1.7">' . nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')) . '</p>';
$out .= '<p style="margin:0;line-height:1.7">' . nl2br(htmlspecialchars($t, ENT_QUOTES, 'UTF-8')) . '</p>';
}
return $out;
};
echo $renderText($text);
if ($quotedText !== '') echo mail_quote_wrap($renderText($quotedText));
?>
<?php endif; ?>
</div>
@@ -997,9 +1104,9 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<?php if ($imgAtts): ?>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px">
<?php foreach ($imgAtts as $a): ?>
<a href="/download.php?id=<?= $a['id'] ?>&preview=1" target="_blank"
<a href="/download.php?id=<?= $a['id'] ?>&preview=1" target="_blank" data-lightbox="1" data-download="/download.php?id=<?= (int) $a['id'] ?>"
title="<?= e($a['original_name']) ?>"
style="display:block;border:1px solid var(--border);border-radius:6px;overflow:hidden;flex-shrink:0">
style="display:block;border:1px solid var(--border);border-radius:6px;overflow:hidden;flex-shrink:0;cursor:zoom-in">
<img src="/download.php?id=<?= $a['id'] ?>&preview=1"
alt="<?= e($a['original_name']) ?>"
style="max-width:200px;max-height:150px;display:block;object-fit:cover">
@@ -1156,7 +1263,7 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
</div>
<div id="canned-preview" style="display:none;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:12px;font-size:.84rem;line-height:1.7;margin-bottom:8px;max-height:200px;overflow-y:auto"></div>
<?php endif; ?>
<textarea name="message" id="message-field" data-richtext placeholder="Nachricht eingeben..." required></textarea>
<textarea name="message" id="message-field" data-richtext data-image-upload-url="/staff/upload_signature_image.php" placeholder="Nachricht eingeben... (Bilder und Screenshots mit Strg + V einfügen)" required></textarea>
<div id="reply-signature" style="margin:14px 0">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px">
@@ -1214,9 +1321,35 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<button type="submit" class="btn" onclick="document.getElementById('form-action').value='reply'">Öffentliche Antwort senden</button>
<button type="submit" class="btn secondary" onclick="document.getElementById('form-action').value='note'">Nur interne Notiz speichern</button>
</div>
<details style="margin-top:12px">
<summary style="cursor:pointer;font-weight:600">Später senden</summary>
<div style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin-top:8px">
<div><label for="send_at" style="margin-top:0">Senden am</label><input type="datetime-local" id="send_at" name="send_at" min="<?= e(date('Y-m-d\TH:i')) ?>" max="<?= e(date('Y-m-d\TH:i', time() + SCHEDULED_REPLY_MAX_DAYS * 86400)) ?>" style="width:auto"></div>
<button type="submit" class="btn secondary" onclick="document.getElementById('form-action').value='schedule_reply'">Antwort einplanen</button>
</div>
<div class="muted" style="font-size:.78rem;margin-top:4px">Die Antwort geht zur gewählten Zeit (Serverzeit) automatisch an den Kunden. Status und Signatur gelten wie oben gewählt; Anhänge sind hier nicht möglich.</div>
</details>
</form>
</div>
<?php if ($scheduledReplies): ?>
<div class="card" style="border-left:4px solid #6366f1">
<h3>Geplante Antworten</h3>
<?php foreach ($scheduledReplies as $sr): ?>
<div style="padding:8px 0;border-top:1px solid var(--border-soft)">
<div style="display:flex;justify-content:space-between;gap:10px;align-items:center;flex-wrap:wrap">
<strong>Wird gesendet am <?= e(format_dt((string) $sr['send_at'])) ?></strong>
<span class="muted" style="font-size:.8rem">von <strong><?= e($sr['staff_name'] ?? '–') ?></strong></span>
<?php if ((int) $sr['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin'): ?>
<form method="post" style="margin:0" onsubmit="return confirm('Geplante Antwort abbrechen?')"><?= csrf_field() ?><input type="hidden" name="action" value="cancel_scheduled_reply"><input type="hidden" name="scheduled_id" value="<?= (int) $sr['id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 10px;font-size:.78rem">Abbrechen</button></form>
<?php endif; ?>
</div>
<div style="font-size:.86rem;margin-top:6px;max-height:140px;overflow:auto"><?= $sr['message'] ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($activity): ?>
<div class="card">
<h3>Aktivität</h3>
@@ -1314,6 +1447,23 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
</script>
</div>
<div class="card" data-ptab="manage" style="padding:14px 20px">
<div style="font-size:.71rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Verknüpfte Tickets</div>
<?php foreach ($ticketLinks as $lk): [$lkLabel, $lkColor] = status_label($lk['status']); ?>
<div style="display:flex;gap:8px;align-items:center;justify-content:space-between;padding:7px 0;border-top:1px solid var(--border-soft);margin-top:8px">
<div style="min-width:0"><div class="muted" style="font-size:.74rem"><?= e($lk['label']) ?></div><a href="/staff/ticket_view.php?n=<?= urlencode($lk['number']) ?>"><strong>#<?= e($lk['number']) ?></strong> · <?= e($lk['subject']) ?></a> <span class="badge" style="background:<?= $lkColor ?>"><?= e($lkLabel) ?></span></div>
<form method="post" style="margin:0" onsubmit="return confirm('Verknüpfung entfernen?')"><?= csrf_field() ?><input type="hidden" name="action" value="link_remove"><input type="hidden" name="link_id" value="<?= (int) $lk['id'] ?>"><button type="submit" title="Verknüpfung entfernen" style="background:none;border:none;color:var(--muted);cursor:pointer">✕</button></form>
</div>
<?php endforeach; ?>
<?php if (!$ticketLinks): ?><p class="muted" style="font-size:.84rem">Keine verknüpften Tickets.</p><?php endif; ?>
<?php if ($blockers = ticket_open_blockers($pdo, (int) $ticket['id'])): ?><div style="padding:8px 10px;margin-top:8px;border-radius:7px;background:#fef3c7;color:#92400e;font-size:.82rem">Wird noch blockiert durch <?= e(implode(', ', array_map(fn($b) => '#' . $b['number'], $blockers))) ?>.</div><?php endif; ?>
<form method="post" style="display:flex;gap:6px;align-items:end;margin-top:8px;flex-wrap:wrap"><?= csrf_field() ?><input type="hidden" name="action" value="link_add">
<div style="flex:1;min-width:110px"><label style="font-size:.75rem" for="link_number">Ticketnummer</label><input type="text" id="link_number" name="link_number" required maxlength="40" placeholder="z. B. 923425"></div>
<div style="min-width:130px"><label style="font-size:.75rem" for="link_type">Art</label><select id="link_type" name="link_type"><?php foreach (ticket_link_types() as $lt => $ll): ?><option value="<?= e($lt) ?>"><?= e($ll[0]) ?></option><?php endforeach; ?></select></div>
<button class="btn secondary" type="submit">Verknüpfen</button>
</form>
</div>
<div class="card" data-ptab="manage" style="padding:14px 20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="font-size:.71rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Zugehörige Probleme</span><?php if ($staff['role'] === 'admin'): ?><a class="btn secondary" style="padding:3px 8px;font-size:.75rem" href="/staff/problems.php">Verwalten</a><?php endif; ?></div>
<?php if ($ticketProblems): foreach ($ticketProblems as $problemItem): ?>
@@ -1888,7 +2038,14 @@ function toggleRaw(id) {
</script>
<script src="/assets/js/ticket-lightbox.js?v=<?= @filemtime(__DIR__ . '/../assets/js/ticket-lightbox.js') ?: time() ?>"></script>
<style>
/* Eingeklappter E-Mail-Verlauf: gleiche Optik wie der Knopf „Quelltext“ */
.email-quote-wrap { margin:12px 0 4px; }
.email-quote-toggle { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:.76rem; font-weight:600; line-height:1; padding:6px 12px; border:1px solid var(--border,#e2e8f0); border-radius:999px; background:var(--bg-soft,#f8fafc); color:var(--muted,#64748b); cursor:pointer; transition:background .15s,border-color .15s,color .15s; }
.email-quote-toggle:hover { background:var(--surface,#fff); border-color:var(--primary,#0f6f6a); color:var(--primary,#0f6f6a); }
.email-quote-toggle:focus-visible { outline:2px solid var(--primary,#0f6f6a); outline-offset:2px; }
.email-quoted { margin-top:10px; padding:4px 0 4px 14px; border-left:3px solid var(--border,#e2e8f0); color:var(--muted,#64748b); font-size:.92em; }
.tv-tabs { grid-column:1 / -1; display:flex; gap:2px; margin:0; border-bottom:1px solid var(--border,#e2e8f0); overflow-x:auto; overflow-y:hidden; }
.tv-tab { background:none; border:0; border-bottom:3px solid transparent; padding:9px 14px; margin-bottom:-1px; cursor:pointer; font:inherit; font-weight:600; color:var(--muted,#64748b); border-radius:8px 8px 0 0; white-space:nowrap; }
.tv-tab:hover { color:var(--text,#0f172a); background:var(--surface,#fff); }
+26 -2
View File
@@ -12,6 +12,20 @@ $agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER
$bulkErrors = [];
$resolutionReasons = ticket_resolution_reasons();
// ---------------------------------------------------------------
// E-Mail-Abruf von Hand starten
// ---------------------------------------------------------------
require_once __DIR__ . '/../includes/mail_fetch_manual.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'fetch_mail_now') {
verify_csrf();
$fetch = mail_fetch_manual($pdo);
flash($fetch['level'], $fetch['message']);
audit_log((int) $staff['id'], 'E-Mail-Abruf von Hand gestartet (' . $fetch['processed'] . ' E-Mail(s))');
redirect('/staff/tickets.php' . (($_POST['return_query'] ?? '') !== '' ? '?' . ltrim((string) $_POST['return_query'], '?') : ''));
}
$fetchAvailable = mail_fetch_available($pdo);
$fetchLastRun = (string) get_setting('cron_fetch_emails_last_run', '');
// ---------------------------------------------------------------
// Gespeicherte Ansichten: speichern / löschen
// ---------------------------------------------------------------
@@ -369,8 +383,18 @@ try {
?>
<div class="topbar">
<h1>Tickets</h1>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?php if ($fetchAvailable): ?>
<form method="post" style="display:inline" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='⏳ Abruf läuft …';">
<?= csrf_field() ?>
<input type="hidden" name="action" value="fetch_mail_now">
<input type="hidden" name="return_query" value="<?= e((string) ($_SERVER['QUERY_STRING'] ?? '')) ?>">
<button type="submit" class="btn secondary" title="<?= $fetchLastRun !== '' && $fetchLastRun > '1971' ? 'Letzter Abruf: ' . e(format_dt($fetchLastRun)) : 'Noch kein Abruf' ?>">📥 E-Mails jetzt abrufen</button>
</form>
<?php endif; ?>
<a href="/staff/ticket_create.php" class="btn">+ Ticket erstellen</a>
</div>
</div>
<!-- Tabs + Schnellfilter-Dropdown -->
<?php
@@ -380,8 +404,8 @@ $statusTabs = [
'on_hold' => ['Pausiert', $tabCounts['on_hold']],
'resolved' => ['Gelöst', $tabCounts['resolved']],
'closed' => ['Geschlossen', $tabCounts['closed']],
'snoozed' => ['💤 Wiedervorlage', $tabCounts['snoozed']],
'banned' => ['⛔ Gesperrt', $tabCounts['banned']],
'snoozed' => ['Wiedervorlage', $tabCounts['snoozed']],
'banned' => ['Gesperrt', $tabCounts['banned']],
'all' => ['Alle', array_sum($tabCounts) - $tabCounts['snoozed']],
];
$quickFilters = [