Upload via GUI (396 Dateien)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user