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

@@ -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();
}