Files
breadcrumb-the-shire/modules/helpdesk/web/js/helpdesk-communication.js
fs 792f706d9e feat(helpdesk): add ticket file download and inline image preview
New SOAP method getTicketFile() on BcSoapGateway calls BC's
GetTicketFile to retrieve file attachments as Base64, decodes in
memory and returns binary data without writing to disk.

New proxy endpoint helpdesk/ticket-file-data streams files directly
to the browser with correct MIME type. Images served inline, other
files as download attachment.

TicketCommunicationService now attaches file_entry_no and file_name
to file-type events. Frontend renders download links with bi-download
icon and inline image previews for jpg/png/gif attachments.

Works in both ticket detail and debitor detail communication feeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:49:01 +02:00

157 lines
5.6 KiB
JavaScript

import { renderEmptyState } from './helpdesk-empty-state.js';
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 '—';
}
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 = '',
fileDownloadUrl = '',
} = options;
const list = Array.isArray(entries) ? entries : [];
if (list.length === 0) {
return renderEmptyState({
message: t(emptyMessage),
size: 'compact',
align: 'center',
icon: false,
});
}
let html = '<ol class="helpdesk-comm-feed" role="list">';
for (const entry of list) {
const actor = entry.actor || 'System';
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;
}
// File attachment: render download link and inline image for images
if (typeVariant === 'file' && fileDownloadUrl && entry.file_entry_no) {
const fileName = entry.file_name || 'attachment';
const fileUrl = fileDownloadUrl + '?entryNo=' + encodeURIComponent(entry.file_entry_no) + '&filename=' + encodeURIComponent(fileName);
const ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp'];
const isImage = imageExts.includes(ext);
html += `<li class="helpdesk-chat-row helpdesk-chat-row--activity" data-variant="file">`;
html += `<div class="helpdesk-chat-activity helpdesk-chat-file" data-variant="file">`;
html += `<span class="helpdesk-chat-activity-pill">${esc(actor)} ${esc(t('attached a file'))}`;
if (timeLabel) html += ` ${esc(t('on'))} ${esc(timeLabel)}`;
html += `</span>`;
if (isImage) {
html += `<a href="${esc(fileUrl)}" target="_blank" class="helpdesk-chat-file-preview"><img src="${esc(fileUrl)}" alt="${esc(fileName)}" loading="lazy"></a>`;
}
html += `<a href="${esc(fileUrl)}" target="_blank" class="helpdesk-chat-file-link"><i class="bi bi-download"></i> ${esc(fileName)}</a>`;
html += '</div>';
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;
}