feat(helpdesk): add ticket/debtor communication feed and chat timeline UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
modules/helpdesk/web/js/helpdesk-communication.js
Normal file
128
modules/helpdesk/web/js/helpdesk-communication.js
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user