2026-04-02 17:48:27 +02:00
/ * *
2026-04-04 18:34:03 +02:00
* Helpdesk debtor detail page — role - based dashboard rendering .
2026-04-02 17:48:27 +02:00
*
2026-04-04 18:34:03 +02:00
* - Support tab : health tiles , system recommendations , contracts / products widget , tickets grid
* - Sales tab : contact table ( lazy - loaded )
* - Controlling tab : placeholder
* - Aside : cross - ticket communication feed
2026-04-02 17:48:27 +02:00
* /
2026-04-20 23:01:54 +02:00
import { initListSection } from '/js/core/app-list-page-module.js' ;
2026-04-20 22:31:13 +02:00
import { getJson } from '/js/core/app-http.js' ;
import { resolveHost } from '/js/core/app-dom.js' ;
2026-04-02 17:48:27 +02:00
import { readPageConfig } from '/js/core/app-page-config.js' ;
2026-04-03 16:45:41 +02:00
import {
renderCommunicationError ,
renderCommunicationFeed ,
renderCommunicationHint ,
} from './helpdesk-communication.js' ;
2026-04-04 18:34:03 +02:00
import { renderEmptyState } from './helpdesk-empty-state.js' ;
2026-04-02 17:48:27 +02:00
2026-04-20 22:31:13 +02:00
const EMPTY _API = Object . freeze ( { destroy : ( ) => { } } ) ;
const resolveContainer = ( root ) => {
const host = resolveHost ( root ) ;
if ( host instanceof HTMLElement && host . matches ( '.app-details-container[data-app-component="helpdesk-detail"]' ) ) {
return host ;
}
return host . querySelector ( '.app-details-container[data-app-component="helpdesk-detail"]' ) ;
} ;
export function initHelpdeskDetail ( root = document ) {
const container = resolveContainer ( root ) ;
if ( ! container ) {
return EMPTY _API ;
}
return init ( container ) ;
2026-04-02 17:48:27 +02:00
}
function init ( container ) {
2026-04-20 22:31:13 +02:00
const listenerController = new AbortController ( ) ;
const requestController = new AbortController ( ) ;
const on = ( target , type , handler , options = { } ) => {
if ( ! target || typeof target . addEventListener !== 'function' ) {
return ;
}
target . addEventListener ( type , handler , { ... options , signal : listenerController . signal } ) ;
} ;
2026-04-02 17:48:27 +02:00
const customerNo = container . dataset . customerNo || '' ;
const customerName = container . dataset . customerName || '' ;
2026-04-04 18:34:03 +02:00
const supportDashboardUrl = container . dataset . supportDashboardUrl || '' ;
2026-04-02 17:48:27 +02:00
const contactsUrl = container . dataset . contactsUrl || '' ;
const ticketBaseUrl = container . dataset . ticketBaseUrl || '' ;
2026-04-03 16:45:41 +02:00
const communicationUrl = container . dataset . communicationUrl || '' ;
2026-04-05 22:49:01 +02:00
const fileDownloadUrl = container . dataset . fileDownloadUrl || '' ;
2026-04-04 18:34:03 +02:00
const salesDashboardUrl = container . dataset . salesDashboardUrl || '' ;
2026-04-13 14:13:29 +02:00
const meetingsUrl = container . dataset . meetingsUrl || '' ;
2026-04-04 18:34:03 +02:00
const refreshFromUrl = ( ( ) => {
const value = new URLSearchParams ( window . location . search ) . get ( 'refresh' ) ;
if ( ! value ) return false ;
const normalized = String ( value ) . trim ( ) . toLowerCase ( ) ;
return normalized === '1' || normalized === 'true' || normalized === 'yes' ;
} ) ( ) ;
2026-04-02 17:48:27 +02:00
2026-04-20 22:31:13 +02:00
if ( ! customerNo || ! customerName ) {
return EMPTY _API ;
}
2026-04-02 17:48:27 +02:00
const i18nEl = document . getElementById ( 'helpdesk-i18n' ) ;
const i18n = i18nEl ? JSON . parse ( i18nEl . textContent ) : { } ;
const t = ( key ) => i18n [ key ] || key ;
2026-04-04 18:34:03 +02:00
let supportDashboardPromise = null ;
let salesDashboardPromise = null ;
2026-04-13 14:13:29 +02:00
let meetingsPromise = null ;
2026-04-02 17:48:27 +02:00
let contactsPromise = null ;
let ticketCategoriesPromise = null ;
2026-04-03 16:45:41 +02:00
let debitorCommunicationPromise = null ;
2026-04-04 18:34:03 +02:00
let supportTabRendered = false ;
let salesTabRendered = false ;
2026-04-02 17:48:27 +02:00
let ticketsGridInitialized = false ;
let ticketsGridInitializing = false ;
2026-04-04 18:34:03 +02:00
function buildBaseParams ( ) {
const params = new URLSearchParams ( { customerNo , customerName } ) ;
if ( refreshFromUrl ) {
params . set ( 'refresh' , '1' ) ;
}
return params ;
}
function fetchSupportDashboard ( ) {
if ( ! supportDashboardPromise ) {
if ( ! supportDashboardUrl ) {
supportDashboardPromise = Promise . resolve ( { ok : false , error : 'No support dashboard endpoint configured' } ) ;
} else {
const params = buildBaseParams ( ) ;
2026-04-20 22:31:13 +02:00
supportDashboardPromise = getJson ( ` ${ supportDashboardUrl } ? ${ params . toString ( ) } ` , {
signal : requestController . signal ,
} )
2026-04-04 18:34:03 +02:00
. catch ( ( ) => ( { ok : false , error : 'Network error' } ) ) ;
}
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
return supportDashboardPromise ;
}
function fetchSalesDashboard ( ) {
if ( ! salesDashboardPromise ) {
if ( ! salesDashboardUrl ) {
salesDashboardPromise = Promise . resolve ( { ok : false , error : 'No sales dashboard endpoint configured' } ) ;
} else {
const params = buildBaseParams ( ) ;
2026-04-20 22:31:13 +02:00
salesDashboardPromise = getJson ( ` ${ salesDashboardUrl } ? ${ params . toString ( ) } ` , {
signal : requestController . signal ,
} )
2026-04-04 18:34:03 +02:00
. catch ( ( ) => ( { ok : false , error : 'Network error' } ) ) ;
}
}
return salesDashboardPromise ;
}
2026-04-02 17:48:27 +02:00
2026-04-13 14:13:29 +02:00
function fetchMeetings ( ) {
if ( ! meetingsPromise ) {
if ( ! meetingsUrl ) {
meetingsPromise = Promise . resolve ( { ok : false , error : 'No meetings endpoint configured' } ) ;
} else {
const params = new URLSearchParams ( { customerNo } ) ;
if ( refreshFromUrl ) params . set ( 'refresh' , '1' ) ;
2026-04-20 22:31:13 +02:00
meetingsPromise = getJson ( ` ${ meetingsUrl } ? ${ params . toString ( ) } ` , {
signal : requestController . signal ,
} )
2026-04-13 14:13:29 +02:00
. catch ( ( ) => ( { ok : false , error : 'Network error' } ) ) ;
}
}
return meetingsPromise ;
}
2026-04-02 17:48:27 +02:00
function fetchContacts ( ) {
if ( ! contactsPromise ) {
2026-04-04 18:34:03 +02:00
const params = buildBaseParams ( ) ;
2026-04-20 22:31:13 +02:00
contactsPromise = getJson ( ` ${ contactsUrl } ? ${ params . toString ( ) } ` , {
signal : requestController . signal ,
} )
2026-04-02 17:48:27 +02:00
. catch ( ( ) => ( { ok : false , error : 'Network error' } ) ) ;
}
2026-04-04 18:34:03 +02:00
return contactsPromise ;
2026-04-02 17:48:27 +02:00
}
function fetchTicketCategories ( config , appBase ) {
if ( ! ticketCategoriesPromise ) {
const categoryOptionsUrl = config . categoryOptionsUrl || '' ;
if ( ! categoryOptionsUrl ) {
ticketCategoriesPromise = Promise . resolve ( { ok : false , categories : [ ] } ) ;
return ticketCategoriesPromise ;
}
const url = new URL ( categoryOptionsUrl , appBase ) ;
url . searchParams . set ( 'customerNo' , config . customerNo || customerNo ) ;
url . searchParams . set ( 'customerName' , config . customerName || customerName ) ;
2026-04-04 18:34:03 +02:00
if ( refreshFromUrl ) {
url . searchParams . set ( 'refresh' , '1' ) ;
}
2026-04-02 17:48:27 +02:00
2026-04-20 22:31:13 +02:00
ticketCategoriesPromise = getJson ( url . toString ( ) , {
signal : requestController . signal ,
} )
2026-04-02 17:48:27 +02:00
. catch ( ( ) => ( { ok : false , categories : [ ] } ) ) ;
}
return ticketCategoriesPromise ;
}
2026-04-03 16:45:41 +02:00
function fetchDebitorCommunication ( ) {
if ( ! debitorCommunicationPromise ) {
if ( ! communicationUrl ) {
debitorCommunicationPromise = Promise . resolve ( { ok : false , error : 'No communication endpoint configured' } ) ;
} else {
2026-04-04 18:34:03 +02:00
const params = buildBaseParams ( ) ;
2026-04-20 22:31:13 +02:00
debitorCommunicationPromise = getJson ( ` ${ communicationUrl } ? ${ params . toString ( ) } ` , {
signal : requestController . signal ,
} )
2026-04-03 16:45:41 +02:00
. catch ( ( ) => ( { ok : false , error : 'Network error' } ) ) ;
}
}
return debitorCommunicationPromise ;
}
2026-04-05 16:32:34 +02:00
/ * *
* Hydrate a dynamic select filter with values from the API response .
* Works for category , support user , and contact filters .
* /
function hydrateSelectFilter ( config , selectorId , filterKey , values ) {
const selectEl = document . querySelector ( selectorId ) ;
if ( ! ( selectEl instanceof HTMLSelectElement ) ) return ;
const uniqueValues = Array . from ( new Set (
( Array . isArray ( values ) ? values : [ ] )
2026-04-02 17:48:27 +02:00
. map ( item => String ( item || '' ) . trim ( ) )
. filter ( Boolean ) ,
) ) . sort ( ( a , b ) => a . localeCompare ( b , undefined , { numeric : true , sensitivity : 'base' } ) ) ;
2026-04-05 16:32:34 +02:00
const currentValue = String ( selectEl . value || '' ) ;
selectEl . innerHTML = '' ;
2026-04-02 17:48:27 +02:00
const allOption = document . createElement ( 'option' ) ;
allOption . value = '' ;
allOption . textContent = t ( 'All' ) ;
2026-04-05 16:32:34 +02:00
selectEl . appendChild ( allOption ) ;
2026-04-02 17:48:27 +02:00
2026-04-05 16:32:34 +02:00
const optionMap = { } ;
for ( const value of uniqueValues ) {
2026-04-02 17:48:27 +02:00
const option = document . createElement ( 'option' ) ;
2026-04-05 16:32:34 +02:00
option . value = value ;
option . textContent = value ;
selectEl . appendChild ( option ) ;
optionMap [ value ] = value ;
2026-04-02 17:48:27 +02:00
}
2026-04-05 16:32:34 +02:00
if ( currentValue !== '' && Object . prototype . hasOwnProperty . call ( optionMap , currentValue ) ) {
selectEl . value = currentValue ;
2026-04-02 17:48:27 +02:00
} else {
2026-04-05 16:32:34 +02:00
selectEl . value = '' ;
2026-04-02 17:48:27 +02:00
}
if ( ! config . filterChipMeta || typeof config . filterChipMeta !== 'object' ) {
config . filterChipMeta = { } ;
}
2026-04-05 16:32:34 +02:00
const currentMeta = ( config . filterChipMeta [ filterKey ] && typeof config . filterChipMeta [ filterKey ] === 'object' )
? config . filterChipMeta [ filterKey ]
2026-04-02 17:48:27 +02:00
: { } ;
2026-04-05 16:32:34 +02:00
config . filterChipMeta [ filterKey ] = {
... currentMeta ,
2026-04-02 17:48:27 +02:00
type : 'select' ,
default : '' ,
2026-04-05 16:32:34 +02:00
options : optionMap ,
2026-04-02 17:48:27 +02:00
} ;
}
function esc ( str ) {
const d = document . createElement ( 'div' ) ;
d . textContent = str ;
return d . innerHTML ;
}
2026-04-04 18:34:03 +02:00
function ticketUrl ( ticketNo ) {
return ticketBaseUrl + encodeURIComponent ( ticketNo ) + '?from=' + encodeURIComponent ( customerNo ) ;
}
function errorNotice ( message ) {
return ` <div class="notice" data-variant="warning" role="alert"><p> ${ esc ( message ) } </p></div> ` ;
}
let contactsGridInitialized = false ;
let contactsGridInitializing = false ;
async function initContactsGrid ( ) {
if ( contactsGridInitialized || contactsGridInitializing ) return ;
contactsGridInitializing = true ;
const contactsConfig = readPageConfig ( 'helpdesk-contacts' ) ;
if ( ! contactsConfig ) {
contactsGridInitializing = false ;
return ;
}
const labels = contactsConfig . labels || { } ;
const gridjsLib = window . gridjs ;
if ( ! gridjsLib ) {
contactsGridInitializing = false ;
return ;
}
const baseEl = document . querySelector ( 'base' ) ;
const appBase = ( baseEl && baseEl . href ) ? baseEl . href : '/' ;
const gridDataUrl = new URL ( contactsConfig . dataUrl || '' , appBase ) ;
gridDataUrl . searchParams . set ( 'customerNo' , contactsConfig . customerNo || customerNo ) ;
gridDataUrl . searchParams . set ( 'customerName' , contactsConfig . customerName || customerName ) ;
if ( refreshFromUrl ) {
gridDataUrl . searchParams . set ( 'refresh' , '1' ) ;
}
2026-04-02 17:48:27 +02:00
try {
2026-04-04 18:34:03 +02:00
// Fetch type options from first data response
const typeOptionsUrl = new URL ( gridDataUrl . toString ( ) ) ;
typeOptionsUrl . searchParams . set ( 'limit' , '1' ) ;
2026-04-20 22:31:13 +02:00
const typeResp = await getJson ( typeOptionsUrl . toString ( ) , {
signal : requestController . signal ,
} ) . catch ( ( ) => null ) ;
2026-04-04 18:34:03 +02:00
const typeOptions = Array . isArray ( typeResp ? . type _options ) ? typeResp . type _options : [ ] ;
hydrateContactTypeFilter ( contactsConfig , typeOptions ) ;
const gridOptions = {
gridjs : gridjsLib ,
container : '#helpdesk-contacts-grid' ,
dataUrl : gridDataUrl . toString ( ) ,
appBase ,
columns : [
{ name : labels . name || 'Name' , sort : true } ,
{ name : labels . type || 'Type' , sort : true } ,
{ name : labels . jobTitle || 'Job title' , sort : true } ,
{ name : labels . email || 'E-Mail' , sort : true } ,
{ name : labels . phone || 'Phone' , sort : true } ,
{ name : labels . mobile || 'Mobile' , sort : false } ,
] ,
sortColumns : [ 'Name' , 'Type' , 'Job_Title' , 'E_Mail' , 'Phone_No' , null ] ,
paginationLimit : 10 ,
language : contactsConfig . gridLang || { } ,
mapData : ( data ) => ( data . data || [ ] ) . map ( ( row ) => [
row . Name || '' ,
row . Type || '' ,
row . Job _Title || '' ,
row . E _Mail || '' ,
row . Phone _No || '' ,
row . Mobile _Phone _No || '' ,
] ) ,
search : contactsConfig . gridSearch || null ,
filterSchema : contactsConfig . filterSchema || [ ] ,
urlSync : false ,
actions : { enabled : false } ,
} ;
// Scope filter UI to the contacts section to avoid collision with tickets filter
const contactsSection = document . getElementById ( 'sales-contacts-section' ) ;
const contactsChipsEl = contactsSection
? contactsSection . querySelector ( '[data-active-filter-chips]' )
: null ;
const contactsLiveEl = contactsSection
? contactsSection . querySelector ( '[data-filter-live-region]' )
: null ;
2026-04-20 23:01:54 +02:00
initListSection ( {
moduleId : 'helpdesk-detail-contacts' ,
initErrorMessage : 'Helpdesk contacts grid init failed' ,
initOptions : {
2026-04-04 18:34:03 +02:00
grid : gridOptions ,
filters : {
mode : 'drawer' ,
chipMeta : contactsConfig . filterChipMeta || { } ,
watchInputs : [ '#helpdesk-contact-search' ] ,
drawerRoot : contactsSection || undefined ,
chips : contactsChipsEl ? { container : contactsChipsEl } : { } ,
live : contactsLiveEl ? { regionSelector : null } : { } ,
} ,
2026-04-20 23:01:54 +02:00
} ,
2026-04-04 18:34:03 +02:00
} ) ;
contactsGridInitialized = true ;
} finally {
contactsGridInitializing = false ;
2026-04-02 17:48:27 +02:00
}
}
2026-04-04 18:34:03 +02:00
function hydrateContactTypeFilter ( contactsConfig , typeOptions ) {
const select = document . getElementById ( 'helpdesk-contact-type-filter' ) ;
if ( ! ( select instanceof HTMLSelectElement ) || ! Array . isArray ( typeOptions ) ) return ;
const currentValue = String ( select . value || '' ) ;
select . innerHTML = '' ;
const allOption = document . createElement ( 'option' ) ;
allOption . value = '' ;
allOption . textContent = t ( 'All' ) ;
select . appendChild ( allOption ) ;
const typeOptionMap = { } ;
for ( const opt of typeOptions ) {
const id = String ( opt . id || '' ) . trim ( ) ;
const label = String ( opt . description || opt . id || '' ) . trim ( ) ;
if ( id === '' ) continue ;
const option = document . createElement ( 'option' ) ;
option . value = id ;
option . textContent = label ;
select . appendChild ( option ) ;
typeOptionMap [ id ] = label ;
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
if ( currentValue !== '' && Object . prototype . hasOwnProperty . call ( typeOptionMap , currentValue ) ) {
select . value = currentValue ;
} else {
select . value = '' ;
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
if ( ! contactsConfig . filterChipMeta || typeof contactsConfig . filterChipMeta !== 'object' ) {
contactsConfig . filterChipMeta = { } ;
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
const currentTypeMeta = ( contactsConfig . filterChipMeta . type && typeof contactsConfig . filterChipMeta . type === 'object' )
? contactsConfig . filterChipMeta . type
: { } ;
contactsConfig . filterChipMeta . type = {
... currentTypeMeta ,
type : 'select' ,
default : '' ,
options : typeOptionMap ,
} ;
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
function formatTemplate ( template , params ) {
const tpl = String ( template || '' ) ;
const map = ( params && typeof params === 'object' ) ? params : { } ;
return tpl . replace ( /\{([a-zA-Z0-9_]+)\}/g , ( _ , key ) => {
const value = map [ key ] ;
if ( value === null || value === undefined ) {
return '' ;
}
return String ( value ) ;
} ) ;
2026-04-02 17:48:27 +02:00
}
2026-04-05 16:32:34 +02:00
const severityMap = {
escalation _overdue : { cls : 'critical' , icon : '🔴' } ,
high _risk _aging : { cls : 'high' , icon : '🟠' } ,
unassigned _open : { cls : 'medium' , icon : '🟡' } ,
stale _open : { cls : 'low' , icon : '🔵' } ,
customer _backlog : { cls : 'low' , icon : '🔵' } ,
} ;
2026-04-04 18:34:03 +02:00
function renderSystemRecommendations ( recommendations ) {
const entries = Array . isArray ( recommendations ) ? recommendations : [ ] ;
if ( entries . length === 0 ) {
return renderEmptyState ( {
message : t ( 'No system recommendations right now.' ) ,
size : 'compact' ,
align : 'center' ,
icon : false ,
} ) ;
}
const formatRecommendationMessage = ( entry ) => {
const messageKey = String ( entry . message _key || '' ) ;
const template = t ( messageKey ) ;
const params = ( entry . message _params && typeof entry . message _params === 'object' )
? entry . message _params
: { } ;
if ( template && template !== messageKey ) {
return formatTemplate ( template , params ) ;
}
return messageKey || String ( entry . rule _id || '—' ) ;
} ;
2026-04-02 17:48:27 +02:00
2026-04-05 16:32:34 +02:00
let html = '<div class="helpdesk-rec-list">' ;
2026-04-04 18:34:03 +02:00
for ( const entry of entries ) {
const ticketNo = String ( entry . ticket _no || '' ) ;
const href = ticketNo ? ticketUrl ( ticketNo ) : '' ;
2026-04-05 16:32:34 +02:00
const ruleId = String ( entry . rule _id || '' ) ;
const severity = severityMap [ ruleId ] || { cls : 'low' , icon : '🔵' } ;
const ageHours = Number . isFinite ( Number ( entry . age _hours ) ) ? Number ( entry . age _hours ) : null ;
const ageLabel = ageHours !== null ? fmtHours ( ageHours ) : '' ;
const message = formatRecommendationMessage ( entry ) ;
const tag = href ? 'a' : 'div' ;
const hrefAttr = href ? ` href=" ${ esc ( href ) } " ` : '' ;
html += ` < ${ tag } ${ hrefAttr } class="helpdesk-rec-card helpdesk-rec- ${ severity . cls } " tabindex="0"> ` ;
html += ` <div class="helpdesk-rec-card-header"> ` ;
html += ` <span class="helpdesk-rec-card-ticket"> ${ esc ( ticketNo || '—' ) } </span> ` ;
if ( ageLabel ) html += ` <span class="helpdesk-rec-card-age"> ${ esc ( ageLabel ) } </span> ` ;
html += ` </div> ` ;
html += ` <div class="helpdesk-rec-card-message"> ${ esc ( message ) } </div> ` ;
html += ` </ ${ tag } > ` ;
2026-04-02 17:48:27 +02:00
}
2026-04-05 16:32:34 +02:00
html += '</div>' ;
2026-04-02 17:48:27 +02:00
return html ;
}
2026-04-04 18:34:03 +02:00
2026-04-05 16:32:34 +02:00
function contractTypeLabel ( productType ) {
return String ( productType || '' ) . trim ( ) || '—' ;
2026-04-04 18:34:03 +02:00
}
2026-04-05 16:32:34 +02:00
// Shared formatter for contract rendering (dialog, timeline)
2026-04-04 18:34:03 +02:00
const contractAmountFmt = new Intl . NumberFormat ( undefined , {
style : 'currency' ,
currency : 'EUR' ,
maximumFractionDigits : 2 ,
} ) ;
function contractFormatDate ( value ) {
const raw = String ( value || '' ) . trim ( ) ;
if ( ! raw || raw . startsWith ( '0001-01-01' ) ) return '—' ;
const date = new Date ( raw ) ;
if ( Number . isNaN ( date . getTime ( ) ) ) return raw ;
return date . toLocaleDateString ( ) ;
}
function contractStateVariant ( state ) {
const s = String ( state || '' ) . toLowerCase ( ) ;
if ( s === 'aktiv' ) return 'success' ;
if ( s === 'vorbereitung' ) return 'warning' ;
return 'neutral' ;
}
/** Store all contracts for dialog access */
let salesContracts = [ ] ;
function renderContractLinesTable ( lines ) {
if ( ! Array . isArray ( lines ) || lines . length === 0 ) {
return ` <p class="muted"> ${ esc ( t ( 'No contract details available.' ) ) } </p> ` ;
}
2026-04-02 17:48:27 +02:00
let html = '<table><thead><tr>' ;
2026-04-04 18:34:03 +02:00
html += ` <th> ${ esc ( t ( 'Description' ) ) } </th> ` ;
html += ` <th> ${ esc ( t ( 'Type' ) ) } </th> ` ;
html += ` <th> ${ esc ( t ( 'Quantity' ) ) } </th> ` ;
html += ` <th> ${ esc ( t ( 'Unit price' ) ) } </th> ` ;
html += ` <th> ${ esc ( t ( 'Line amount' ) ) } </th> ` ;
html += ` <th> ${ esc ( t ( 'Line state' ) ) } </th> ` ;
2026-04-02 17:48:27 +02:00
html += '</tr></thead><tbody>' ;
2026-04-04 18:34:03 +02:00
for ( const line of lines ) {
const lineVariant = String ( line . line _state || '' ) . toLowerCase ( ) === 'aktiv' ? 'success' : 'neutral' ;
2026-04-02 17:48:27 +02:00
html += '<tr>' ;
2026-04-04 18:34:03 +02:00
html += ` <td> ${ esc ( line . description || '—' ) } </td> ` ;
html += ` <td> ${ line . type ? ` <span class="badge"> ${ esc ( line . type ) } </span> ` : '—' } </td> ` ;
html += ` <td> ${ esc ( String ( line . quantity ? ? 0 ) ) } </td> ` ;
html += ` <td> ${ esc ( contractAmountFmt . format ( Number ( line . unit _price ? ? 0 ) ) ) } </td> ` ;
html += ` <td> ${ esc ( contractAmountFmt . format ( Number ( line . line _amount ? ? 0 ) ) ) } </td> ` ;
html += ` <td><span class="badge" data-variant=" ${ lineVariant } "> ${ esc ( line . line _state || '—' ) } </span></td> ` ;
2026-04-02 17:48:27 +02:00
html += '</tr>' ;
}
html += '</tbody></table>' ;
return html ;
}
2026-04-04 18:34:03 +02:00
function openContractDialog ( contract ) {
// Remove existing dialog if present
const existing = document . getElementById ( 'helpdesk-contract-dialog' ) ;
if ( existing ) existing . remove ( ) ;
const lines = Array . isArray ( contract . lines ) ? contract . lines : [ ] ;
const variant = contractStateVariant ( contract . state ) ;
const typeLabel = contractTypeLabel ( contract . product _type ) ;
let dialogHtml = '<dialog id="helpdesk-contract-dialog">' ;
dialogHtml += '<article class="app-dialog-lg helpdesk-contract-dialog-article">' ;
// Header — follows core pattern: <h2> then <button class="close">
dialogHtml += '<header>' ;
dialogHtml += ` <h2> ${ esc ( contract . description || typeLabel ) } <small> ${ esc ( contract . contract _no || '' ) } </small></h2> ` ;
dialogHtml += '<button type="button" class="close" data-dialog-close aria-label="Close"></button>' ;
dialogHtml += '</header>' ;
// Contract meta
dialogHtml += '<div class="helpdesk-contract-dialog-meta">' ;
const metaItems = [ ] ;
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Status' ) ) } </strong><span class="badge" data-variant=" ${ variant } "> ${ esc ( contract . state || '—' ) } </span></span> ` ) ;
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Type' ) ) } </strong> ${ esc ( typeLabel ) } </span> ` ) ;
if ( contract . starting _date ) {
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Starting date' ) ) } </strong> ${ esc ( contractFormatDate ( contract . starting _date ) ) } </span> ` ) ;
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
if ( contract . invoicing _period ) {
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Invoicing period' ) ) } </strong> ${ esc ( contract . invoicing _period ) } </span> ` ) ;
}
if ( contract . next _invoicing _date ) {
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Next invoicing' ) ) } </strong> ${ esc ( contractFormatDate ( contract . next _invoicing _date ) ) } </span> ` ) ;
}
if ( Number ( contract . monthly _amount ? ? 0 ) > 0 ) {
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Monthly volume' ) ) } </strong> ${ esc ( contractAmountFmt . format ( contract . monthly _amount ) ) } </span> ` ) ;
}
if ( contract . salesperson _code ) {
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Salesperson' ) ) } </strong> ${ esc ( contract . salesperson _code ) } </span> ` ) ;
}
if ( Number ( contract . support _hours ? ? 0 ) > 0 ) {
metaItems . push ( ` <span><strong> ${ esc ( t ( 'Support hours' ) ) } </strong> ${ esc ( String ( contract . support _hours ) ) } </span> ` ) ;
}
dialogHtml += metaItems . join ( '' ) ;
dialogHtml += '</div>' ;
// Lines table
if ( lines . length > 0 ) {
dialogHtml += ` <h3 class="helpdesk-contract-dialog-section-title"> ${ esc ( t ( 'positions' ) ) } ( ${ lines . length } )</h3> ` ;
dialogHtml += '<div class="helpdesk-contract-dialog-lines">' ;
dialogHtml += renderContractLinesTable ( lines ) ;
dialogHtml += '</div>' ;
}
// Footer
dialogHtml += '<footer>' ;
dialogHtml += ` <button type="button" class="secondary outline" data-dialog-close> ${ esc ( t ( 'Close' ) ) } </button> ` ;
dialogHtml += '</footer>' ;
dialogHtml += '</article></dialog>' ;
document . body . insertAdjacentHTML ( 'beforeend' , dialogHtml ) ;
const dialog = document . getElementById ( 'helpdesk-contract-dialog' ) ;
2026-04-20 22:31:13 +02:00
on ( dialog , 'click' , ( e ) => {
2026-04-04 18:34:03 +02:00
if ( e . target . closest ( '[data-dialog-close]' ) ) {
dialog . close ( ) ;
dialog . remove ( ) ;
return ;
}
// Close on backdrop click
if ( e . target === dialog ) {
dialog . close ( ) ;
dialog . remove ( ) ;
}
} ) ;
2026-04-20 22:31:13 +02:00
on ( dialog , 'close' , ( ) => { dialog . remove ( ) ; } ) ;
2026-04-04 18:34:03 +02:00
dialog . showModal ( ) ;
}
2026-04-05 16:32:34 +02:00
// Delegate click on contract cards and timeline rows
2026-04-20 22:31:13 +02:00
on ( container , 'click' , ( e ) => {
2026-04-05 16:32:34 +02:00
const target = e . target . closest ( '.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]' ) ;
if ( ! target ) return ;
const index = Number ( target . dataset . contractIndex ) ;
2026-04-04 18:34:03 +02:00
if ( salesContracts [ index ] ) {
openContractDialog ( salesContracts [ index ] ) ;
}
} ) ;
2026-04-05 16:32:34 +02:00
// KPI tile click → scroll to ticket list and apply filter or sort
2026-04-20 22:31:13 +02:00
on ( container , 'click' , ( e ) => {
2026-04-05 16:32:34 +02:00
const kpi = e . target . closest ( '.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]' ) ;
2026-04-04 18:34:03 +02:00
if ( ! kpi ) return ;
2026-04-05 16:32:34 +02:00
// Apply status filter if present
if ( kpi . dataset . kpiFilter !== undefined ) {
const filterValue = kpi . dataset . kpiFilter || '' ;
const statusSelect = document . getElementById ( 'helpdesk-ticket-status-filter' ) ;
if ( statusSelect instanceof HTMLSelectElement ) {
statusSelect . value = filterValue ;
statusSelect . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
2026-04-04 18:34:03 +02:00
}
2026-04-05 16:32:34 +02:00
}
2026-04-04 18:34:03 +02:00
2026-04-05 16:32:34 +02:00
// Apply sort if present (format: "column:dir")
if ( kpi . dataset . kpiSort ) {
const [ col , dir ] = kpi . dataset . kpiSort . split ( ':' ) ;
const url = new URL ( window . location . href ) ;
url . searchParams . set ( 'order' , col ) ;
url . searchParams . set ( 'dir' , dir || 'asc' ) ;
// Also set open filter for "avg age" to show only open tickets
const statusSelect = document . getElementById ( 'helpdesk-ticket-status-filter' ) ;
if ( statusSelect instanceof HTMLSelectElement ) {
statusSelect . value = 'open' ;
statusSelect . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
2026-04-04 18:34:03 +02:00
}
2026-04-05 16:32:34 +02:00
window . history . replaceState ( null , '' , url . toString ( ) ) ;
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
2026-04-05 16:32:34 +02:00
const ticketSection = document . querySelector ( '.helpdesk-support-ticket-list' ) ;
if ( ticketSection ) {
ticketSection . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
2026-04-02 17:48:27 +02:00
}
2026-04-05 16:32:34 +02:00
} ) ;
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
function setText ( id , value ) {
const el = document . getElementById ( id ) ;
if ( el ) {
el . textContent = String ( value ) ;
}
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
function setHtml ( id , html ) {
const el = document . getElementById ( id ) ;
if ( el ) {
el . innerHTML = html ;
}
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
function fmtHours ( h ) {
const hours = Math . round ( h ) ;
if ( hours < 24 ) return hours + 'h' ;
const days = Math . floor ( hours / 24 ) ;
const rest = hours % 24 ;
return rest > 0 ? days + 'd ' + rest + 'h' : days + 'd' ;
}
2026-04-02 17:48:27 +02:00
function showLoading ( id ) {
const el = document . getElementById ( id ) ;
2026-04-02 21:05:54 +02:00
if ( el ) {
el . hidden = false ;
}
2026-04-02 17:48:27 +02:00
}
function hideLoading ( id ) {
const el = document . getElementById ( id ) ;
2026-04-02 21:05:54 +02:00
if ( el ) {
el . hidden = true ;
}
2026-04-02 17:48:27 +02:00
}
function showContent ( id ) {
const el = document . getElementById ( id ) ;
2026-04-04 18:34:03 +02:00
if ( el ) {
el . hidden = false ;
}
2026-04-02 17:48:27 +02:00
}
2026-04-20 22:31:13 +02:00
on ( container , 'click' , ( e ) => {
2026-04-02 21:05:54 +02:00
if ( e . target . closest ( 'a' ) ) return ;
const row = e . target . closest ( '.app-clickable-row[data-href]' ) ;
if ( row ) {
const href = row . dataset . href ;
2026-04-04 18:34:03 +02:00
if ( href ) {
window . location . href = href ;
}
2026-04-02 21:05:54 +02:00
}
} ) ;
2026-04-20 22:31:13 +02:00
on ( container , 'keydown' , ( e ) => {
2026-04-02 21:05:54 +02:00
if ( e . key !== 'Enter' && e . key !== ' ' ) return ;
const row = e . target . closest ( '.app-clickable-row[data-href]' ) ;
if ( row ) {
e . preventDefault ( ) ;
const href = row . dataset . href ;
2026-04-04 18:34:03 +02:00
if ( href ) {
window . location . href = href ;
}
2026-04-05 16:32:34 +02:00
return ;
}
// Timeline row keyboard activation
const tlRow = e . target . closest ( '.helpdesk-tl-row-clickable[data-contract-index]' ) ;
if ( tlRow ) {
e . preventDefault ( ) ;
const index = Number ( tlRow . dataset . contractIndex ) ;
if ( salesContracts [ index ] ) {
openContractDialog ( salesContracts [ index ] ) ;
}
2026-04-02 21:05:54 +02:00
}
2026-04-02 17:48:27 +02:00
} ) ;
async function initTicketsGrid ( ) {
if ( ticketsGridInitialized || ticketsGridInitializing ) return ;
ticketsGridInitializing = true ;
const config = readPageConfig ( 'helpdesk-tickets' ) ;
if ( ! config ) {
ticketsGridInitializing = false ;
return ;
}
const labels = config . labels || { } ;
const gridjs = window . gridjs ;
if ( ! gridjs ) {
ticketsGridInitializing = false ;
return ;
}
const baseEl = document . querySelector ( 'base' ) ;
const appBase = ( baseEl && baseEl . href ) ? baseEl . href : '/' ;
const gridDataUrl = new URL ( config . dataUrl || '' , appBase ) ;
gridDataUrl . searchParams . set ( 'customerNo' , config . customerNo || customerNo ) ;
gridDataUrl . searchParams . set ( 'customerName' , config . customerName || customerName ) ;
2026-04-04 18:34:03 +02:00
if ( refreshFromUrl ) {
gridDataUrl . searchParams . set ( 'refresh' , '1' ) ;
}
2026-04-02 17:48:27 +02:00
try {
2026-04-05 16:32:34 +02:00
const filterOptionsResponse = await fetchTicketCategories ( config , appBase ) ;
hydrateSelectFilter ( config , '#helpdesk-ticket-category-filter' , 'category' , filterOptionsResponse ? . categories || [ ] ) ;
hydrateSelectFilter ( config , '#helpdesk-ticket-support-filter' , 'support' , filterOptionsResponse ? . support _users || [ ] ) ;
hydrateSelectFilter ( config , '#helpdesk-ticket-contact-filter' , 'contact' , filterOptionsResponse ? . contacts || [ ] ) ;
2026-04-02 17:48:27 +02:00
const gridOptions = {
gridjs ,
container : '#helpdesk-tickets-grid' ,
dataUrl : gridDataUrl . toString ( ) ,
appBase ,
columns : [
{
name : labels . ticketNo || 'Ticket No.' ,
sort : true ,
formatter : ( cell ) => gridjs . html ( ` <a href=" ${ cell . url } "> ${ cell . no } </a> ` ) ,
} ,
{ name : labels . description || 'Description' , sort : false } ,
{
name : labels . status || 'Status' ,
sort : true ,
formatter : ( cell ) => {
if ( ! cell || typeof cell !== 'object' ) return gridjs . html ( '-' ) ;
return gridjs . html ( ` <span class="badge" data-variant=" ${ cell . variant || 'neutral' } "> ${ cell . label || '-' } </span> ` ) ;
} ,
} ,
{ name : labels . category || 'Category' , sort : false } ,
{ name : labels . support || 'Support' , sort : false } ,
{ name : labels . created || 'Created' , sort : true } ,
{ name : labels . lastActivity || 'Last activity' , sort : true } ,
{ name : 'ticket_url' , hidden : true } ,
] ,
sortColumns : [ 'No' , null , 'Ticket_State' , null , null , 'Created_On' , 'Last_Activity_Date' , null ] ,
paginationLimit : 10 ,
language : config . gridLang || { } ,
mapData : ( data ) => ( data . data || [ ] ) . map ( ( row ) => [
{ no : row . No || '' , url : row . ticket _url || '#' } ,
row . Description || '' ,
{ variant : row . state _variant || 'neutral' , label : row . Ticket _State || '' } ,
row . Category _1 _Description || '' ,
row . Support _User _Name || '' ,
row . created _formatted || '' ,
row . last _activity _formatted || '' ,
row . ticket _url || '' ,
] ) ,
search : config . gridSearch || null ,
filterSchema : config . filterSchema || [ ] ,
urlSync : true ,
actions : { enabled : false } ,
rowDblClick : {
getUrl : ( rowData ) => {
const url = rowData ? . cells ? . [ 7 ] ? . data ;
return url || '' ;
} ,
} ,
rowInteraction : { linkColumn : 0 } ,
} ;
2026-04-20 23:01:54 +02:00
initListSection ( {
moduleId : 'helpdesk-detail-tickets' ,
initErrorMessage : 'Helpdesk tickets grid init failed' ,
initOptions : {
2026-04-02 17:48:27 +02:00
grid : gridOptions ,
filters : {
mode : 'drawer' ,
chipMeta : config . filterChipMeta || { } ,
watchInputs : [ '#helpdesk-ticket-search' ] ,
} ,
2026-04-20 23:01:54 +02:00
} ,
2026-04-02 17:48:27 +02:00
} ) ;
ticketsGridInitialized = true ;
} finally {
ticketsGridInitializing = false ;
}
}
2026-04-04 18:34:03 +02:00
async function renderSupportTab ( ) {
if ( supportTabRendered ) return ;
showLoading ( 'support-loading' ) ;
const result = await fetchSupportDashboard ( ) ;
hideLoading ( 'support-loading' ) ;
showContent ( 'support-content' ) ;
const recommendationsEl = document . getElementById ( 'support-system-recommendations' ) ;
if ( ! result ? . ok ) {
setText ( 'support-kpi-open-tickets' , '—' ) ;
setText ( 'support-kpi-created-30d' , '—' ) ;
setText ( 'support-kpi-closed-30d' , '—' ) ;
setText ( 'support-kpi-avg-age' , '—' ) ;
if ( recommendationsEl ) {
recommendationsEl . innerHTML = errorNotice ( t ( 'Could not load tickets.' ) ) ;
}
supportTabRendered = true ;
return ;
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
const health = ( result . health && typeof result . health === 'object' ) ? result . health : { } ;
const openTickets = Number ( health . open _tickets ? ? 0 ) ;
const created30d = Number ( health . created _30d ? ? 0 ) ;
const createdPrev30d = Number ( health . created _prev _30d ? ? 0 ) ;
const closed30d = Number ( health . closed _30d ? ? 0 ) ;
const closedPrev30d = Number ( health . closed _prev _30d ? ? 0 ) ;
const avgAgeHours = health . avg _open _age _hours !== null && health . avg _open _age _hours !== undefined
? Number ( health . avg _open _age _hours ) : null ;
const criticalTickets = Number ( health . critical _tickets ? ? 0 ) ;
// Helper: render trend indicator
const renderTrend = ( current , previous , invertColor ) => {
if ( previous === 0 && current === 0 ) return '' ;
const delta = current - previous ;
if ( delta === 0 ) return ` <span class="helpdesk-kpi-trend-neutral">— ${ t ( 'vs. prev. 30d' ) } </span> ` ;
const arrow = delta > 0 ? '↑' : '↓' ;
const abs = Math . abs ( delta ) ;
// For "resolved" more is good (invert), for "created/open" more is bad
const isPositive = invertColor ? delta > 0 : delta < 0 ;
const cls = isPositive ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative' ;
return ` <span class=" ${ cls } "> ${ arrow } ${ abs } ${ t ( 'vs. prev. 30d' ) } </span> ` ;
} ;
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
// 1. Open tickets + net flow trend
setText ( 'support-kpi-open-tickets' , openTickets ) ;
const netFlow = created30d - closed30d ;
const openTrendEl = document . getElementById ( 'support-kpi-open-trend' ) ;
if ( openTrendEl ) {
if ( netFlow > 0 ) {
openTrendEl . innerHTML = ` <span class="helpdesk-kpi-trend-negative">↑ ${ netFlow } ${ t ( 'net (30d)' ) } </span> ` ;
} else if ( netFlow < 0 ) {
openTrendEl . innerHTML = ` <span class="helpdesk-kpi-trend-positive">↓ ${ Math . abs ( netFlow ) } ${ t ( 'net (30d)' ) } </span> ` ;
} else if ( created30d > 0 ) {
openTrendEl . innerHTML = ` <span class="helpdesk-kpi-trend-neutral">± 0 ${ t ( 'net (30d)' ) } </span> ` ;
}
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
// 2. Created last 30d + trend vs previous 30d
setText ( 'support-kpi-created-30d' , created30d ) ;
const createdTrendEl = document . getElementById ( 'support-kpi-created-trend' ) ;
if ( createdTrendEl ) {
createdTrendEl . innerHTML = renderTrend ( created30d , createdPrev30d , false ) ;
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
// 3. Closed last 30d + trend vs previous 30d
setText ( 'support-kpi-closed-30d' , closed30d ) ;
const closedTrendEl = document . getElementById ( 'support-kpi-closed-trend' ) ;
if ( closedTrendEl ) {
closedTrendEl . innerHTML = renderTrend ( closed30d , closedPrev30d , true ) ;
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
// 4. Avg age of open tickets
const formatAge = ( hours ) => {
if ( hours === null ) return '—' ;
if ( hours < 24 ) return ` ${ hours } h ` ;
const days = Math . round ( hours / 24 ) ;
return ` ${ days } d ` ;
} ;
setText ( 'support-kpi-avg-age' , formatAge ( avgAgeHours ) ) ;
const ageHintEl = document . getElementById ( 'support-kpi-age-hint' ) ;
if ( ageHintEl && criticalTickets > 0 ) {
ageHintEl . innerHTML = ` <span class="helpdesk-kpi-trend-negative"> ${ criticalTickets } ${ t ( 'critical' ) } </span> ` ;
2026-04-02 17:48:27 +02:00
}
2026-04-05 16:32:34 +02:00
// Recommendations + escalations merged — only show section when there are entries
const recommendationsWrapper = document . getElementById ( 'support-recommendations-wrapper' ) ;
2026-04-04 18:34:03 +02:00
if ( recommendationsEl ) {
const recommendations = Array . isArray ( result . recommendations )
? result . recommendations
: ( Array . isArray ( result . actions ) ? result . actions : [ ] ) ;
if ( recommendations . length > 0 ) {
recommendationsEl . innerHTML = renderSystemRecommendations ( recommendations ) ;
2026-04-05 16:32:34 +02:00
if ( recommendationsWrapper ) recommendationsWrapper . hidden = false ;
} else if ( recommendationsWrapper ) {
recommendationsWrapper . hidden = true ;
2026-04-02 21:46:01 +02:00
}
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
supportTabRendered = true ;
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
async function renderSalesTab ( ) {
if ( salesTabRendered ) return ;
showLoading ( 'sales-loading' ) ;
const result = await fetchSalesDashboard ( ) ;
hideLoading ( 'sales-loading' ) ;
showContent ( 'sales-content' ) ;
if ( ! result ? . ok ) {
setText ( 'sales-kpi-monthly-volume' , '—' ) ;
setText ( 'sales-kpi-active-contracts' , '—' ) ;
setText ( 'sales-kpi-avg-value' , '—' ) ;
setText ( 'sales-kpi-next-invoicing' , '—' ) ;
const detailEl = document . getElementById ( 'sales-contracts-detail' ) ;
if ( detailEl ) detailEl . innerHTML = errorNotice ( t ( 'Could not load sales dashboard.' ) ) ;
salesTabRendered = true ;
return ;
}
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
// Render KPIs
const kpis = ( result . kpis && typeof result . kpis === 'object' ) ? result . kpis : { } ;
const amountFmt = new Intl . NumberFormat ( undefined , {
style : 'currency' ,
currency : 'EUR' ,
maximumFractionDigits : 0 ,
} ) ;
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
const formatDate = ( value ) => {
const raw = String ( value || '' ) . trim ( ) ;
if ( ! raw || raw . startsWith ( '0001-01-01' ) ) return '—' ;
const date = new Date ( raw ) ;
if ( Number . isNaN ( date . getTime ( ) ) ) return raw ;
return date . toLocaleDateString ( ) ;
} ;
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
// Trend helper (same pattern as support dashboard)
const salesTrend = ( current , previous , invertColor ) => {
if ( previous === 0 && current === 0 ) return '' ;
const delta = current - previous ;
if ( delta === 0 ) return ` <span class="helpdesk-kpi-trend-neutral">— ${ esc ( t ( 'vs. prev. 30d' ) ) } </span> ` ;
const arrow = delta > 0 ? '↑' : '↓' ;
const abs = Math . abs ( delta ) ;
const isPositive = invertColor ? delta > 0 : delta < 0 ;
const cls = isPositive ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative' ;
return ` <span class=" ${ cls } "> ${ arrow } ${ abs } ${ esc ( t ( 'vs. prev. 30d' ) ) } </span> ` ;
} ;
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
const started30d = Number ( kpis . started _30d ? ? 0 ) ;
const startedPrev30d = Number ( kpis . started _prev _30d ? ? 0 ) ;
const ended30d = Number ( kpis . ended _30d ? ? 0 ) ;
const endedPrev30d = Number ( kpis . ended _prev _30d ? ? 0 ) ;
// 1. Monthly volume + net contract flow trend
setText ( 'sales-kpi-monthly-volume' , amountFmt . format ( Number ( kpis . monthly _volume ? ? 0 ) ) ) ;
const netContracts = started30d - ended30d ;
if ( started30d > 0 || ended30d > 0 ) {
const netCls = netContracts > 0 ? 'helpdesk-kpi-trend-positive' : ( netContracts < 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral' ) ;
const netArrow = netContracts > 0 ? '↑' : ( netContracts < 0 ? '↓' : '—' ) ;
const netAbs = Math . abs ( netContracts ) ;
setHtml ( 'sales-kpi-monthly-volume-trend' ,
` <span class=" ${ netCls } "> ${ netArrow } ${ netAbs } ${ esc ( t ( 'net (30d)' ) ) } </span> ` ) ;
2026-04-02 17:48:27 +02:00
} else {
2026-04-04 18:34:03 +02:00
setHtml ( 'sales-kpi-monthly-volume-trend' , '' ) ;
}
// 2. Active contracts + trend arrow (started vs previous period)
setText ( 'sales-kpi-active-contracts' , kpis . active _contracts ? ? 0 ) ;
const totalContracts = Number ( kpis . total _contracts ? ? 0 ) ;
const contractsTrendHtml = salesTrend ( started30d , startedPrev30d , false ) ;
const totalHint = totalContracts > 0 ? ` <span class="helpdesk-kpi-trend-neutral"> ${ esc ( t ( 'of {total} total' ) . replace ( '{total}' , totalContracts ) ) } </span> ` : '' ;
setHtml ( 'sales-kpi-active-contracts-trend' , contractsTrendHtml
? contractsTrendHtml + ( totalHint ? ' · ' + totalHint : '' )
: totalHint ) ;
// 3. Avg contract value + largest hint
setText ( 'sales-kpi-avg-value' , amountFmt . format ( Number ( kpis . avg _contract _value ? ? 0 ) ) ) ;
const largest = Number ( kpis . largest _contract _volume ? ? 0 ) ;
const activeCount = Number ( kpis . active _contracts ? ? 0 ) ;
const totalPositions = Number ( kpis . total _positions ? ? 0 ) ;
const avgParts = [ ] ;
if ( activeCount > 1 && largest > 0 ) {
avgParts . push ( ` <span class="helpdesk-kpi-trend-neutral"> ${ esc ( t ( 'largest' ) ) } : ${ esc ( amountFmt . format ( largest ) ) } </span> ` ) ;
2026-04-02 17:48:27 +02:00
}
2026-04-04 18:34:03 +02:00
if ( totalPositions > 0 ) {
avgParts . push ( ` <span class="helpdesk-kpi-trend-neutral"> ${ esc ( totalPositions + ' ' + t ( 'positions' ) ) } </span> ` ) ;
}
setHtml ( 'sales-kpi-avg-value-trend' , avgParts . join ( ' · ' ) ) ;
// 4. Next invoicing + countdown
setText ( 'sales-kpi-next-invoicing' , formatDate ( kpis . next _invoicing _date ) ) ;
const daysUntil = kpis . days _until _next _invoicing ;
if ( daysUntil !== null && daysUntil !== undefined ) {
const cls = daysUntil <= 7 ? 'helpdesk-kpi-trend-negative' : ( daysUntil <= 30 ? 'helpdesk-kpi-trend-neutral' : 'helpdesk-kpi-trend-positive' ) ;
setHtml ( 'sales-kpi-next-invoicing-trend' ,
` <span class=" ${ cls } "> ${ esc ( t ( 'in {days} days' ) . replace ( '{days}' , daysUntil ) ) } </span> ` ) ;
}
2026-04-05 16:32:34 +02:00
// Contract timeline (swimlane view — click on bar opens contract dialog)
renderContractTimeline ( result . contracts || [ ] ) ;
2026-04-04 18:34:03 +02:00
// Init contacts grid (server-side, same pattern as tickets)
initContactsGrid ( ) ;
2026-04-02 17:48:27 +02:00
2026-04-13 14:13:29 +02:00
// Meetings timeline (async, doesn't block sales tab)
renderMeetingsSection ( ) ;
2026-04-04 18:34:03 +02:00
salesTabRendered = true ;
2026-04-02 17:48:27 +02:00
}
2026-04-13 14:13:29 +02:00
// All values inserted via esc() which escapes HTML entities — safe innerHTML usage (same pattern as renderContractTimeline, renderSystemRecommendations)
async function renderMeetingsSection ( ) {
const loadingEl = document . getElementById ( 'meetings-loading' ) ;
const contentEl = document . getElementById ( 'meetings-content' ) ;
const emptyEl = document . getElementById ( 'meetings-empty' ) ;
const errorEl = document . getElementById ( 'meetings-error' ) ;
if ( ! loadingEl || ! contentEl ) return ;
loadingEl . hidden = false ;
contentEl . hidden = true ;
if ( emptyEl ) emptyEl . hidden = true ;
if ( errorEl ) errorEl . hidden = true ;
const result = await fetchMeetings ( ) ;
loadingEl . hidden = true ;
if ( ! result ? . ok ) {
if ( errorEl ) {
errorEl . innerHTML = errorNotice ( t ( 'Could not load meetings.' ) ) ; // raw-html-ok: errorNotice returns escaped HTML
errorEl . hidden = false ;
}
return ;
}
const kpis = result . kpis || { } ;
const timeline = result . timeline || { } ;
const upcoming = timeline . upcoming || { } ;
const past = timeline . past || { } ;
const hasEntries = Object . keys ( upcoming ) . length > 0 || Object . keys ( past ) . length > 0 ;
if ( ! hasEntries ) {
if ( emptyEl ) {
emptyEl . innerHTML = renderEmptyState ( t ( 'No meetings found for this customer.' ) ) ; // raw-html-ok: renderEmptyState returns escaped HTML
emptyEl . hidden = false ;
}
return ;
}
// Render timeline — all dynamic values passed through esc() which HTML-entity-escapes
const timelineEl = document . getElementById ( 'meetings-timeline' ) ;
if ( ! timelineEl ) { contentEl . hidden = false ; return ; }
let html = '' ;
if ( Object . keys ( upcoming ) . length > 0 ) {
html += ` <div class="helpdesk-meetings-group-header"> ${ esc ( t ( 'Upcoming meetings' ) ) } </div> ` ;
html += renderMeetingGroups ( upcoming ) ;
}
if ( Object . keys ( past ) . length > 0 ) {
html += ` <div class="helpdesk-meetings-group-header helpdesk-meetings-group-header-past"> ${ esc ( t ( 'Past meetings' ) ) } </div> ` ;
html += renderMeetingGroups ( past ) ;
}
timelineEl . innerHTML = html ; // raw-html-ok: all values escaped via esc()
contentEl . hidden = false ;
}
function renderMeetingGroups ( groups ) {
const monthFmt = new Intl . DateTimeFormat ( undefined , { year : 'numeric' , month : 'long' } ) ;
const dateFmt = new Intl . DateTimeFormat ( undefined , { day : 'numeric' , month : 'short' } ) ;
let html = '' ;
for ( const [ monthKey , entries ] of Object . entries ( groups ) ) {
const monthDate = new Date ( monthKey + '-01' ) ;
const monthLabel = Number . isNaN ( monthDate . getTime ( ) ) ? monthKey : monthFmt . format ( monthDate ) ;
html += ` <div class="helpdesk-meetings-month"><span class="helpdesk-meetings-month-label"> ${ esc ( monthLabel ) } </span> ` ;
for ( const entry of entries ) {
const d = new Date ( entry . appointment _date ) ;
const dateStr = Number . isNaN ( d . getTime ( ) ) ? entry . appointment _date : dateFmt . format ( d ) ;
const statusCls = entry . is _released ? 'helpdesk-meetings-card-released' : 'helpdesk-meetings-card-pending' ;
const badgeCls = entry . is _released ? 'helpdesk-meetings-badge-released' : 'helpdesk-meetings-badge-pending' ;
const badgeLabel = entry . is _released ? t ( 'Released' ) : t ( 'Pending' ) ;
html += ` <article class="helpdesk-meetings-card ${ statusCls } ">
< p class = "helpdesk-meetings-card-comment" > $ { esc ( entry . comment || '—' ) } < / p >
< p class = "helpdesk-meetings-card-secondary" >
< span class = "helpdesk-meetings-card-context" >
< time datetime = "${esc(entry.appointment_date)}" > $ { esc ( dateStr ) } < / t i m e >
< span > · < / s p a n >
< span > $ { esc ( entry . salesperson _code ) } < / s p a n >
< / s p a n >
< span class = "helpdesk-meetings-badge ${badgeCls}" aria - label = "${esc(badgeLabel)}" > $ { esc ( badgeLabel ) } < / s p a n >
< / p >
< / a r t i c l e > ` ;
}
html += '</div>' ;
}
return html ;
}
2026-04-05 16:32:34 +02:00
function renderContractTimeline ( contracts ) {
const chartEl = document . getElementById ( 'sales-trend-chart' ) ;
if ( ! chartEl || ! contracts . length ) return ;
// Store contracts for dialog access
salesContracts = contracts ;
const amountFmt = new Intl . NumberFormat ( undefined , {
style : 'currency' ,
currency : 'EUR' ,
maximumFractionDigits : 0 ,
} ) ;
const nowDate = new Date ( ) ;
// Parse dates and compute timeline range
const items = [ ] ;
let globalMin = Infinity ;
let globalMax = - Infinity ;
for ( let i = 0 ; i < contracts . length ; i ++ ) {
const c = contracts [ i ] ;
const startRaw = String ( c . starting _date || '' ) . trim ( ) ;
if ( ! startRaw || startRaw . startsWith ( '0001' ) ) continue ;
const startTs = new Date ( startRaw ) . getTime ( ) ;
if ( Number . isNaN ( startTs ) ) continue ;
const stateLower = String ( c . state || '' ) . toLowerCase ( ) ;
const isActive = stateLower === 'aktiv' ;
let endTs ;
if ( isActive ) {
endTs = nowDate . getTime ( ) ;
} else {
const endRaw = String ( c . next _invoicing _date || '' ) . trim ( ) ;
endTs = ( endRaw && ! endRaw . startsWith ( '0001' ) ) ? new Date ( endRaw ) . getTime ( ) : startTs ;
if ( Number . isNaN ( endTs ) ) endTs = startTs ;
}
if ( startTs < globalMin ) globalMin = startTs ;
if ( endTs > globalMax ) globalMax = endTs ;
if ( isActive && nowDate . getTime ( ) > globalMax ) globalMax = nowDate . getTime ( ) ;
items . push ( {
contract _index : i ,
contract _no : String ( c . contract _no || '' ) ,
product _type : String ( c . product _type || '' ) . trim ( ) ,
monthly _amount : Number ( c . monthly _amount ? ? 0 ) ,
is _active : isActive ,
start _ts : startTs ,
end _ts : endTs ,
} ) ;
}
if ( items . length === 0 ) return ;
// Sort: active first, then by start date
items . sort ( ( a , b ) => {
if ( a . is _active !== b . is _active ) return a . is _active ? - 1 : 1 ;
return a . start _ts - b . start _ts ;
} ) ;
// Extend range to full years so all year ticks are always visible
const firstYear = new Date ( globalMin ) . getFullYear ( ) ;
const lastYear = new Date ( globalMax ) . getFullYear ( ) ;
const yearStartMin = new Date ( firstYear , 0 , 1 ) . getTime ( ) ;
const yearStartMax = new Date ( lastYear , 0 , 1 ) . getTime ( ) ;
const rangeMin = Math . min ( globalMin , yearStartMin ) ;
const rangeMax = Math . max ( globalMax , yearStartMax ) ;
const range = rangeMax - rangeMin || 1 ;
const padding = range * 0.02 ;
const timeMin = rangeMin - padding ;
const timeMax = rangeMax + padding ;
const timeRange = timeMax - timeMin ;
// Build year tick marks + vertical guide lines
const minYear = new Date ( globalMin ) . getFullYear ( ) ;
const maxYear = new Date ( globalMax ) . getFullYear ( ) ;
let ticksHtml = '' ;
let guidesHtml = '' ;
for ( let y = minYear ; y <= maxYear ; y ++ ) {
const yearTs = new Date ( y , 0 , 1 ) . getTime ( ) ;
const pct = ( ( yearTs - timeMin ) / timeRange ) * 100 ;
if ( pct >= 0 && pct <= 100 ) {
ticksHtml += ` <div class="helpdesk-tl-tick" style="left: ${ pct . toFixed ( 2 ) } %">
< span class = "helpdesk-tl-tick-label" > $ { y } < / s p a n >
< / d i v > ` ;
guidesHtml += ` <div class="helpdesk-tl-guide" style="left: ${ pct . toFixed ( 2 ) } %"></div> ` ;
}
}
// Build swimlane rows
let rowsHtml = '' ;
for ( const item of items ) {
const leftPct = ( ( item . start _ts - timeMin ) / timeRange ) * 100 ;
const widthPct = ( ( item . end _ts - item . start _ts ) / timeRange ) * 100 ;
const barCls = item . is _active ? 'helpdesk-tl-bar-active' : 'helpdesk-tl-bar-ended' ;
const typeLabel = item . product _type ? item . product _type . toUpperCase ( ) : '' ;
const amtLabel = item . monthly _amount > 0 ? amountFmt . format ( item . monthly _amount ) : '' ;
const startDate = new Date ( item . start _ts ) . toLocaleDateString ( ) ;
const endLabel = item . is _active ? t ( 'active' ) : new Date ( item . end _ts ) . toLocaleDateString ( ) ;
const tooltip = ` ${ item . contract _no } · ${ startDate } – ${ endLabel } ` + ( amtLabel ? ` · ${ amtLabel } /M ` : '' ) ;
rowsHtml += ` <div class="helpdesk-tl-row helpdesk-tl-row-clickable" data-contract-index=" ${ item . contract _index } " tabindex="0">
< div class = "helpdesk-tl-label" >
< span class = "helpdesk-tl-type" > $ { esc ( typeLabel || item . contract _no ) } < / s p a n >
< span class = "helpdesk-tl-amount" > $ { esc ( amtLabel ? amtLabel + '/M' : '' ) } < / s p a n >
< / d i v >
< div class = "helpdesk-tl-track" >
< div class = "helpdesk-tl-bar ${barCls}" style = "left:${leftPct.toFixed(2)}%;width:${Math.max(widthPct, 0.5).toFixed(2)}%"
data - tooltip = "${esc(tooltip)}" data - tooltip - pos = "top" > $ { item . is _active ? '<span class="helpdesk-tl-bar-arrow"></span>' : '' } < / d i v >
< / d i v >
< / d i v > ` ;
}
chartEl . innerHTML = `
< div class = "helpdesk-tl" >
< div class = "helpdesk-tl-body" >
< div class = "helpdesk-tl-guides" > $ { guidesHtml } < / d i v >
< div class = "helpdesk-tl-rows" > $ { rowsHtml } < / d i v >
< / d i v >
< div class = "helpdesk-tl-axis" > $ { ticksHtml } < / d i v >
< / d i v >
< div class = "helpdesk-tl-legend" >
< span class = "helpdesk-tl-legend-item" >
< span class = "helpdesk-tl-legend-dot helpdesk-tl-dot-active" > < / s p a n >
$ { esc ( t ( 'active' ) ) }
< / s p a n >
< span class = "helpdesk-tl-legend-item" >
< span class = "helpdesk-tl-legend-dot helpdesk-tl-dot-ended" > < / s p a n >
$ { esc ( t ( 'ended' ) ) }
< / s p a n >
< / d i v >
` ;
}
2026-04-03 16:45:41 +02:00
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 ,
2026-04-05 22:49:01 +02:00
fileDownloadUrl ,
2026-04-03 16:45:41 +02:00
} ) ;
feedEl . innerHTML = html ;
}
2026-04-04 18:34:03 +02:00
// --- Controlling tab ---
const controllingDashboardUrl = container . dataset . controllingDashboardUrl || '' ;
let controllingTabRendered = false ;
let controllingCurrentPeriod = 90 ;
async function fetchControllingDashboard ( periodDays ) {
const params = buildBaseParams ( ) ;
params . set ( 'periodDays' , String ( periodDays ) ) ;
const url = controllingDashboardUrl + '?' + params . toString ( ) ;
try {
2026-04-20 22:31:13 +02:00
return await getJson ( url , { signal : requestController . signal } ) ;
2026-04-04 18:34:03 +02:00
} catch {
return null ;
}
}
async function renderControllingTab ( periodDays ) {
showLoading ( 'controlling-spinner' ) ;
const contentEl = document . getElementById ( 'controlling-content' ) ;
const errorEl = document . getElementById ( 'controlling-error' ) ;
const emptyEl = document . getElementById ( 'controlling-empty' ) ;
if ( contentEl ) contentEl . hidden = true ;
if ( errorEl ) errorEl . hidden = true ;
if ( emptyEl ) emptyEl . hidden = true ;
const result = await fetchControllingDashboard ( periodDays ) ;
hideLoading ( 'controlling-spinner' ) ;
if ( ! result ? . ok ) {
if ( errorEl ) {
errorEl . innerHTML = errorNotice ( t ( 'Could not load controlling dashboard.' ) ) ;
errorEl . hidden = false ;
}
controllingTabRendered = true ;
return ;
}
const kpis = result . kpis || { } ;
const meta = result . meta || { } ;
if ( meta . created _in _period === 0 && meta . created _in _prev === 0 && kpis . backlog _open === 0 ) {
if ( emptyEl ) {
emptyEl . innerHTML = renderEmptyState ( {
message : t ( 'No controlling data available.' ) ,
size : 'compact' ,
icon : false ,
} ) ;
emptyEl . hidden = false ;
}
controllingTabRendered = true ;
return ;
}
if ( contentEl ) contentEl . hidden = false ;
// --- KPI 1: Ticket Volume ---
const volume = Number ( kpis . volume ? ? 0 ) ;
const volumePrev = Number ( kpis . volume _prev ? ? 0 ) ;
const volumeDelta = Number ( kpis . volume _delta ? ? 0 ) ;
setText ( 'controlling-kpi-volume' , String ( volume ) ) ;
if ( volumeDelta !== 0 || volumePrev > 0 ) {
const arrow = volumeDelta > 0 ? '↑' : volumeDelta < 0 ? '↓' : '—' ;
const abs = Math . abs ( volumeDelta ) ;
// More tickets = more load = negative for controlling
const cls = volumeDelta > 0 ? 'helpdesk-kpi-trend-negative'
: volumeDelta < 0 ? 'helpdesk-kpi-trend-positive'
: 'helpdesk-kpi-trend-neutral' ;
setHtml ( 'controlling-kpi-volume-trend' ,
` <span class=" ${ cls } "> ${ arrow } ${ abs } ${ esc ( t ( 'vs. prev. period' ) ) } </span> `
) ;
}
// --- KPI 2: Close-Rate ---
const closeRate = kpis . close _rate ;
const closeRatePrev = kpis . close _rate _prev ;
setText ( 'controlling-kpi-closerate' , closeRate != null ? closeRate . toFixed ( 1 ) + '× ' : '—' ) ;
if ( closeRate != null && closeRatePrev != null ) {
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive'
: 'helpdesk-kpi-trend-negative' ;
const prevLabel = closeRatePrev . toFixed ( 1 ) + '× ' ;
setHtml ( 'controlling-kpi-closerate-trend' ,
` <span class=" ${ cls } "> ${ esc ( t ( 'vs. prev. period' ) ) } : ${ prevLabel } </span> `
) ;
} else if ( closeRate != null ) {
const cls = closeRate >= 1.0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative' ;
const label = closeRate >= 1.0 ? t ( 'Backlog shrinking' ) : t ( 'Backlog growing' ) ;
setHtml ( 'controlling-kpi-closerate-trend' , ` <span class=" ${ cls } "> ${ esc ( label ) } </span> ` ) ;
}
// --- KPI 3: Avg. Resolution ---
const resolution = kpis . resolution _hours ;
const resolutionPrev = kpis . resolution _hours _prev ;
setText ( 'controlling-kpi-resolution' , resolution != null ? fmtHours ( resolution ) : '—' ) ;
if ( resolution != null && resolutionPrev != null ) {
const delta = resolution - resolutionPrev ;
if ( delta === 0 ) {
setHtml ( 'controlling-kpi-resolution-trend' ,
` <span class="helpdesk-kpi-trend-neutral">— ${ esc ( t ( 'vs. prev. period' ) ) } </span> `
) ;
} else {
const arrow = delta > 0 ? '↑' : '↓' ;
const absDelta = Math . abs ( Math . round ( delta ) ) ;
// Lower resolution = better
const cls = delta < 0 ? 'helpdesk-kpi-trend-positive' : 'helpdesk-kpi-trend-negative' ;
setHtml ( 'controlling-kpi-resolution-trend' ,
` <span class=" ${ cls } "> ${ arrow } ${ fmtHours ( absDelta ) } ${ esc ( t ( 'vs. prev. period' ) ) } </span> `
) ;
}
}
// --- KPI 4: Open Backlog ---
const backlog = Number ( kpis . backlog _open ? ? 0 ) ;
const unassigned = Number ( kpis . backlog _unassigned ? ? 0 ) ;
const critical = Number ( kpis . backlog _critical ? ? 0 ) ;
setText ( 'controlling-kpi-backlog' , String ( backlog ) ) ;
const parts = [ ] ;
if ( unassigned > 0 ) parts . push ( unassigned + ' ' + t ( 'unassigned' ) ) ;
if ( critical > 0 ) parts . push ( critical + ' ' + t ( 'critical' ) ) ;
if ( parts . length > 0 ) {
const cls = critical > 0 ? 'helpdesk-kpi-trend-negative' : 'helpdesk-kpi-trend-neutral' ;
setHtml ( 'controlling-kpi-backlog-trend' , ` <span class=" ${ cls } "> ${ esc ( parts . join ( ', ' ) ) } </span> ` ) ;
}
// Trend chart
renderTrendChart ( result . trend || [ ] ) ;
2026-04-05 16:32:34 +02:00
2026-04-04 18:34:03 +02:00
// Risk indicators
renderControllingRiskIndicators ( result . risk _indicators || [ ] , result . risk _summary || { } ) ;
controllingTabRendered = true ;
}
function renderTrendChart ( trend ) {
const chartEl = document . getElementById ( 'controlling-trend-chart' ) ;
if ( ! chartEl || ! trend . length ) return ;
2026-04-05 16:32:34 +02:00
// Find max value for Y-axis scaling
2026-04-04 18:34:03 +02:00
let max = 0 ;
for ( const bucket of trend ) {
if ( bucket . created > max ) max = bucket . created ;
if ( bucket . closed > max ) max = bucket . closed ;
}
if ( max === 0 ) max = 1 ;
2026-04-05 16:32:34 +02:00
// Compute nice grid lines (2-3 lines)
const gridStep = max <= 5 ? 1 : max <= 15 ? 5 : max <= 50 ? 10 : Math . ceil ( max / 4 / 10 ) * 10 ;
let gridHtml = '' ;
for ( let v = gridStep ; v <= max ; v += gridStep ) {
const pct = ( ( v / max ) * 100 ) . toFixed ( 1 ) ;
gridHtml += ` <div class="helpdesk-vchart-grid-line" style="bottom: ${ pct } %">
< span class = "helpdesk-vchart-grid-label" > $ { v } < / s p a n >
< / d i v > ` ;
}
// Bar groups
let barsHtml = '' ;
2026-04-04 18:34:03 +02:00
for ( const bucket of trend ) {
2026-04-05 16:32:34 +02:00
const createdPct = ( ( bucket . created / max ) * 100 ) . toFixed ( 1 ) ;
const closedPct = ( ( bucket . closed / max ) * 100 ) . toFixed ( 1 ) ;
barsHtml += ` <div class="helpdesk-vchart-group">
< div class = "helpdesk-vchart-bars" >
< div class = "helpdesk-vchart-bar helpdesk-vchart-bar-created" style = "height:${createdPct}%" data - tooltip = "${bucket.created}" data - tooltip - pos = "top" > < / d i v >
< div class = "helpdesk-vchart-bar helpdesk-vchart-bar-closed" style = "height:${closedPct}%" data - tooltip = "${bucket.closed}" data - tooltip - pos = "top" > < / d i v >
2026-04-04 18:34:03 +02:00
< / d i v >
2026-04-05 16:32:34 +02:00
< span class = "helpdesk-vchart-label" > $ { esc ( bucket . label ) } < / s p a n >
2026-04-04 18:34:03 +02:00
< / d i v > ` ;
}
chartEl . innerHTML = `
2026-04-05 16:32:34 +02:00
< div class = "helpdesk-vchart-area" >
$ { gridHtml }
< div class = "helpdesk-vchart-groups" > $ { barsHtml } < / d i v >
< / d i v >
< div class = "helpdesk-vchart-legend" >
< span class = "helpdesk-vchart-legend-item" >
< span class = "helpdesk-vchart-legend-dot helpdesk-vchart-dot-created" > < / s p a n >
2026-04-04 18:34:03 +02:00
$ { esc ( t ( 'Created' ) ) }
< / s p a n >
2026-04-05 16:32:34 +02:00
< span class = "helpdesk-vchart-legend-item" >
< span class = "helpdesk-vchart-legend-dot helpdesk-vchart-dot-closed" > < / s p a n >
2026-04-04 18:34:03 +02:00
$ { esc ( t ( 'Closed' ) ) }
< / s p a n >
< / d i v >
` ;
}
function renderControllingRiskIndicators ( indicators , summary ) {
const el = document . getElementById ( 'controlling-risk-indicators' ) ;
if ( ! el || ! indicators . length ) return ;
2026-04-05 16:32:34 +02:00
// Count triggered from actual indicator data (single source of truth)
const total = indicators . length ;
const triggered = indicators . filter ( ind => ! ! ind . triggered ) . length ;
const summaryLabel = triggered === 0
? t ( 'All checks passed' )
: triggered + ' ' + t ( 'of' ) + ' ' + total + ' ' + t ( 'checks flagged' ) ;
const summaryCls = triggered === 0 ? 'helpdesk-risk-summary-ok'
: triggered <= 2 ? 'helpdesk-risk-summary-warn'
: 'helpdesk-risk-summary-critical' ;
let html = ` <div class="helpdesk-risk-header">
< span class = "helpdesk-risk-header-icon ${summaryCls}" > $ { triggered === 0 ? '✓' : '⚠' } < / s p a n >
< span class = "helpdesk-risk-header-label" > $ { esc ( summaryLabel ) } < / s p a n >
2026-04-04 18:34:03 +02:00
< / d i v > ` ;
2026-04-05 16:32:34 +02:00
html += '<div class="helpdesk-risk-checks">' ;
2026-04-04 18:34:03 +02:00
for ( const ind of indicators ) {
2026-04-05 16:32:34 +02:00
const icon = ind . triggered ? '⚠' : '✓' ;
const iconCls = ind . triggered ? 'helpdesk-risk-icon-warn' : 'helpdesk-risk-icon-ok' ;
const barCls = ind . triggered ? 'helpdesk-risk-bar-over' : 'helpdesk-risk-bar-ok' ;
// Threshold bar: normalised to whichever is larger (value or threshold)
// so the bar always fits inside the track and the limit marker stays visible.
const rawVal = ind . raw _value ? ? 0 ;
const thresholdVal = ind . threshold _value ? ? 1 ;
const scale = Math . max ( rawVal , thresholdVal , 1 ) ; // prevent division by 0
const fillPct = Math . min ( 100 , ( rawVal / scale ) * 100 ) ;
const limitPct = Math . min ( 100 , ( thresholdVal / scale ) * 100 ) ;
// Format display value with fmtHours for hour-based units
const displayValue = ind . unit === 'h' && typeof rawVal === 'number' && rawVal > 0
? fmtHours ( rawVal )
: esc ( ind . value ) ;
const limitLabel = ind . unit === 'h' ? t ( 'Limit' ) + ' ' + fmtHours ( thresholdVal ) : t ( 'Limit' ) + ' ' + thresholdVal + ind . unit ;
const descText = ind . description ? t ( ind . description ) : '' ;
html += ` <div class="helpdesk-risk-check">
< div class = "helpdesk-risk-check-header" >
< span class = "helpdesk-risk-icon ${iconCls}" > $ { icon } < / s p a n >
< span class = "helpdesk-risk-check-label" > $ { esc ( t ( ind . label ) || ind . label ) } < / s p a n >
< / d i v >
< div class = "helpdesk-risk-check-value" > $ { displayValue } < / d i v >
$ { descText ? ` <div class="helpdesk-risk-check-desc"> ${ esc ( descText ) } </div> ` : '' }
< div class = "helpdesk-risk-bar-wrapper" >
< div class = "helpdesk-risk-bar-track" >
< div class = "helpdesk-risk-bar-fill ${barCls}" style = "width:${fillPct}%" > < / d i v >
< / d i v >
< div class = "helpdesk-risk-bar-limit" style = "left:${limitPct}%" > < / d i v >
< / d i v >
< div class = "helpdesk-risk-bar-legend" > $ { esc ( limitLabel ) } < / d i v >
2026-04-04 18:34:03 +02:00
< / d i v > ` ;
}
html += '</div>' ;
el . innerHTML = html ;
}
// Period selector (radio inputs)
const periodSelector = document . getElementById ( 'controlling-period-selector' ) ;
2026-04-20 22:31:13 +02:00
on ( periodSelector , 'change' , ( e ) => {
const radio = e . target . closest ( 'input[name="controlling-period"]' ) ;
if ( ! radio ) return ;
const period = parseInt ( radio . value , 10 ) ;
if ( ! period || period === controllingCurrentPeriod ) return ;
controllingCurrentPeriod = period ;
controllingTabRendered = false ;
void renderControllingTab ( period ) ;
} ) ;
2026-04-02 17:48:27 +02:00
const tabPanels = container . querySelectorAll ( '[data-tab-panel]' ) ;
const observer = new MutationObserver ( ( ) => {
for ( const panel of tabPanels ) {
if ( panel . hidden ) continue ;
const tabName = panel . dataset . tabPanel ;
2026-04-04 18:34:03 +02:00
if ( tabName === 'support' ) {
renderSupportTab ( ) ;
2026-04-02 17:48:27 +02:00
initTicketsGrid ( ) ;
2026-04-04 18:34:03 +02:00
} else if ( tabName === 'sales' ) {
renderSalesTab ( ) ;
} else if ( tabName === 'controlling' ) {
if ( ! controllingTabRendered ) {
renderControllingTab ( controllingCurrentPeriod ) ;
}
2026-04-02 17:48:27 +02:00
}
}
} ) ;
for ( const panel of tabPanels ) {
observer . observe ( panel , { attributes : true , attributeFilter : [ 'hidden' ] } ) ;
}
2026-04-04 18:34:03 +02:00
const refreshButton = container . querySelector ( 'button[name="support-refresh"]' )
|| document . getElementById ( 'support-refresh-button' ) ;
2026-04-20 22:31:13 +02:00
on ( refreshButton , 'click' , ( ) => {
refreshButton . setAttribute ( 'aria-busy' , 'true' ) ;
refreshButton . setAttribute ( 'disabled' , 'disabled' ) ;
2026-04-04 18:34:03 +02:00
2026-04-20 22:31:13 +02:00
const url = new URL ( window . location . href ) ;
url . searchParams . set ( 'refresh' , '1' ) ;
window . location . assign ( url . toString ( ) ) ;
} ) ;
2026-04-02 17:48:27 +02:00
2026-04-04 18:34:03 +02:00
renderSupportTab ( ) ;
2026-04-20 22:31:13 +02:00
void initTicketsGrid ( ) ;
void renderDebitorCommunicationAside ( ) ;
return {
destroy : ( ) => {
listenerController . abort ( ) ;
requestController . abort ( ) ;
observer . disconnect ( ) ;
const dialog = document . getElementById ( 'helpdesk-contract-dialog' ) ;
if ( dialog ) {
dialog . remove ( ) ;
}
} ,
} ;
2026-04-02 17:48:27 +02:00
}