feat(helpdesk): redesign ticket detail with communication as main content and metadata aside

Swap layout: communication feed becomes the primary main content area (full width),
ticket metadata moves to aside panel with 2 stacked KPI tiles (Status, Age) and
a Stripe-style key-value definition list (Description, Company link, Contact,
Category, Support, Debtor No.). Remove activity timeline entirely — communication
feed already includes activity fallback when SOAP is unavailable. Add skeleton
bubble loading for async communication. Simplify JS from 211 to 89 lines. Remove
unused ticketLogUrl data attribute and i18n keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 16:32:53 +02:00
parent 2f0f407268
commit eed1e848df
3 changed files with 116 additions and 247 deletions

View File

@@ -40,7 +40,6 @@ if ($ticket === null && !$connectionError) {
$ticketCustomerNo = $fromCustomerNo;
$ticketDescription = (string) ($ticket['Description'] ?? $ticketNo);
$ticketLogUrl = lurl('helpdesk/ticket-log-data');
$ticketCommunicationUrl = lurl('helpdesk/ticket-communication-data');
Buffer::set('title', $ticketDescription . ' — ' . t('Helpdesk'));

View File

@@ -7,7 +7,6 @@
* @var bool $connectionError
* @var string $ticketCustomerNo
* @var string $ticketDescription
* @var string $ticketLogUrl
* @var string $ticketCommunicationUrl
*/
@@ -17,7 +16,6 @@ $ticket = is_array($ticket ?? null) ? $ticket : null;
$connectionError = $connectionError ?? false;
$ticketCustomerNo = $ticketCustomerNo ?? '';
$ticketDescription = $ticketDescription ?? '';
$ticketLogUrl = $ticketLogUrl ?? '';
$ticketCommunicationUrl = $ticketCommunicationUrl ?? '';
$backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk');
@@ -25,7 +23,6 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
?>
<div class="app-details-container"
data-ticket-no="<?php e($ticketNo); ?>"
data-ticket-log-url="<?php e($ticketLogUrl); ?>"
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
>
<section>
@@ -58,122 +55,117 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
<?php e(t('Could not connect to Business Central.')); ?>
</div>
<?php elseif ($ticket !== null): ?>
<?php
$createdRaw = (string) ($ticket['Created_On'] ?? '');
$createdFormatted = $createdRaw !== '' && $createdRaw !== '0001-01-01T00:00:00Z'
? (new DateTimeImmutable($createdRaw))->format('d.m.Y H:i')
: '—';
$lastActivityRaw = (string) ($ticket['Last_Activity_Date'] ?? '');
$lastActivityFormatted = $lastActivityRaw !== '' && $lastActivityRaw !== '0001-01-01T00:00:00Z'
? (new DateTimeImmutable($lastActivityRaw))->format('d.m.Y H:i')
: '—';
$ticketState = (string) ($ticket['Ticket_State'] ?? '');
$stateVariant = match ($ticketState) {
'Offen', 'Open' => 'primary',
'In Bearbeitung', 'In Progress' => 'warning',
'Erledigt', 'Resolved', 'Closed', 'Geschlossen' => 'success',
default => 'neutral',
};
?>
<div class="grid grid-2">
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Ticket details')); ?></div>
<table>
<tbody>
<tr>
<td><strong><?php e(t('Ticket No.')); ?></strong></td>
<td><?php e((string) ($ticket['No'] ?? '')); ?></td>
</tr>
<tr>
<td><strong><?php e(t('Description')); ?></strong></td>
<td><?php e((string) ($ticket['Description'] ?? '')); ?></td>
</tr>
<tr>
<td><strong><?php e(t('Status')); ?></strong></td>
<td><span class="badge" data-variant="<?php e($stateVariant); ?>"><?php e($ticketState); ?></span></td>
</tr>
<tr>
<td><strong><?php e(t('Category')); ?></strong></td>
<td><?php e((string) ($ticket['Category_1_Description'] ?? '')); ?></td>
</tr>
<tr>
<td><strong><?php e(t('Support')); ?></strong></td>
<td><?php e((string) ($ticket['Support_User_Name'] ?? '')); ?></td>
</tr>
</tbody>
</table>
</div>
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Contact & dates')); ?></div>
<table>
<tbody>
<tr>
<td><strong><?php e(t('Company')); ?></strong></td>
<td><?php e((string) ($ticket['Company_Contact_Name'] ?? '')); ?></td>
</tr>
<tr>
<td><strong><?php e(t('Contact')); ?></strong></td>
<td><?php e((string) ($ticket['Current_Contact_Name'] ?? '')); ?></td>
</tr>
<tr>
<td><strong><?php e(t('Created')); ?></strong></td>
<td><?php e($createdFormatted); ?></td>
</tr>
<tr>
<td><strong><?php e(t('Last activity')); ?></strong></td>
<td><?php e($lastActivityFormatted); ?></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Activity timeline + communication feed — loaded async -->
<div class="grid grid-2">
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Activity timeline')); ?></div>
<div id="ticket-timeline">
<div id="timeline-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<!-- Communication feed — main content, loaded async -->
<div class="helpdesk-ticket-detail-content">
<section>
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket communication')); ?></h3>
<div id="ticket-communication-loading">
<?php for ($i = 0; $i < 6; $i++): ?>
<div class="helpdesk-skeleton helpdesk-skeleton-aside-bubble <?php e($i % 2 === 1 ? 'helpdesk-skeleton-aside-bubble-alt' : ($i % 3 === 0 ? 'helpdesk-skeleton-aside-bubble-short' : '')); ?>"></div>
<?php endfor; ?>
</div>
</div>
<div class="app-stats-table">
<div class="app-stats-table-header"><?php e(t('Communication history')); ?></div>
<div id="ticket-communication">
<div id="ticket-communication-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="ticket-communication-content" hidden>
<div id="ticket-communication-feed"></div>
</div>
</div>
</section>
</div>
<?php endif; ?>
</section>
<?php if (!$connectionError && $ticket !== null): ?>
<?php
$createdRaw = (string) ($ticket['Created_On'] ?? '');
$createdFormatted = $createdRaw !== '' && $createdRaw !== '0001-01-01T00:00:00Z'
? (new DateTimeImmutable($createdRaw))->format('d.m.Y H:i')
: '—';
$lastActivityRaw = (string) ($ticket['Last_Activity_Date'] ?? '');
$lastActivityFormatted = $lastActivityRaw !== '' && $lastActivityRaw !== '0001-01-01T00:00:00Z'
? (new DateTimeImmutable($lastActivityRaw))->format('d.m.Y H:i')
: '—';
$ticketState = (string) ($ticket['Ticket_State'] ?? '');
// Compute ticket age
$ageLabel = '—';
if ($createdRaw !== '' && $createdRaw !== '0001-01-01T00:00:00Z') {
$createdDt = new DateTimeImmutable($createdRaw);
$diff = $createdDt->diff(new DateTimeImmutable('now'));
if ($diff->days >= 1) {
$ageLabel = $diff->days . ' ' . ($diff->days === 1 ? t('day') : t('days'));
} else {
$hours = $diff->h + ($diff->days * 24);
$ageLabel = max(1, $hours) . ' ' . t('hours');
}
}
?>
<aside id="app-details-aside-section">
<div class="app-details-aside-section helpdesk-ticket-aside">
<!-- KPI tiles: Status + Age — stacked vertically -->
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket details')); ?></h3>
<div class="helpdesk-support-metrics helpdesk-support-metrics-aside">
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Status')); ?></p>
<p class="helpdesk-support-metric-value"><?php e($ticketState); ?></p>
<p class="helpdesk-support-metric-trend"><?php e(t('Last activity')); ?>: <?php e($lastActivityFormatted); ?></p>
</article>
<article class="helpdesk-support-metric">
<p class="helpdesk-support-metric-label"><?php e(t('Age')); ?></p>
<p class="helpdesk-support-metric-value"><?php e($ageLabel); ?></p>
<p class="helpdesk-support-metric-trend"><?php e(t('Created')); ?>: <?php e($createdFormatted); ?></p>
</article>
</div>
<!-- Compact key-value details -->
<h3 class="helpdesk-support-section-title"><?php e(t('Details')); ?></h3>
<dl class="helpdesk-ticket-meta">
<div class="helpdesk-ticket-meta-item">
<dt><?php e(t('Description')); ?></dt>
<dd><?php e((string) ($ticket['Description'] ?? '—')); ?></dd>
</div>
<div class="helpdesk-ticket-meta-item">
<dt><?php e(t('Company')); ?></dt>
<dd>
<?php if ($ticketCustomerNo !== ''): ?>
<a href="<?php e(lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo))); ?>"><?php e((string) ($ticket['Company_Contact_Name'] ?? $ticketCustomerNo)); ?></a>
<?php else: ?>
<?php e((string) ($ticket['Company_Contact_Name'] ?? '—')); ?>
<?php endif; ?>
</dd>
</div>
<div class="helpdesk-ticket-meta-item">
<dt><?php e(t('Contact')); ?></dt>
<dd><?php e((string) ($ticket['Current_Contact_Name'] ?? '—')); ?></dd>
</div>
<div class="helpdesk-ticket-meta-item">
<dt><?php e(t('Category')); ?></dt>
<dd><?php e((string) ($ticket['Category_1_Description'] ?? '—')); ?></dd>
</div>
<div class="helpdesk-ticket-meta-item">
<dt><?php e(t('Support')); ?></dt>
<dd><?php e((string) ($ticket['Support_User_Name'] ?? '—')); ?></dd>
</div>
<div class="helpdesk-ticket-meta-item">
<dt><?php e(t('Debtor No.')); ?></dt>
<dd><?php e($ticketCustomerNo !== '' ? $ticketCustomerNo : '—'); ?></dd>
</div>
</dl>
</div>
</aside>
<?php endif; ?>
</div>
<!-- i18n strings for JS rendering -->
<script type="application/json" id="helpdesk-ticket-i18n"><?php gridJsonForJs([
'Message' => t('Message'),
'Status change' => t('Status change'),
'Activity' => t('Activity'),
'No activity found.' => t('No activity found.'),
'Could not load activity log.' => t('Could not load activity log.'),
'Communication history' => t('Communication history'),
'No communication found.' => t('No communication found.'),
'Could not load communication history.' => t('Could not load communication history.'),
'Live communication unavailable, showing activity fallback.' => t('Live communication unavailable, showing activity fallback.'),
'Ticket' => t('Ticket'),
'Unknown user' => t('Unknown user'),
'Unknown time' => t('Unknown time'),
'Source' => t('Source'),
'SOAP' => t('SOAP'),
'Activity fallback' => t('Activity fallback'),
'sent a message' => t('sent a message'),
'changed status' => t('changed status'),
'Forwarded' => t('Forwarded'),
'attached a file' => t('attached a file'),
'logged an activity' => t('logged an activity'),
'on' => t('on'),
'Process' => t('Process'),
'Loading...' => t('Loading...'),
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-ticket.js')); ?>"></script>

View File

@@ -1,15 +1,14 @@
/**
* Helpdesk ticket detail page — async activity timeline.
* Helpdesk ticket detail page — async communication aside.
*
* Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders
* a chronological timeline of status changes and messages.
* Loads the ticket communication feed via fetch and renders
* it in the aside panel, matching the debitor detail pattern.
*/
import {
renderCommunicationError,
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
import { renderEmptyState } from './helpdesk-empty-state.js';
const container = document.querySelector('.app-details-container[data-ticket-no]');
if (container) {
@@ -18,169 +17,48 @@ if (container) {
function init(container) {
const ticketNo = container.dataset.ticketNo || '';
const logUrl = container.dataset.ticketLogUrl || '';
const communicationUrl = container.dataset.ticketCommunicationUrl || '';
if (!ticketNo || !logUrl) return;
if (!ticketNo || !communicationUrl) return;
// i18n
const i18nEl = document.getElementById('helpdesk-ticket-i18n');
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
const t = (key) => i18n[key] || key;
// Date/time formatter
const dateFmt = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
const timeFmt = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit', minute: '2-digit',
});
function esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
function showLoading(id) {
const el = document.getElementById(id);
if (el) el.hidden = false;
}
function formatDate(raw) {
if (!raw || raw === '0001-01-01' || raw === '0001-01-01T00:00:00Z') return '';
try {
return dateFmt.format(new Date(raw));
} catch {
return raw;
}
function hideLoading(id) {
const el = document.getElementById(id);
if (el) el.hidden = true;
}
function formatTime(raw) {
if (!raw) return '';
// BC time format: "15:21:09.76" — parse as today's time
const parts = raw.split(':');
if (parts.length < 2) return raw;
return parts[0] + ':' + parts[1];
}
function typeIcon(type) {
switch (type) {
case 'Nachricht': return 'bi-chat-dots-fill';
case 'Status': return 'bi-arrow-repeat';
case 'Weiterleitung': return 'bi-forward-fill';
case 'Datei': return 'bi-paperclip';
default: return 'bi-circle-fill';
}
}
function typeVariant(type) {
switch (type) {
case 'Nachricht': return 'message';
case 'Status': return 'status';
case 'Weiterleitung': return 'forward';
case 'Datei': return 'file';
default: return 'other';
}
}
function typeLabel(type, stateSubtype) {
switch (type) {
case 'Nachricht': return t('sent a message');
case 'Status': {
if (stateSubtype) {
return t('changed status') + ' → ' + stateSubtype;
}
return t('changed status');
}
case 'Weiterleitung': return t('Forwarded');
case 'Datei': return t('attached a file');
default: return type || t('Activity');
}
}
function renderTimeline(entries) {
if (entries.length === 0) {
return renderEmptyState({
message: t('No activity found.'),
size: 'compact',
align: 'center',
icon: false,
});
}
let html = '<div class="helpdesk-timeline">';
let lastDate = '';
for (const entry of entries) {
const date = formatDate(entry.Creation_Date);
const time = formatTime(entry.Creation_Time);
const type = entry.Type || '';
const user = entry.Created_By || '';
const variant = typeVariant(type);
const icon = typeIcon(type);
const label = typeLabel(type, entry.State_Subtype || '');
// Date separator
if (date !== lastDate) {
html += `<div class="helpdesk-timeline-date">${esc(date)}</div>`;
lastDate = date;
}
html += `<div class="helpdesk-timeline-entry" data-variant="${variant}">`;
html += `<div class="helpdesk-timeline-icon"><i class="bi ${icon}" aria-hidden="true"></i></div>`;
html += '<div class="helpdesk-timeline-body">';
html += `<strong>${esc(user)}</strong> ${esc(label)}`;
if (time) {
html += `<span class="helpdesk-timeline-time">${esc(time)}</span>`;
}
html += '</div>';
html += '</div>';
}
html += '</div>';
return html;
}
function renderError(message) {
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
}
// --- Load and render ---
async function loadTimeline() {
const timelineEl = document.getElementById('ticket-timeline');
const loadingEl = document.getElementById('timeline-loading');
if (!timelineEl) return;
try {
const params = new URLSearchParams({ ticketNo });
const response = await fetch(logUrl + '?' + params.toString(), { credentials: 'same-origin' });
const data = await response.json();
if (loadingEl) loadingEl.hidden = true;
if (!data.ok) {
timelineEl.innerHTML = renderError(t('Could not load activity log.'));
return;
}
timelineEl.innerHTML = renderTimeline(data.log || []);
} catch {
if (loadingEl) loadingEl.hidden = true;
timelineEl.innerHTML = renderError(t('Could not load activity log.'));
}
function showContent(id) {
const el = document.getElementById(id);
if (el) el.hidden = false;
}
async function loadCommunication() {
const communicationEl = document.getElementById('ticket-communication');
const feedEl = document.getElementById('ticket-communication-feed');
const loadingEl = document.getElementById('ticket-communication-loading');
if (!communicationEl || !communicationUrl) return;
const contentEl = document.getElementById('ticket-communication-content');
if (!feedEl || !loadingEl || !contentEl) return;
showLoading('ticket-communication-loading');
try {
const params = new URLSearchParams({ ticketNo });
const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' });
const data = await response.json();
if (loadingEl) loadingEl.hidden = true;
hideLoading('ticket-communication-loading');
showContent('ticket-communication-content');
if (!data.ok) {
communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
return;
}
@@ -199,13 +77,13 @@ function init(container) {
showTicketNo: false,
});
communicationEl.innerHTML = html;
feedEl.innerHTML = html;
} catch {
if (loadingEl) loadingEl.hidden = true;
communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
hideLoading('ticket-communication-loading');
showContent('ticket-communication-content');
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
}
}
loadTimeline();
loadCommunication();
}