Quelltext des aktuellen Standes
<?php $this->layout('layouts/portal', [
'page_title' => 'Ticket #' . (int)$ticket['id'],
'ssp_active' => 'tickets',
]);
$du = ($portal_address ?? 'du') !== 'sie';
// HTML-Erkennung wie in der internen Ticketansicht: gespeicherte Mail-Bodies
// sind bereits serverseitig entschärft (sanitizeIncomingHtml).
$isHtml = static fn(string $s): bool => $s !== strip_tags($s);
$renderBody = function (string $body) use ($isHtml): string {
// Aeltere Mail-Bodys tragen die Word-Marken noch maskiert in sich.
$body = \OpenNIT\Tickets\TicketManager::stripOfficeMarkup($body);
return $isHtml($body) ? $body : nl2br($this->e($body));
};
$initials = static function (string $name): string {
$ini = '';
foreach (preg_split('/\s+/', trim($name)) ?: [] as $w) {
if ($w !== '') { $ini .= mb_strtoupper(mb_substr($w, 0, 1)); }
if (mb_strlen($ini) >= 2) { break; }
}
return $ini !== '' ? $ini : '?';
};
$fmtSize = static function (int $b): string {
if ($b >= 1048576) { return number_format($b / 1048576, 1, ',', '.') . ' MB'; }
if ($b >= 1024) { return number_format($b / 1024, 0, ',', '.') . ' KB'; }
return $b . ' B';
};
// Bilder & PDFs öffnen in der Vorschau-Lightbox (wie in der internen
// Ticketansicht); alle übrigen Anhänge weiterhin im neuen Tab.
$previewType = static function (string $mime): ?string {
if (in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) { return 'image'; }
if ($mime === 'application/pdf') { return 'pdf'; }
return null;
};
$renderAtts = function (array $atts) use ($fmtSize, $previewType): void {
if (empty($atts)) { return; }
echo '<div class="ssp-atts">';
foreach ($atts as $a) {
$url = '/portal/tickets/attachments/' . (int)$a['id'];
$type = $previewType((string)$a['mime_type']);
$view = $type !== null
? ' data-ssp-view="' . $this->e($url) . '" data-ssp-view-type="' . $type . '" data-ssp-view-name="' . $this->e($a['original_name']) . '"'
: '';
echo '<a class="ssp-att" href="' . $this->e($url) . '" target="_blank" rel="noopener"' . $view . '><i class="fas fa-paperclip" aria-hidden="true"></i> '
. $this->e($a['original_name']) . ' <small>(' . $this->e($fmtSize((int)$a['file_size'])) . ')</small></a>';
}
echo '</div>';
};
$fromLabel = $ticket['from_name'] !== '' ? (string)$ticket['from_name'] : (string)$ticket['from_email'];
?>
<a href="/portal/tickets" class="ssp-back"><i class="fas fa-arrow-left" aria-hidden="true"></i> <?= $this->te('Meine Tickets') ?></a>
<div class="ssp-ticket-head">
<h1><?= $this->e($ticket['subject']) ?> <span class="ssp-ticket-id">#<?= (int)$ticket['id'] ?></span></h1>
<p class="ssp-ticket-info">
<span class="status-pill <?= $this->e($status_pills[$ticket['status']] ?? 'status-pill--neutral') ?>">
<?= $this->e($status_labels[$ticket['status']] ?? $ticket['status']) ?>
</span>
<?php if (!empty($assignees)): ?>
<span><i class="fas fa-user" aria-hidden="true"></i><?= $this->e(implode(', ', $assignees)) ?></span>
<?php endif; ?>
<span><i class="fas fa-clock" aria-hidden="true"></i>erstellt <?= $this->te('{zeitpunkt} Uhr', ['zeitpunkt' => date('d.m.Y H:i', strtotime($ticket['created_at']))]) ?></span>
</p>
</div>
<div class="ssp-thread">
<!-- Ursprüngliche Meldung -->
<div class="ssp-msg ssp-msg-mine">
<span class="ssp-msg-avatar"><?= $this->e($initials($fromLabel)) ?></span>
<div class="ssp-msg-bubble">
<div class="ssp-msg-meta">
<strong><?= $this->e($fromLabel) ?></strong>
<span><?= $this->te('{zeitpunkt} Uhr', ['zeitpunkt' => date('d.m.Y H:i', strtotime($ticket['created_at']))]) ?></span>
</div>
<div class="ssp-msg-body"><?= $renderBody((string)($ticket['description'] ?? '')) ?></div>
<?php $renderAtts($attachments[0] ?? []); ?>
</div>
</div>
<?php
// Antworten und Bearbeiter-Übergaben chronologisch zu einer Timeline
// mischen – Übergaben als neutrale System-Zeilen ohne Akteur.
$timeline = [];
foreach ($replies as $r) {
$timeline[] = ['kind' => 'reply', 'ts' => $r['created_at'], 'data' => $r];
}
foreach (($assign_events ?? []) as $ev) {
$timeline[] = ['kind' => 'event', 'ts' => $ev['ts'], 'data' => $ev];
}
usort($timeline, static fn($a, $b) => strcmp((string)$a['ts'], (string)$b['ts']));
?>
<?php foreach ($timeline as $item): ?>
<?php if ($item['kind'] === 'event'): ?>
<div class="ssp-event" title="<?= $this->te('{zeitpunkt} Uhr', ['zeitpunkt' => date('d.m.Y H:i', strtotime($item['ts']))]) ?>">
<i class="fas fa-user" aria-hidden="true"></i> <?= $this->e($item['data']['text']) ?>
</div>
<?php else: $r = $item['data']; $mine = $r['author_type'] === 'customer'; $authorName = $r['author_name'] ?: ($mine ? 'Melder' : 'IT-Team'); ?>
<div class="ssp-msg <?= $mine ? 'ssp-msg-mine' : 'ssp-msg-staff' ?>">
<span class="ssp-msg-avatar"><?= $mine ? $this->e($initials($authorName)) : '<i class="fas fa-headset" aria-hidden="true"></i>' ?></span>
<div class="ssp-msg-bubble">
<div class="ssp-msg-meta">
<strong><?= $this->e($authorName) ?></strong>
<span><?= $this->te('{zeitpunkt} Uhr', ['zeitpunkt' => date('d.m.Y H:i', strtotime($r['created_at']))]) ?></span>
</div>
<div class="ssp-msg-body"><?= $renderBody((string)$r['body']) ?></div>
<?php $renderAtts($attachments[(int)$r['id']] ?? []); ?>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!empty($csat_on) && in_array((string)$ticket['status'], ['solved', 'closed'], true)):
$csatRow = $csat ?? null;
$csatRated = !empty($csatRow) && !empty($csatRow['completed_at']);
$csatValue = (int)($csatRow['rating'] ?? 0);
$csatUi = $csat_ui ?? ['title' => 'Wie zufrieden waren Sie mit der Bearbeitung?', 'intro' => '', 'thanks' => 'Danke für Ihre Bewertung!'];
?>
<div class="ssp-card ssp-feedback">
<h2><span class="ssp-card-icon"><i class="fas fa-star" aria-hidden="true"></i></span> <?= $this->e($csatUi['title']) ?></h2>
<?php if ($csatRated): ?>
<p class="ssp-feedback-given">
<span class="ssp-stars" aria-label="<?= $csatValue ?> von 5 Sternen">
<?php for ($i = 1; $i <= 5; $i++): ?><span class="ssp-star<?= $i <= $csatValue ? ' is-on' : '' ?>" aria-hidden="true">★</span><?php endfor; ?>
</span>
</p>
<?php if (!empty($csatRow['comment'])): ?><p class="ssp-feedback-comment"><?= nl2br($this->e($csatRow['comment'])) ?></p><?php endif; ?>
<p class="ssp-hint"><?= $this->e($csatUi['thanks']) ?> <?= $du ? $this->te('Du kannst deine Bewertung unten bei Bedarf ändern.') : $this->te('Sie können Ihre Bewertung unten bei Bedarf ändern.') ?></p>
<?php elseif (trim((string)$csatUi['intro']) !== ''): ?>
<p class="ssp-hint"><?= nl2br($this->e($csatUi['intro'])) ?></p>
<?php endif; ?>
<form method="POST" action="/portal/tickets/<?= (int)$ticket['id'] ?>/feedback" class="ssp-feedback-form">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<!-- 5 Sterne, von 5 → 1 wegen direction:rtl (Sterne links des gewählten leuchten). -->
<div class="ssp-stars-input" role="radiogroup" aria-label="<?= $this->te('Sterne-Bewertung') ?>">
<?php for ($i = 5; $i >= 1; $i--): ?>
<input type="radio" name="rating" id="ssp-star<?= $i ?>" value="<?= $i ?>" <?= $csatValue === $i ? 'checked' : '' ?><?= $i === 5 ? ' required' : '' ?>>
<label for="ssp-star<?= $i ?>" title="<?= $i ?> von 5"><span class="ssp-sr-only"><?= $i ?> Sterne</span>★</label>
<?php endfor; ?>
</div>
<div class="form-group">
<label class="ssp-sr-only" for="ssp-feedback-comment"><?= $this->te('Kommentar (optional)') ?></label>
<textarea id="ssp-feedback-comment" name="comment" class="form-control" rows="3" maxlength="2000"
placeholder="<?= $this->te('Kommentar (optional)…') ?>"><?= $this->e($csatRow['comment'] ?? '') ?></textarea>
</div>
<button type="submit" class="btn btn-primary" data-busy-label="Wird gesendet …"><i class="fas fa-paper-plane" aria-hidden="true"></i> <?= $csatRated ? 'Bewertung aktualisieren' : 'Bewertung abgeben' ?></button>
</form>
</div>
<?php endif; ?>
<?php if ($ticket['status'] !== 'closed'): ?>
<div class="ssp-card ssp-replybox ssp-form">
<h2><span class="ssp-card-icon"><i class="fas fa-reply" aria-hidden="true"></i></span> <?= $this->te('Antworten') ?></h2>
<?php if ($ticket['status'] === 'solved'): ?>
<p class="ssp-hint"><?= $du
? $this->te('Das Ticket ist als gelöst markiert. Wenn dein Anliegen noch nicht erledigt ist, antworte einfach – das Ticket wird automatisch wieder geöffnet.')
: $this->te('Das Ticket ist als gelöst markiert. Wenn Ihr Anliegen noch nicht erledigt ist, antworten Sie einfach – das Ticket wird automatisch wieder geöffnet.') ?></p>
<?php endif; ?>
<form method="POST" action="/portal/tickets/<?= (int)$ticket['id'] ?>/reply" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="form-group">
<label class="ssp-sr-only" for="ssp-reply-body"><?= $du ? $this->te('Deine Nachricht an die IT') : $this->te('Ihre Nachricht an die IT') ?></label>
<textarea id="ssp-reply-body" name="body" class="form-control" rows="5" required
placeholder="<?= $du ? $this->te('Deine Nachricht an die IT…') : $this->te('Ihre Nachricht an die IT…') ?>"></textarea>
</div>
<div class="form-group">
<label class="ssp-att-label" for="ssp-reply-att"><i class="fas fa-paperclip" aria-hidden="true"></i> <?= $this->te('Anhänge (optional)') ?></label>
<input type="file" id="ssp-reply-att" name="attachments[]" class="form-control" multiple>
</div>
<button type="submit" class="btn btn-primary" data-busy-label="Wird gesendet …"><i class="fas fa-paper-plane" aria-hidden="true"></i> <?= $this->te('Antwort senden') ?></button>
</form>
</div>
<?php else: ?>
<div class="ssp-card">
<p class="ssp-hint"><i class="fas fa-lock" aria-hidden="true"></i> <?= $du
? $this->te('Dieses Ticket ist abgeschlossen. Bei einem neuen Anliegen erstelle bitte ein neues Ticket.')
: $this->te('Dieses Ticket ist abgeschlossen. Bei einem neuen Anliegen erstellen Sie bitte ein neues Ticket.') ?>
<a href="/portal/tickets/new"><?= $this->te('Neues Ticket erstellen') ?></a></p>
</div>
<?php endif; ?>
<!-- Vorschau-Lightbox für Bild- und PDF-Anhänge (Muster der internen Ticketansicht) -->
<div id="ssp-lightbox" class="ssp-lightbox" hidden>
<div class="ssp-lightbox-backdrop" data-ssp-lb-close></div>
<button type="button" class="ssp-lightbox-btn ssp-lightbox-close" data-ssp-lb-close aria-label="<?= $this->te('Schließen') ?>" title="<?= $this->te('Schließen (Esc)') ?>">×</button>
<button type="button" class="ssp-lightbox-btn ssp-lightbox-nav ssp-lightbox-prev" aria-label="<?= $this->te('Vorheriges') ?>" title="<?= $this->te('Vorheriges (←)') ?>">❮</button>
<figure class="ssp-lightbox-figure" role="dialog" aria-modal="true" aria-label="<?= $this->te('Anhang-Vorschau') ?>">
<img id="ssp-lightbox-img" class="ssp-lightbox-img" src="" alt="" hidden>
<iframe id="ssp-lightbox-frame" class="ssp-lightbox-frame" src="" title="<?= $this->te('PDF-Vorschau') ?>" hidden></iframe>
<figcaption class="ssp-lightbox-caption">
<span id="ssp-lightbox-name"></span>
<a id="ssp-lightbox-open" href="#" target="_blank" rel="noopener"><?= $this->te('In neuem Tab öffnen ↗') ?></a>
</figcaption>
</figure>
<button type="button" class="ssp-lightbox-btn ssp-lightbox-nav ssp-lightbox-next" aria-label="<?= $this->te('Nächstes') ?>" title="<?= $this->te('Nächstes (→)') ?>">❯</button>
</div>
<script>
// Vorschau-Lightbox: Bild-/PDF-Anhänge und inline eingebettete Bilder öffnen
// im Overlay statt im neuen Tab; Vor/Zurück blättert durch alle Vorschauen.
(function () {
var modal = document.getElementById('ssp-lightbox');
if (!modal) return;
var imgEl = document.getElementById('ssp-lightbox-img');
var frameEl = document.getElementById('ssp-lightbox-frame');
var nameEl = document.getElementById('ssp-lightbox-name');
var openEl = document.getElementById('ssp-lightbox-open');
var prevEl = modal.querySelector('.ssp-lightbox-prev');
var nextEl = modal.querySelector('.ssp-lightbox-next');
var nodes = [];
var index = 0;
function collect() {
return Array.prototype.slice.call(
document.querySelectorAll('[data-ssp-view], .ssp-msg-body img')
);
}
function dataFor(node) {
if (node.hasAttribute('data-ssp-view')) {
return {
url: node.getAttribute('data-ssp-view'),
name: node.getAttribute('data-ssp-view-name') || '',
type: node.getAttribute('data-ssp-view-type') || 'image'
};
}
return { url: node.getAttribute('src') || '', name: node.getAttribute('alt') || '', type: 'image' };
}
function render() {
var d = dataFor(nodes[index]);
if (d.type === 'pdf') {
imgEl.hidden = true; imgEl.src = '';
frameEl.src = d.url; frameEl.hidden = false;
} else {
frameEl.hidden = true; frameEl.src = '';
imgEl.src = d.url; imgEl.alt = d.name; imgEl.hidden = false;
}
nameEl.textContent = d.name;
openEl.href = d.url;
var multiple = nodes.length > 1;
prevEl.style.display = multiple ? '' : 'none';
nextEl.style.display = multiple ? '' : 'none';
}
function open(startNode) {
nodes = collect();
if (!nodes.length) return;
index = nodes.indexOf(startNode);
if (index < 0) index = 0;
render();
modal.removeAttribute('hidden');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
// Zusätzlich zum hidden-Attribut die Inline-Anzeige setzen: sie gewinnt
// gegen jede Stylesheet-Regel, sodass die Vorschau auch bei veraltetem
// CSS zuverlässig schließt.
function close() {
modal.setAttribute('hidden', '');
modal.style.display = 'none';
imgEl.src = '';
frameEl.src = '';
document.body.style.overflow = '';
}
close();
function step(delta) {
if (nodes.length < 2) return;
index = (index + delta + nodes.length) % nodes.length;
render();
}
document.addEventListener('click', function (e) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
var node = e.target.closest('[data-ssp-view], .ssp-msg-body img');
if (!node) return;
e.preventDefault();
open(node);
});
prevEl.addEventListener('click', function () { step(-1); });
nextEl.addEventListener('click', function () { step(1); });
modal.querySelectorAll('[data-ssp-lb-close]').forEach(function (el) {
el.addEventListener('click', close);
});
document.addEventListener('keydown', function (e) {
if (modal.hasAttribute('hidden')) return;
if (e.key === 'Escape') { close(); }
else if (e.key === 'ArrowLeft') { step(-1); }
else if (e.key === 'ArrowRight') { step(1); }
});
})();
</script>