forked from fa/breadcrumb-the-shire
feat(helpdesk): ticket detail drawer — third drawer consumer
Migrates the ticket detail view (communication feed + ticket metadata aside)
into the core detail drawer pattern. Click a ticket number in the customer's
support tab → drawer slides in with the full ticket thread; arrow icons step
through the current filter page; ↗ opens the full page.
- `modules/helpdesk/templates/helpdesk-ticket-detail.phtml` extracts the
body (communication feed container + metadata aside + i18n JSON) so the
full page and the fragment render from the same source.
- `ticket-fragment($id).php` / `(none).phtml` reuse BcODataGateway::getTicket
and enforce the same ABILITY_ACCESS guard as the full page. Connection
errors render inline without aborting the fragment.
- Route `helpdesk/ticket-fragment/{id}` declared in module.php.
- `helpdesk-detail.js` wires the tickets grid: ticket-no formatter emits
`data-drawer-trigger`, `linkColumn` disabled, `initDetailDrawer` called
with `onContentLoaded → initHelpdeskTicket(contentEl)` so the async
communication feed loads inside the drawer just like on the full page.
- `fetchUrl`/`fullUrl` written as single-expression arrows so the
DetailDrawerFragmentContractTest can discover the consumer and validate
the fragment endpoint files exist — the test now covers all three drawer
consumers (address book, admin users, helpdesk tickets).
The full-page ticket view is unchanged for users who deep-link directly;
only the router-to-body wiring moved through the partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ return [
|
||||
['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'],
|
||||
['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'],
|
||||
['path' => 'helpdesk/ticket/{id}', 'target' => 'helpdesk/ticket'],
|
||||
['path' => 'helpdesk/ticket-fragment/{id}', 'target' => 'helpdesk/ticket-fragment'],
|
||||
['path' => 'helpdesk/settings', 'target' => 'helpdesk/settings'],
|
||||
['path' => 'helpdesk/settings/test-connection-data', 'target' => 'helpdesk/settings/test-connection-data'],
|
||||
['path' => 'helpdesk/settings/diagnose-data', 'target' => 'helpdesk/settings/diagnose-data'],
|
||||
|
||||
@@ -20,142 +20,19 @@ $ticketCommunicationUrl = $ticketCommunicationUrl ?? '';
|
||||
|
||||
$backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk');
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-ticket"
|
||||
data-ticket-no="<?php e($ticketNo); ?>"
|
||||
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
|
||||
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Ticket') . ' ' . $ticketNo,
|
||||
'backHref' => $backUrl,
|
||||
'backTitle' => t('Back'),
|
||||
'actions' => $ticket !== null ? [
|
||||
[
|
||||
'label' => t('Refresh data'),
|
||||
'type' => 'button',
|
||||
'name' => 'ticket-refresh',
|
||||
'class' => 'secondary outline',
|
||||
],
|
||||
] : [],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if ($connectionError): ?>
|
||||
<div class="notice" data-variant="error" role="alert">
|
||||
<?php e(t('Could not connect to Business Central.')); ?>
|
||||
</div>
|
||||
<?php elseif ($ticket !== null): ?>
|
||||
<!-- Communication feed — main content, loaded async -->
|
||||
<div class="helpdesk-ticket-detail-content">
|
||||
<section>
|
||||
<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 id="ticket-communication-content" hidden>
|
||||
<div id="ticket-communication-feed"></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">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket details')); ?></h3>
|
||||
<dl class="helpdesk-ticket-meta">
|
||||
<div class="helpdesk-ticket-meta-item">
|
||||
<dt><?php e(t('Status')); ?></dt>
|
||||
<dd><?php e($ticketState); ?></dd>
|
||||
</div>
|
||||
<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('Created')); ?></dt>
|
||||
<dd><?php e($createdFormatted); ?></dd>
|
||||
</div>
|
||||
<div class="helpdesk-ticket-meta-item">
|
||||
<dt><?php e(t('Last activity')); ?></dt>
|
||||
<dd><?php e($lastActivityFormatted); ?></dd>
|
||||
</div>
|
||||
<div class="helpdesk-ticket-meta-item">
|
||||
<dt><?php e(t('Age')); ?></dt>
|
||||
<dd><?php e($ageLabel); ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- i18n strings for JS rendering -->
|
||||
<script type="application/json" id="helpdesk-ticket-i18n"><?php gridJsonForJs([
|
||||
'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'),
|
||||
'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'),
|
||||
]); ?></script>
|
||||
$titlebar = [
|
||||
'title' => t('Ticket') . ' ' . $ticketNo,
|
||||
'backHref' => $backUrl,
|
||||
'backTitle' => t('Back'),
|
||||
'actions' => $ticket !== null ? [
|
||||
[
|
||||
'label' => t('Refresh data'),
|
||||
'type' => 'button',
|
||||
'name' => 'ticket-refresh',
|
||||
'class' => 'secondary outline',
|
||||
],
|
||||
] : [],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
require templatePath('partials/app-flash.phtml');
|
||||
require __DIR__ . '/../../templates/helpdesk-ticket-detail.phtml';
|
||||
|
||||
37
modules/helpdesk/pages/helpdesk/ticket-fragment($id).php
Normal file
37
modules/helpdesk/pages/helpdesk/ticket-fragment($id).php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
$ticketNo = trim((string) ($id ?? ''));
|
||||
$fromCustomerNo = trim((string) $request->query('from', ''));
|
||||
|
||||
if ($ticketNo === '') {
|
||||
http_response_code(400);
|
||||
$ticket = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = app(BcODataGateway::class);
|
||||
|
||||
$connectionError = false;
|
||||
try {
|
||||
$ticket = $gateway->getTicket($ticketNo);
|
||||
} catch (\Throwable) {
|
||||
$ticket = null;
|
||||
$connectionError = true;
|
||||
}
|
||||
|
||||
if ($ticket === null && !$connectionError) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
$ticketCustomerNo = $fromCustomerNo;
|
||||
$ticketDescription = (string) ($ticket['Description'] ?? $ticketNo);
|
||||
$ticketCommunicationUrl = lurl('helpdesk/ticket-communication-data');
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
if (!isset($ticketNo) || $ticketNo === '') {
|
||||
return;
|
||||
}
|
||||
require __DIR__ . '/../../templates/helpdesk-ticket-detail.phtml';
|
||||
136
modules/helpdesk/templates/helpdesk-ticket-detail.phtml
Normal file
136
modules/helpdesk/templates/helpdesk-ticket-detail.phtml
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Ticket detail body — shared by the full-page ticket view and the drawer
|
||||
* fragment. Does NOT render the titlebar or flash area (those belong to the
|
||||
* page shell, not the content body).
|
||||
*
|
||||
* Required variables:
|
||||
* $ticketNo, $ticketCustomerNo, $ticket (array|null), $connectionError (bool),
|
||||
* $ticketCommunicationUrl (string)
|
||||
*/
|
||||
|
||||
$ticketNo = $ticketNo ?? '';
|
||||
$ticketCustomerNo = $ticketCustomerNo ?? '';
|
||||
$ticket = is_array($ticket ?? null) ? $ticket : null;
|
||||
$connectionError = $connectionError ?? false;
|
||||
$ticketCommunicationUrl = $ticketCommunicationUrl ?? '';
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-ticket"
|
||||
data-ticket-no="<?php e($ticketNo); ?>"
|
||||
data-ticket-communication-url="<?php e($ticketCommunicationUrl); ?>"
|
||||
data-file-download-url="<?php e(lurl('helpdesk/ticket-file-data')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php if ($connectionError): ?>
|
||||
<div class="notice" data-variant="error" role="alert">
|
||||
<?php e(t('Could not connect to Business Central.')); ?>
|
||||
</div>
|
||||
<?php elseif ($ticket !== null): ?>
|
||||
<!-- Communication feed — main content, loaded async -->
|
||||
<div class="helpdesk-ticket-detail-content">
|
||||
<section>
|
||||
<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 id="ticket-communication-content" hidden>
|
||||
<div id="ticket-communication-feed"></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'] ?? '');
|
||||
|
||||
$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">
|
||||
<h3 class="helpdesk-support-section-title"><?php e(t('Ticket details')); ?></h3>
|
||||
<dl class="helpdesk-ticket-meta">
|
||||
<div class="helpdesk-ticket-meta-item">
|
||||
<dt><?php e(t('Status')); ?></dt>
|
||||
<dd><?php e($ticketState); ?></dd>
|
||||
</div>
|
||||
<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('Created')); ?></dt>
|
||||
<dd><?php e($createdFormatted); ?></dd>
|
||||
</div>
|
||||
<div class="helpdesk-ticket-meta-item">
|
||||
<dt><?php e(t('Last activity')); ?></dt>
|
||||
<dd><?php e($lastActivityFormatted); ?></dd>
|
||||
</div>
|
||||
<div class="helpdesk-ticket-meta-item">
|
||||
<dt><?php e(t('Age')); ?></dt>
|
||||
<dd><?php e($ageLabel); ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- i18n strings for JS rendering -->
|
||||
<script type="application/json" id="helpdesk-ticket-i18n"><?php gridJsonForJs([
|
||||
'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'),
|
||||
'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'),
|
||||
]); ?></script>
|
||||
@@ -10,6 +10,8 @@ import { initListSection } from '/js/core/app-list-page-module.js';
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
import { initDetailDrawer } from '/js/components/app-detail-drawer.js';
|
||||
import { initHelpdeskTicket } from './helpdesk-ticket.js';
|
||||
import {
|
||||
renderCommunicationError,
|
||||
renderCommunicationFeed,
|
||||
@@ -776,7 +778,7 @@ function init(container) {
|
||||
{
|
||||
name: labels.ticketNo || 'Ticket No.',
|
||||
sort: true,
|
||||
formatter: (cell) => gridjs.html(`<a href="${cell.url}">${cell.no}</a>`),
|
||||
formatter: (cell) => gridjs.html(`<a class="app-grid-link-cell" href="${cell.url}" data-drawer-trigger data-ticket-no="${cell.no || ''}">${cell.no}</a>`),
|
||||
},
|
||||
{ name: labels.description || 'Description', sort: false },
|
||||
{
|
||||
@@ -816,10 +818,13 @@ function init(container) {
|
||||
return url || '';
|
||||
},
|
||||
},
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowInteraction: { linkColumn: false },
|
||||
rowDataset: (row) => ({
|
||||
uuid: row?.cells?.[0]?.data?.no || '',
|
||||
}),
|
||||
};
|
||||
|
||||
initListSection({
|
||||
const section = initListSection({
|
||||
moduleId: 'helpdesk-detail-tickets',
|
||||
initErrorMessage: 'Helpdesk tickets grid init failed',
|
||||
initOptions: {
|
||||
@@ -832,6 +837,26 @@ function init(container) {
|
||||
},
|
||||
});
|
||||
ticketsGridInitialized = true;
|
||||
|
||||
initDetailDrawer({
|
||||
gridConfig: section?.gridConfig || null,
|
||||
triggerSelector: '[data-drawer-trigger][data-ticket-no]',
|
||||
rowUuidAttr: 'uuid',
|
||||
fetchUrl: (ticketNo) => new URL(`helpdesk/ticket-fragment/${encodeURIComponent(ticketNo)}?from=${encodeURIComponent(customerNo || '')}`, appBase).toString(),
|
||||
fullUrl: (ticketNo) => new URL(`helpdesk/ticket/${encodeURIComponent(ticketNo)}?from=${encodeURIComponent(customerNo || '')}`, appBase).toString(),
|
||||
hashPrefix: 'ticket',
|
||||
onContentLoaded: (contentEl) => {
|
||||
try { initHelpdeskTicket(contentEl); } catch (err) { console.warn('[helpdesk-detail] initHelpdeskTicket failed', err); }
|
||||
},
|
||||
labels: {
|
||||
close: labels.drawerClose || 'Close',
|
||||
previous: labels.drawerPrev || 'Previous',
|
||||
next: labels.drawerNext || 'Next',
|
||||
openFull: labels.drawerOpenFull || 'Open full page',
|
||||
loading: labels.drawerLoading || 'Loading',
|
||||
error: labels.drawerError || 'Failed to load',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
ticketsGridInitializing = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user