289 lines
13 KiB
PHP
289 lines
13 KiB
PHP
<?php
|
||
require_once __DIR__ . '/includes/bootstrap.php';
|
||
|
||
$pdo = get_db();
|
||
$number = $_GET['n'] ?? '';
|
||
|
||
$stmt = $pdo->prepare(
|
||
'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name
|
||
FROM tickets t
|
||
JOIN priorities p ON p.id = t.priority_id
|
||
JOIN departments d ON d.id = t.department_id
|
||
WHERE t.ticket_number = ?'
|
||
);
|
||
$stmt->execute([$number]);
|
||
$ticket = $stmt->fetch();
|
||
|
||
if (!$ticket) {
|
||
flash('error', 'Dieses Ticket wurde nicht gefunden.');
|
||
redirect('/home.php');
|
||
}
|
||
|
||
$user = current_user();
|
||
if (verify_ticket_access_token((int) $ticket['id'], (string) ($_GET['token'] ?? ''))) {
|
||
if ($user) {
|
||
claim_ticket_for_customer($pdo, (int) $ticket['id'], $user);
|
||
}
|
||
$_SESSION['guest_ticket_access'][$ticket['ticket_number']] = true;
|
||
session_write_close();
|
||
redirect('/ticket_view.php?n=' . urlencode($ticket['ticket_number']));
|
||
}
|
||
|
||
$allowed = ($user && $ticket['user_id'] == $user['id'])
|
||
|| !empty($_SESSION['guest_ticket_access'][$ticket['ticket_number']]);
|
||
|
||
if (!$allowed) {
|
||
flash('error', 'Bitte melden Sie sich an oder bestätigen Sie Ihre E-Mail-Adresse, um dieses Ticket zu sehen.');
|
||
redirect('/ticket_lookup.php?n=' . urlencode($number));
|
||
}
|
||
|
||
$errors = [];
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
// Ticket-Bewertung (auch bei geschlossenem Ticket erlaubt)
|
||
if (($_POST['action'] ?? '') === 'rate_ticket') {
|
||
verify_csrf();
|
||
$rating = (int)($_POST['rating'] ?? 0);
|
||
$comment = trim($_POST['rating_comment'] ?? '');
|
||
if ($rating >= 1 && $rating <= 5 && get_setting('ticket_ratings_enabled', '1') === '1') {
|
||
$ratingSql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'
|
||
? 'INSERT INTO ticket_ratings (ticket_id, user_id, rating, comment, created_at) VALUES (?,?,?,?,?)
|
||
ON CONFLICT(ticket_id) DO UPDATE SET
|
||
user_id=excluded.user_id, rating=excluded.rating,
|
||
comment=excluded.comment, created_at=excluded.created_at'
|
||
: 'INSERT INTO ticket_ratings (ticket_id, user_id, rating, comment, created_at) VALUES (?,?,?,?,?)
|
||
ON DUPLICATE KEY UPDATE
|
||
user_id=VALUES(user_id), rating=VALUES(rating),
|
||
comment=VALUES(comment), created_at=VALUES(created_at)';
|
||
$pdo->prepare($ratingSql)
|
||
->execute([$ticket['id'], $user['id'] ?? null, $rating, $comment ?: null, db_now()]);
|
||
flash('success', 'Vielen Dank für Ihre Bewertung!');
|
||
}
|
||
redirect('/ticket_view.php?n=' . urlencode($ticket['ticket_number']));
|
||
}
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !in_array($ticket['status'], ['closed'])) {
|
||
verify_csrf();
|
||
$message = trim($_POST['message'] ?? '');
|
||
if ($message === '') {
|
||
$errors[] = 'Bitte geben Sie eine Nachricht ein.';
|
||
} else {
|
||
$now = db_now();
|
||
$stmt = $pdo->prepare(
|
||
'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at)
|
||
VALUES (?,?,?,?,0,?)'
|
||
);
|
||
$stmt->execute([$ticket['id'], 'customer', $user['id'] ?? null, $message, $now]);
|
||
$threadId = (int) $pdo->lastInsertId();
|
||
|
||
if (!empty($_FILES['attachments']['name'][0])) {
|
||
$uploadResults = handle_ticket_uploads_multi($_FILES['attachments']);
|
||
foreach ($uploadResults as $up) {
|
||
if (isset($up['error'])) {
|
||
$errors[] = $up['error'];
|
||
} else {
|
||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')
|
||
->execute([$threadId, $up['original_name'], $up['stored_name'], $up['filesize'], $now]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ein Kunden-Reply auf ein beantwortetes Ticket setzt den Status zurück auf "Offen"
|
||
$wasReopened = in_array($ticket['status'], ['answered', 'resolved', 'pending']);
|
||
$newStatus = $wasReopened ? 'open' : $ticket['status'];
|
||
$stmt = $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?');
|
||
$stmt->execute([$newStatus, $now, $ticket['id']]);
|
||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus);
|
||
|
||
log_activity($pdo, $ticket['id'], null, 'Kunde hat geantwortet');
|
||
if ($wasReopened) {
|
||
log_activity($pdo, $ticket['id'], null, 'Ticket wieder geöffnet');
|
||
}
|
||
notify_new_reply_channels($ticket, $message);
|
||
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']);
|
||
if ($newStatus !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]);
|
||
flash('success', 'Ihre Antwort wurde übermittelt.');
|
||
redirect('/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
}
|
||
|
||
// Thread laden
|
||
$stmt = $pdo->prepare(
|
||
"SELECT tt.*, s.name AS staff_name, u.name AS user_name
|
||
FROM ticket_threads tt
|
||
LEFT JOIN staff s ON s.id = tt.staff_id
|
||
LEFT JOIN users u ON u.id = tt.user_id
|
||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0
|
||
ORDER BY tt.created_at ASC"
|
||
);
|
||
$stmt->execute([$ticket['id']]);
|
||
$thread = $stmt->fetchAll();
|
||
|
||
// Anhänge je Thread-Eintrag
|
||
$attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?');
|
||
|
||
// Individuelle Felder, die bei der Ticket-Erstellung ausgefüllt wurden
|
||
$fieldStmt = $pdo->prepare(
|
||
"SELECT cf.label, cf.field_type, v.value FROM ticket_field_values v
|
||
JOIN custom_fields cf ON cf.id = v.custom_field_id
|
||
WHERE v.ticket_id = ? ORDER BY cf.sort_order, cf.label"
|
||
);
|
||
$fieldStmt->execute([$ticket['id']]);
|
||
$customFieldValues = $fieldStmt->fetchAll();
|
||
|
||
$pageTitle = "Ticket #{$ticket['ticket_number']}";
|
||
$activePage = 'dashboard';
|
||
require __DIR__ . '/includes/header_customer.php';
|
||
[$statusLabel, $statusColor] = status_label($ticket['status']);
|
||
?>
|
||
|
||
<div class="topbar">
|
||
<div>
|
||
<h1><?= e($ticket['subject']) ?></h1>
|
||
<span class="ticket-no">#<?= e($ticket['ticket_number']) ?></span>
|
||
<span class="badge" style="background:<?= $statusColor ?>"><?= e($statusLabel) ?></span>
|
||
<span class="badge" style="background:<?= e($ticket['priority_color']) ?>"><?= e($ticket['priority_name']) ?></span>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="muted"><?= e($ticket['department_name']) ?> · erstellt am <?= format_dt($ticket['created_at']) ?></p>
|
||
|
||
<?php if (!$user): ?>
|
||
<div class="card">
|
||
<strong>Ticket dauerhaft in Ihrem Konto speichern?</strong>
|
||
<p class="muted">Melden Sie sich an oder erstellen Sie ein Konto mit derselben E-Mail-Adresse. Dieses Ticket bleibt in diesem Browser freigeschaltet und kann danach in „Meine Tickets“ hinzugefügt werden.</p>
|
||
<a href="/login.php">Anmelden</a> · <a href="/register.php">Konto erstellen</a>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($customFieldValues): ?>
|
||
<div class="card" style="font-size:.88rem">
|
||
<?php foreach ($customFieldValues as $fv): ?>
|
||
<div style="padding:4px 0"><strong><?= e($fv['label']) ?>:</strong>
|
||
<?= $fv['field_type'] === 'checkbox' ? ($fv['value'] ? 'Ja' : 'Nein') : e($fv['value']) ?></div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($errors as $err): ?>
|
||
<div class="flash error"><?= e($err) ?></div>
|
||
<?php endforeach; ?>
|
||
|
||
<?php foreach ($thread as $msg):
|
||
$attStmt->execute([$msg['id']]);
|
||
$attachments = $attStmt->fetchAll();
|
||
$isStaff = $msg['author_type'] === 'staff';
|
||
?>
|
||
<div class="thread-msg <?= $isStaff ? 'from-staff' : '' ?>">
|
||
<div class="meta">
|
||
<span><strong><?= e($isStaff ? ($msg['staff_name'] ?? 'Support-Team') : ($msg['user_name'] ?? $ticket['requester_name'])) ?></strong> <?= $isStaff ? '(Support)' : '' ?></span>
|
||
<span><?= format_dt($msg['created_at']) ?></span>
|
||
</div>
|
||
<div class="body"><?= $isStaff ? $msg['message'] : nl2br(e($msg['message'])) ?></div>
|
||
<?php if ($attachments): ?>
|
||
<div class="body" style="padding-top:0">
|
||
<?php foreach ($attachments as $a): ?>
|
||
<a href="/download.php?id=<?= $a['id'] ?>">📎 <?= e($a['original_name']) ?></a><br>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
|
||
<div style="display:flex; justify-content:flex-end; margin-bottom:12px">
|
||
<a href="/ticket_print.php?n=<?= urlencode($ticket['ticket_number']) ?>" target="_blank" style="background:none; border:1px solid var(--border); border-radius:var(--radius); padding:7px 14px; font-size:.82rem; cursor:pointer; color:var(--muted); display:flex; align-items:center; gap:6px; text-decoration:none">🖨️ Ticket drucken</a>
|
||
</div>
|
||
|
||
<?php if ($ticket['status'] !== 'closed'): ?>
|
||
<div class="card" id="reply-form">
|
||
<h3>Antworten</h3>
|
||
<form method="post" enctype="multipart/form-data">
|
||
<?= csrf_field() ?>
|
||
<textarea name="message" placeholder="Ihre Nachricht..." required></textarea>
|
||
<label for="attachments">Anhänge (optional, mehrere möglich)</label>
|
||
<input type="file" id="attachments" name="attachments[]" multiple>
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn">Antwort senden</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<?php else: ?>
|
||
<p class="muted">Dieses Ticket ist geschlossen. Bei weiteren Fragen erstellen Sie bitte ein <a href="/ticket_new.php">neues Ticket</a>.</p>
|
||
|
||
<?php
|
||
$ratingsEnabled = get_setting('ticket_ratings_enabled', '1') === '1';
|
||
$existingRating = null;
|
||
if ($ratingsEnabled) {
|
||
$rs = get_db()->prepare('SELECT * FROM ticket_ratings WHERE ticket_id=?');
|
||
$rs->execute([$ticket['id']]);
|
||
$existingRating = $rs->fetch();
|
||
}
|
||
if ($ratingsEnabled):
|
||
?>
|
||
<div class="card" style="margin-top:16px; text-align:center; padding:28px">
|
||
<?php if ($existingRating): ?>
|
||
<div style="font-size:2rem; margin-bottom:8px"><?= str_repeat('⭐', $existingRating['rating']) ?><?= str_repeat('☆', 5 - $existingRating['rating']) ?></div>
|
||
<div style="font-weight:600; color:var(--ink)">Danke für Ihre Bewertung!</div>
|
||
<?php if ($existingRating['comment']): ?>
|
||
<p class="muted" style="font-size:.85rem; margin-top:6px"><?= e($existingRating['comment']) ?></p>
|
||
<?php endif; ?>
|
||
<?php else: ?>
|
||
<div style="font-size:1rem; font-weight:600; margin-bottom:16px; color:var(--ink)">Wie zufrieden waren Sie mit der Bearbeitung?</div>
|
||
<form method="post" id="rating-form">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="rate_ticket">
|
||
<input type="hidden" name="rating" id="rating-value" value="0">
|
||
<div class="star-rating" style="display:flex; justify-content:center; gap:8px; margin-bottom:16px">
|
||
<?php for ($i = 1; $i <= 5; $i++): ?>
|
||
<button type="button" onclick="setRating(<?= $i ?>)" id="star-<?= $i ?>"
|
||
style="background:none; border:none; font-size:2.2rem; cursor:pointer; transition:transform .1s; line-height:1">☆</button>
|
||
<?php endfor; ?>
|
||
</div>
|
||
<textarea name="rating_comment" placeholder="Kommentar (optional)" rows="2"
|
||
style="width:100%; max-width:400px; margin-bottom:12px; font-size:.85rem"></textarea><br>
|
||
<button type="submit" class="btn" id="rating-submit" disabled>Bewertung abschicken</button>
|
||
</form>
|
||
<script>
|
||
function setRating(val) {
|
||
document.getElementById('rating-value').value = val;
|
||
document.getElementById('rating-submit').disabled = false;
|
||
for (var i = 1; i <= 5; i++) {
|
||
document.getElementById('star-' + i).textContent = i <= val ? '⭐' : '☆';
|
||
document.getElementById('star-' + i).style.transform = i === val ? 'scale(1.2)' : 'scale(1)';
|
||
}
|
||
}
|
||
</script>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
|
||
<script>
|
||
(function() {
|
||
var lastCount = <?= count($thread) ?>;
|
||
var ticketNumber = <?= json_encode($ticket['ticket_number']) ?>;
|
||
function checkUpdates() {
|
||
fetch('/ticket_poll.php?n=' + encodeURIComponent(ticketNumber) + '&count=' + lastCount)
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(data){
|
||
if (data.new_count > lastCount) {
|
||
// Neue Nachrichten vorhanden – Seite sanft neu laden
|
||
window.location.reload();
|
||
}
|
||
})
|
||
.catch(function(){});
|
||
}
|
||
setInterval(checkUpdates, 15000);
|
||
})();
|
||
</script>
|
||
|
||
<style>
|
||
@media print {
|
||
.site-header, .site-header *, footer.public-footer, #reply-form, .topbar nav, .btn, .btn-row { display: none !important; }
|
||
.thread-msg { break-inside: avoid; border: 1px solid #ccc !important; margin-bottom: 12px !important; }
|
||
body { background: white !important; }
|
||
}
|
||
</style>
|
||
|
||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|