feat(helpdesk): add ticket/debtor communication feed and chat timeline UI

This commit is contained in:
2026-04-03 16:45:41 +02:00
parent 8de67fa882
commit e897cc2c56
18 changed files with 2372 additions and 11 deletions

View File

@@ -111,4 +111,321 @@
color: var(--app-muted, #6c757d);
font-size: 0.8125rem;
}
/* Communication feed (ticket + debtor aside) */
.helpdesk-comm-hint {
margin: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--app-muted, #6c757d);
}
.helpdesk-comm-feed {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0;
padding: 0.75rem 1rem;
list-style: none;
max-height: 26rem;
min-width: 0;
}
.helpdesk-comm-feed > li {
margin: 0;
padding: 0;
list-style: none;
}
#ticket-communication .helpdesk-comm-feed {
max-height: 34rem;
}
#app-details-aside-section .helpdesk-comm-feed {
max-height: none;
height: 100%;
min-height: 0;
padding: 0.5rem 0 0;
}
.helpdesk-chat-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-width: 100%;
min-width: 0;
}
.helpdesk-chat-row--message {
--helpdesk-tl-x: 7px;
--helpdesk-tl-dot: 10px;
position: relative;
align-self: stretch;
padding: 0.05rem 0 0.05rem 1.35rem;
}
.helpdesk-chat-row--customer {
align-self: flex-start;
}
.helpdesk-chat-row--support {
align-self: flex-start;
}
.helpdesk-chat-row--activity {
align-self: stretch;
max-width: 100%;
gap: 0.15rem;
}
.helpdesk-chat-meta-block {
display: grid;
gap: 0.18rem;
min-width: 0;
}
.helpdesk-chat-meta-block--center {
justify-items: center;
}
.helpdesk-chat-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.12rem 0.45rem;
font-size: 0.6875rem;
color: var(--app-muted, #6c757d);
min-width: 0;
}
.helpdesk-chat-meta-row > * {
min-width: 0;
}
.helpdesk-chat-meta-row > * + *::before {
content: "•";
margin-right: 0.4rem;
color: color-mix(in srgb, var(--app-muted, #6c757d) 70%, transparent);
}
.helpdesk-chat-meta-row--center {
justify-content: center;
}
.helpdesk-chat-meta-item {
line-height: 1.35;
max-width: 100%;
overflow-wrap: anywhere;
}
.helpdesk-chat-meta-link {
color: color-mix(in srgb, var(--app-primary, #1a56db) 88%, #0b2e7a);
text-decoration: none;
line-height: 1.35;
max-width: 100%;
overflow-wrap: anywhere;
}
.helpdesk-chat-meta-link:hover {
text-decoration: underline;
}
.helpdesk-chat-actor {
display: block;
font-size: 0.8125rem;
font-weight: 600;
color: var(--app-contrast, #1f2937);
overflow-wrap: anywhere;
}
.helpdesk-chat-time {
font-size: 0.75rem;
color: var(--app-muted, #6c757d);
white-space: normal;
overflow-wrap: anywhere;
}
.helpdesk-chat-meta-row--center .helpdesk-chat-time {
margin-left: 0;
}
.helpdesk-chat-bubble {
margin: 0;
border: 1px solid #d0d7de;
background: #fff;
border-radius: 0.5rem;
padding: 0.62rem 0.78rem;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.45;
box-shadow: 0 1px 0 rgb(27 31 36 / 0.04);
}
.helpdesk-chat-row--customer .helpdesk-chat-bubble {
background: var(--app-secondary);
color: var(--app-secondary-inverse);
border-color: var(--app-secondary-border);
}
.helpdesk-chat-row--support .helpdesk-chat-bubble {
background: var(--app-primary);
border-color: var(--app-primary);
color: var(--app-primary-inverse);
}
.helpdesk-chat-row--message::before {
content: "";
position: absolute;
left: var(--helpdesk-tl-x);
top: -0.45rem;
bottom: -0.45rem;
width: 1px;
background: color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent);
z-index: 0;
}
.helpdesk-chat-row--message::after {
content: "";
position: absolute;
left: calc(var(--helpdesk-tl-x) - (var(--helpdesk-tl-dot) / 2) + 0.5px);
top: 0.52rem;
width: var(--helpdesk-tl-dot);
height: var(--helpdesk-tl-dot);
border-radius: 50%;
box-sizing: border-box;
border: 2px solid
color-mix(in srgb, var(--app-border, #dee2e6) 82%, #8f9bae);
background: #fff;
z-index: 1;
}
.helpdesk-chat-row--message > * {
position: relative;
z-index: 1;
}
.helpdesk-chat-row--message .helpdesk-chat-meta-block,
.helpdesk-chat-row--message .helpdesk-chat-bubble {
margin-left: 1.35rem;
}
.helpdesk-chat-activity {
--helpdesk-tl-x: 7px;
--helpdesk-tl-dot: 10px;
position: relative;
display: block;
padding: 0.1rem 0 0.1rem 1.35rem;
margin: 0;
}
.helpdesk-chat-activity::before {
content: "";
position: absolute;
left: var(--helpdesk-tl-x);
top: -0.45rem;
bottom: -0.45rem;
width: 1px;
background: linear-gradient(
to bottom,
transparent 0%,
color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent) 12%,
color-mix(in srgb, var(--app-border, #dee2e6) 88%, transparent) 88%,
transparent 100%
);
z-index: 0;
}
.helpdesk-chat-activity::after {
content: "";
position: absolute;
left: calc(var(--helpdesk-tl-x) - (var(--helpdesk-tl-dot) / 2) + 0.5px);
top: 0.48rem;
width: var(--helpdesk-tl-dot);
height: var(--helpdesk-tl-dot);
border-radius: 50%;
box-sizing: border-box;
border: 2px solid
color-mix(in srgb, var(--app-border, #dee2e6) 86%, #8f9bae);
background: #fff;
z-index: 1;
}
.helpdesk-chat-activity-pill {
position: relative;
z-index: 1;
display: block;
border-radius: 0;
border: 0;
border-left: 0;
background: transparent;
color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
padding: 0.08rem 0 0.08rem 0;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.4;
max-width: 100%;
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
}
.helpdesk-chat-activity[data-variant="status"] .helpdesk-chat-activity-pill {
color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
}
.helpdesk-chat-activity[data-variant="forward"] .helpdesk-chat-activity-pill {
color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
}
.helpdesk-chat-activity[data-variant="file"] .helpdesk-chat-activity-pill {
color: color-mix(in srgb, var(--app-muted, #6c757d) 92%, #334155);
}
#app-details-aside-section .helpdesk-chat-row {
max-width: 100%;
min-width: 0;
}
#app-details-aside-section .helpdesk-chat-time {
margin-left: 0;
}
#app-details-aside-section .helpdesk-chat-meta-row {
gap: 0.25rem 0.4rem;
}
@media (min-width: 968px) {
.app-details-container {
overflow-x: hidden;
overflow-x: clip;
}
#app-details-aside-section {
display: flex;
min-width: 0;
}
#app-details-aside-section .helpdesk-comm-aside {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
}
#app-details-aside-section #debitor-communication-content:not([hidden]) {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
}
#app-details-aside-section #debitor-communication-feed {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
}
}

View File

@@ -0,0 +1,128 @@
function esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
}
function activityActionLabel(typeVariant, type, stateSubtype, t) {
if (typeVariant === 'status') {
const base = t('changed status');
return stateSubtype ? `${base} (${stateSubtype})` : base;
}
if (typeVariant === 'forward') {
return t('Forwarded');
}
if (typeVariant === 'file') {
return t('attached a file');
}
if (type && String(type).trim() !== '') {
return type;
}
return t('logged an activity');
}
function entryTimestamp(entry, t) {
const date = entry.date || '';
const timeRaw = entry.time || '';
const time = timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw;
if (date && time) return `${date} ${time}`;
if (entry.timestamp_iso) return entry.timestamp_iso;
if (date) return date;
return t('Unknown time');
}
function participantRole(entry) {
const explicitRole = String(entry.actor_role || '').trim().toLowerCase();
if (explicitRole === 'customer' || explicitRole === 'support') {
return explicitRole;
}
const actor = String(entry.actor || '').trim().toLowerCase();
if (actor.startsWith('kt') || actor.includes('kontakt') || actor.includes('contact')) {
return 'customer';
}
return 'support';
}
function ticketHref(baseUrl, ticketNo) {
const base = String(baseUrl || '').trim();
const no = String(ticketNo || '').trim();
if (!base || !no) return '';
return `${base}${encodeURIComponent(no)}`;
}
export function renderCommunicationError(message) {
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
}
export function renderCommunicationHint(message) {
return `<p class="helpdesk-comm-hint">${esc(message)}</p>`;
}
export function renderCommunicationFeed(entries, options = {}) {
const {
t = (k) => k,
emptyMessage = 'No communication found.',
showTicketNo = false,
ticketBaseUrl = '',
} = options;
const list = Array.isArray(entries) ? entries : [];
if (list.length === 0) {
return `<div class="app-empty-state app-empty-state-compact">
<p class="app-empty-state-message">${esc(t(emptyMessage))}</p>
</div>`;
}
let html = '<ol class="helpdesk-comm-feed" role="list">';
for (const entry of list) {
const actor = entry.actor || t('Unknown user');
const typeVariant = entry.type_variant || 'other';
const type = entry.type || '';
const stateSubtype = entry.state_subtype || '';
const message = entry.message || '';
const ticketNo = entry.ticket_no || '';
const timeLabel = entryTimestamp(entry, t);
const ticketUrl = ticketHref(ticketBaseUrl, ticketNo);
if (typeVariant === 'message') {
const role = participantRole(entry);
html += `<li class="helpdesk-chat-row helpdesk-chat-row--message helpdesk-chat-row--${esc(role)}" data-variant="${esc(typeVariant)}">`;
html += '<div class="helpdesk-chat-meta-block">';
html += `<strong class="helpdesk-chat-actor">${esc(actor)}</strong>`;
html += '<small class="helpdesk-chat-meta-row">';
if (showTicketNo && ticketNo) {
if (ticketUrl) {
html += `<a class="helpdesk-chat-meta-link" href="${esc(ticketUrl)}">${esc(t('Ticket'))} ${esc(ticketNo)}</a>`;
} else {
html += `<span class="helpdesk-chat-meta-item">${esc(t('Ticket'))} ${esc(ticketNo)}</span>`;
}
}
html += `<span class="helpdesk-chat-meta-item helpdesk-chat-time">${esc(timeLabel)}</span>`;
html += '</small>';
html += '</div>';
html += `<p class="helpdesk-chat-bubble">${esc(message)}</p>`;
html += '</li>';
continue;
}
const activityAction = activityActionLabel(typeVariant, type, stateSubtype, t);
let activityText = `${actor} ${activityAction}`.trim();
if (timeLabel) {
activityText += ` ${t('on')} ${timeLabel}`;
}
if (showTicketNo && ticketNo) {
activityText += ` · ${t('Ticket')} ${ticketNo}`;
}
html += `<li class="helpdesk-chat-row helpdesk-chat-row--activity" data-variant="${esc(typeVariant)}">`;
html += `<div class="helpdesk-chat-activity" data-variant="${esc(typeVariant)}"><span class="helpdesk-chat-activity-pill">${esc(activityText)}</span></div>`;
html += '</li>';
}
html += '</ol>';
return html;
}

View File

@@ -8,6 +8,11 @@
*/
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import {
renderCommunicationError,
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
const container = document.querySelector('.app-details-container[data-customer-no]');
if (container) {
@@ -21,6 +26,7 @@ function init(container) {
const ticketsUrl = container.dataset.ticketsUrl || '';
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
const summaryUrl = container.dataset.summaryUrl || '';
const communicationUrl = container.dataset.communicationUrl || '';
if (!customerNo || !customerName) return;
@@ -34,6 +40,7 @@ function init(container) {
let summaryPromise = null;
let overviewTicketsPromise = null;
let ticketCategoriesPromise = null;
let debitorCommunicationPromise = null;
let contactsRendered = { overview: false, full: false };
let ticketsGridInitialized = false;
let ticketsGridInitializing = false;
@@ -111,6 +118,21 @@ function init(container) {
return ticketCategoriesPromise;
}
function fetchDebitorCommunication() {
if (!debitorCommunicationPromise) {
if (!communicationUrl) {
debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' });
} else {
const params = new URLSearchParams({ customerNo, customerName });
debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' })
.then(r => r.json())
.catch(() => ({ ok: false, error: 'Network error' }));
}
}
return debitorCommunicationPromise;
}
function hydrateTicketCategoryFilter(config, categories) {
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
if (!(categoryFilter instanceof HTMLSelectElement)) return;
@@ -583,6 +605,35 @@ function init(container) {
contactsRendered.full = true;
}
async function renderDebitorCommunicationAside() {
const feedEl = document.getElementById('debitor-communication-feed');
const loadingEl = document.getElementById('debitor-communication-loading');
const contentEl = document.getElementById('debitor-communication-content');
if (!feedEl || !loadingEl || !contentEl) return;
showLoading('debitor-communication-loading');
const result = await fetchDebitorCommunication();
hideLoading('debitor-communication-loading');
showContent('debitor-communication-content');
if (!result.ok) {
feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
return;
}
let html = '';
if ((result.meta?.soap_partial_failures || 0) > 0) {
html += renderCommunicationHint(t('Some ticket communications could not be loaded completely.'));
}
html += renderCommunicationFeed(result.entries || [], {
t,
emptyMessage: 'No communication found.',
showTicketNo: true,
ticketBaseUrl,
});
feedEl.innerHTML = html;
}
// --- Tab change observer ---
// Watch for tab panel visibility changes to trigger lazy rendering
@@ -607,4 +658,5 @@ function init(container) {
// Start loading immediately — overview is the default tab
renderOverview();
renderDebitorCommunicationAside();
}

View File

@@ -4,6 +4,11 @@
* Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders
* a chronological timeline of status changes and messages.
*/
import {
renderCommunicationError,
renderCommunicationFeed,
renderCommunicationHint,
} from './helpdesk-communication.js';
const container = document.querySelector('.app-details-container[data-ticket-no]');
if (container) {
@@ -13,6 +18,7 @@ if (container) {
function init(container) {
const ticketNo = container.dataset.ticketNo || '';
const logUrl = container.dataset.ticketLogUrl || '';
const communicationUrl = container.dataset.ticketCommunicationUrl || '';
if (!ticketNo || !logUrl) return;
@@ -157,5 +163,45 @@ function init(container) {
}
}
async function loadCommunication() {
const communicationEl = document.getElementById('ticket-communication');
const loadingEl = document.getElementById('ticket-communication-loading');
if (!communicationEl || !communicationUrl) return;
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;
if (!data.ok) {
communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
return;
}
let html = '';
if (
data.meta
&& data.meta.soap_used === false
&& data.meta.fallback_used === true
&& data.meta.soap_error
) {
html += renderCommunicationHint(t('Live communication unavailable, showing activity fallback.'));
}
html += renderCommunicationFeed(data.entries || [], {
t,
emptyMessage: 'No communication found.',
showTicketNo: false,
});
communicationEl.innerHTML = html;
} catch {
if (loadingEl) loadingEl.hidden = true;
communicationEl.innerHTML = renderCommunicationError(t('Could not load communication history.'));
}
}
loadTimeline();
loadCommunication();
}