Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:25 +00:00
parent 27b3e4dbaf
commit b207fa4944
4 changed files with 222 additions and 0 deletions
@@ -0,0 +1,91 @@
<?php
/**
* Verknüpfte Tickets: beliebige Tickets lassen sich miteinander verbinden (verwandt, doppelt, blockiert), unabhängig von den
* „Problemen“ (includes/problems.php). Eine Verknüpfung wird einmal gespeichert (ticket_id → linked_ticket_id) und von beiden
* Tickets aus angezeigt, mit der jeweils passenden Bezeichnung.
*/
/** @return array<string,array{0:string,1:string}> Art => [Bezeichnung vom Quell-Ticket aus, Bezeichnung vom Ziel-Ticket aus] */
function ticket_link_types(): array
{
return [
'related' => ['Verwandt mit', 'Verwandt mit'],
'duplicate' => ['Doppelt zu', 'Hat Duplikat'],
'blocks' => ['Blockiert', 'Blockiert durch'],
];
}
/**
* Verknüpft zwei Tickets. Gibt eine Fehlermeldung zurück oder null bei Erfolg.
* Der Mitarbeiter muss beide Tickets sehen dürfen ($deptScope: Abteilungs-IDs des Mitarbeiters, null = alle).
*/
function ticket_link_add(PDO $pdo, int $ticketId, string $targetNumber, string $type, int $staffId, ?array $deptScope = null): ?string
{
if (!isset(ticket_link_types()[$type])) return 'Ungültige Art der Verknüpfung.';
$targetNumber = ltrim(trim($targetNumber), '#');
if ($targetNumber === '') return 'Bitte eine Ticketnummer angeben.';
$stmt = $pdo->prepare('SELECT id, department_id FROM tickets WHERE ticket_number = ?');
$stmt->execute([$targetNumber]);
$target = $stmt->fetch();
if (!$target) return 'Ticket #' . $targetNumber . ' wurde nicht gefunden.';
$targetId = (int) $target['id'];
if ($targetId === $ticketId) return 'Ein Ticket lässt sich nicht mit sich selbst verknüpfen.';
if ($deptScope !== null && !in_array((int) $target['department_id'], array_map('intval', $deptScope), true)) {
return 'Ticket #' . $targetNumber . ' gehört zu einer Abteilung, auf die Sie keinen Zugriff haben.';
}
$exists = $pdo->prepare('SELECT 1 FROM ticket_links WHERE (ticket_id = ? AND linked_ticket_id = ?) OR (ticket_id = ? AND linked_ticket_id = ?)');
$exists->execute([$ticketId, $targetId, $targetId, $ticketId]);
if ($exists->fetchColumn()) return 'Die beiden Tickets sind bereits verknüpft.';
$pdo->prepare('INSERT INTO ticket_links (ticket_id, linked_ticket_id, link_type, created_by, created_at) VALUES (?,?,?,?,?)')
->execute([$ticketId, $targetId, $type, $staffId > 0 ? $staffId : null, db_now()]);
return null;
}
/** Entfernt eine Verknüpfung, sofern sie zu diesem Ticket gehört. */
function ticket_link_remove(PDO $pdo, int $linkId, int $ticketId): bool
{
$stmt = $pdo->prepare('DELETE FROM ticket_links WHERE id = ? AND (ticket_id = ? OR linked_ticket_id = ?)');
$stmt->execute([$linkId, $ticketId, $ticketId]);
return $stmt->rowCount() > 0;
}
/**
* Alle Verknüpfungen eines Tickets aus dessen Sicht. Tickets aus Abteilungen, die der Mitarbeiter nicht sehen darf, bleiben weg.
* @return list<array{id:int,type:string,label:string,number:string,subject:string,status:string}>
*/
function ticket_links_for(PDO $pdo, int $ticketId, ?array $deptScope = null): array
{
$stmt = $pdo->prepare(
'SELECT l.id, l.ticket_id, l.linked_ticket_id, l.link_type, t.ticket_number, t.subject, t.status, t.department_id
FROM ticket_links l
JOIN tickets t ON t.id = CASE WHEN l.ticket_id = ? THEN l.linked_ticket_id ELSE l.ticket_id END
WHERE l.ticket_id = ? OR l.linked_ticket_id = ?
ORDER BY l.id'
);
$stmt->execute([$ticketId, $ticketId, $ticketId]);
$types = ticket_link_types();
$scope = $deptScope === null ? null : array_map('intval', $deptScope);
$out = [];
foreach ($stmt->fetchAll() as $row) {
if ($scope !== null && !in_array((int) $row['department_id'], $scope, true)) continue;
$outgoing = (int) $row['ticket_id'] === $ticketId;
$labels = $types[$row['link_type']] ?? ['Verknüpft mit', 'Verknüpft mit'];
$out[] = [
'id' => (int) $row['id'], 'type' => (string) $row['link_type'], 'label' => $outgoing ? $labels[0] : $labels[1],
'number' => (string) $row['ticket_number'], 'subject' => (string) $row['subject'], 'status' => (string) $row['status'],
];
}
return $out;
}
/** Offene Tickets, die dieses Ticket blockieren (Hinweis vor dem Schließen). */
function ticket_open_blockers(PDO $pdo, int $ticketId): array
{
return array_values(array_filter(ticket_links_for($pdo, $ticketId), fn($l) => $l['type'] === 'blocks' && $l['label'] === 'Blockiert durch' && !in_array($l['status'], ['resolved', 'closed'], true)));
}
/** Beim Löschen eines Tickets die Verknüpfungen mit entfernen. */
function ticket_links_purge(PDO $pdo, int $ticketId): void
{
$pdo->prepare('DELETE FROM ticket_links WHERE ticket_id = ? OR linked_ticket_id = ?')->execute([$ticketId, $ticketId]);
}
@@ -0,0 +1,80 @@
<?php
/**
* Erinnerung an den Bearbeiter: Wartet ein ihm zugewiesenes Ticket seit mehreren Arbeitsstunden auf eine Antwort, bekommt er einen Hinweis
* (Push auf die Android-App und im Browser, auf Wunsch auch per E-Mail), bevor die SLA-Frist reißt. Pro Kundennachricht gibt es nur eine
* Erinnerung. Gezählt wird in Arbeitszeit (Geschäftszeiten und Feiertage aus der SLA-Einstellung, sonst die Uhr).
* Läuft im Hintergrundjob „SLA-Prüfung“ (cron/check_sla.php, alle 15 Minuten).
*/
function wait_reminder_enabled(): bool
{
return get_setting('wait_reminder_enabled', '1') === '1';
}
function wait_reminder_hours(): int
{
return max(1, min(72, (int) get_setting('wait_reminder_hours', '4')));
}
function wait_reminder_email(): bool
{
return get_setting('wait_reminder_email', '0') === '1';
}
/**
* Prüft alle zugewiesenen, unbeantworteten Tickets und erinnert die Bearbeiter.
* @return array{checked:int,sent:int,log:string[]}
*/
function wait_reminders_run(PDO $pdo, ?string $now = null): array
{
$res = ['checked' => 0, 'sent' => 0, 'log' => []];
if (!wait_reminder_enabled()) return $res;
$now = $now ?? db_now();
$needSeconds = wait_reminder_hours() * 3600;
$snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1';
$stmt = $pdo->prepare(
"SELECT t.*, s.name AS staff_name, s.email AS staff_email,
COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at) AS last_customer_at
FROM tickets t
JOIN staff s ON s.id = t.assigned_staff_id AND s.is_active = 1
WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff'
AND t.sla_paused_at IS NULL AND t.is_overdue = 0
AND (t.resolution_due_at IS NULL OR t.resolution_due_at > ?)
AND {$snooze}
ORDER BY t.id
LIMIT 500"
);
$stmt->execute([$now]);
$mark = $pdo->prepare('UPDATE tickets SET wait_reminded_at = ?, updated_at = updated_at WHERE id = ?');
foreach ($stmt->fetchAll() as $t) {
$res['checked']++;
$since = (string) $t['last_customer_at'];
// Pro Kundennachricht nur eine Erinnerung
if (!empty($t['wait_reminded_at']) && (string) $t['wait_reminded_at'] >= $since) continue;
$waited = ticket_sla_working_seconds_between($since, $now);
if ($waited < $needSeconds) continue;
$mark->execute([$now, (int) $t['id']]);
$hours = (int) floor($waited / 3600);
$assigned = (int) $t['assigned_staff_id'];
log_activity($pdo, (int) $t['id'], null, "Erinnerung an den Bearbeiter: wartet seit {$hours} Std. auf Antwort");
if (function_exists('fcm_notify_staff') && fcm_configured()) {
fcm_notify_staff([$assigned], fcm_data('ticket_wait', $t), (int) $t['department_id']);
}
if (function_exists('push_to_staff')) {
[$title, $body] = function_exists('fcm_message') ? fcm_message('ticket_wait', $t) : ['Ticket wartet auf Antwort', '#' . $t['ticket_number']];
try { push_to_staff($assigned, $title, $body, '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number'])); } catch (Throwable $e) { error_log('wait_reminder push: ' . $e->getMessage()); }
}
if (wait_reminder_email() && trim((string) $t['staff_email']) !== '') {
$url = base_url() . '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number']);
$inner = email_p('Hallo ' . e(explode(' ', trim((string) $t['staff_name']))[0]) . ', das Ticket <strong>#' . e((string) $t['ticket_number']) . '</strong> „' . e(mb_strimwidth((string) $t['subject'], 0, 80, '…')) . '“ wartet seit etwa ' . $hours . ' Stunden auf deine Antwort.')
. email_button('Ticket öffnen', $url);
email_send_and_log((string) $t['staff_email'], 'Ticket #' . $t['ticket_number'] . ' wartet auf deine Antwort', email_wrap($inner), null, 'Erinnerung-an-Bearbeiter-Mail', (string) $t['ticket_number']);
}
$res['sent']++;
$res['log'][] = "[ok] Erinnerung an {$t['staff_name']}: Ticket #{$t['ticket_number']} wartet seit {$hours} Std.";
}
return $res;
}
@@ -21,6 +21,7 @@ function web_handoff_pages(array $staff): array
// Alle Mitarbeiter
['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true],
['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true],
['apps', 'Apps und Downloads', '/staff/apps.php', 'Allgemein', true],
// Mit Recht
['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')],
['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')],
@@ -44,6 +44,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail));
}
if ($action === 'gdpr_export' || $action === 'gdpr_erase') {
if ($staff['role'] !== 'admin' || !gdpr_tools_enabled()) {
flash('error', 'Die Datenschutz-Werkzeuge sind nicht verfügbar (nur Administratoren, in den Einstellungen einzuschalten).');
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
}
$ref = $isGuest ? ['email' => $guestEmail] : ['user_id' => $userId];
if ($action === 'gdpr_export') {
$data = gdpr_customer_export($pdo, $ref);
audit_log($staff['id'], 'Kundendaten exportiert (' . ($isGuest ? 'Gast ' . $guestEmail : 'Kunde #' . $userId) . ')');
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="kundendaten-' . ($isGuest ? 'gast' : $userId) . '-' . date('Ymd') . '.json"');
header('X-Content-Type-Options: nosniff');
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
if (trim((string) ($_POST['confirm_email'] ?? '')) !== (string) $customer['email']) {
flash('error', 'Zur Bestätigung muss die E-Mail-Adresse des Kunden genau eingegeben werden.');
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
}
$res = gdpr_customer_erase($pdo, $ref, !empty($_POST['force']));
if ($res['error']) {
flash('error', $res['error']);
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
}
audit_log($staff['id'], 'Kundendaten gelöscht (' . ($isGuest ? 'Gast' : 'Kunde #' . $userId) . '): ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt');
flash('success', 'Kundendaten gelöscht: ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt.');
redirect('/staff/customers.php');
}
if ($action === 'add_note') {
$note = trim($_POST['note'] ?? '');
if ($note !== '') {
@@ -219,6 +248,27 @@ require __DIR__ . '/_header.php';
<?php endif; ?>
</div>
<?php if ($staff['role'] === 'admin' && gdpr_tools_enabled()): ?>
<div class="card" style="padding:16px 18px;margin-top:16px">
<div style="font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:8px">Datenschutz (DSGVO)</div>
<form method="post" style="margin-bottom:12px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="gdpr_export">
<button type="submit" class="btn secondary" style="width:100%;justify-content:center">Alle Daten exportieren (JSON)</button>
<p class="muted" style="font-size:.76rem;margin-top:6px">Auskunft: Stammdaten, alle Tickets mit Nachrichten und Anhangsnamen, Notizen. Die Datei bitte vor der Weitergabe prüfen.</p>
</form>
<form method="post" onsubmit="return confirm('Alle personenbezogenen Daten dieses Kunden unwiderruflich löschen?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="gdpr_erase">
<label style="font-size:.78rem" for="gdpr_confirm">Zur Bestätigung die E-Mail-Adresse eingeben</label>
<input type="text" id="gdpr_confirm" name="confirm_email" autocomplete="off" placeholder="<?= e($customer['email']) ?>" required>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;font-size:.8rem;margin-top:8px"><input type="checkbox" name="force" value="1" style="width:auto"> Auch bei noch offenen Tickets löschen</label>
<button type="submit" class="btn" style="width:100%;justify-content:center;margin-top:10px;background:var(--danger,#dc2626)">Kundendaten löschen</button>
<p class="muted" style="font-size:.76rem;margin-top:6px">Tickets bleiben als anonymisierte Einträge (Nummer, Betreff, Status, Zeiten). Nachrichten und Dateien des Kunden, Notizen und das Konto werden entfernt.</p>
</form>
</div>
<?php endif; ?>
</div>
</div>