feat(helpdesk): redesign dashboards with KPI groups, skeleton loading, and unified spacing
Restructure Support dashboard into 2 KPI groups (Tickets + Contracts) with section dividers, merge escalations and recommendations into "Attention needed". Redesign Sales dashboard with clickable contract timeline and dialog. Add CSS shimmer skeleton loading for all dashboard tabs and aside. Unify vertical rhythm via * + * sibling combinator on tab content containers. Remove ~265 lines of dead CSS (contract cards, escalation styles) and unused JS functions. Refactor hydrateTicketCategoryFilter into generic hydrateSelectFilter. Fix role="button" CSS bleed from shell and remove extra future year from timeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -139,52 +139,56 @@ function init(container) {
|
||||
return debitorCommunicationPromise;
|
||||
}
|
||||
|
||||
function hydrateTicketCategoryFilter(config, categories) {
|
||||
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
|
||||
if (!(categoryFilter instanceof HTMLSelectElement)) return;
|
||||
/**
|
||||
* 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 uniqueCategories = Array.from(new Set(
|
||||
(Array.isArray(categories) ? categories : [])
|
||||
const uniqueValues = Array.from(new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map(item => String(item || '').trim())
|
||||
.filter(Boolean),
|
||||
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
const currentValue = String(categoryFilter.value || '');
|
||||
categoryFilter.innerHTML = '';
|
||||
const currentValue = String(selectEl.value || '');
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = '';
|
||||
allOption.textContent = t('All');
|
||||
categoryFilter.appendChild(allOption);
|
||||
selectEl.appendChild(allOption);
|
||||
|
||||
const categoryOptionMap = {};
|
||||
for (const category of uniqueCategories) {
|
||||
const optionMap = {};
|
||||
for (const value of uniqueValues) {
|
||||
const option = document.createElement('option');
|
||||
option.value = category;
|
||||
option.textContent = category;
|
||||
categoryFilter.appendChild(option);
|
||||
categoryOptionMap[category] = category;
|
||||
option.value = value;
|
||||
option.textContent = value;
|
||||
selectEl.appendChild(option);
|
||||
optionMap[value] = value;
|
||||
}
|
||||
|
||||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
|
||||
categoryFilter.value = currentValue;
|
||||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(optionMap, currentValue)) {
|
||||
selectEl.value = currentValue;
|
||||
} else {
|
||||
categoryFilter.value = '';
|
||||
selectEl.value = '';
|
||||
}
|
||||
|
||||
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
||||
config.filterChipMeta = {};
|
||||
}
|
||||
|
||||
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
|
||||
? config.filterChipMeta.category
|
||||
const currentMeta = (config.filterChipMeta[filterKey] && typeof config.filterChipMeta[filterKey] === 'object')
|
||||
? config.filterChipMeta[filterKey]
|
||||
: {};
|
||||
|
||||
config.filterChipMeta.category = {
|
||||
...currentCategoryMeta,
|
||||
config.filterChipMeta[filterKey] = {
|
||||
...currentMeta,
|
||||
type: 'select',
|
||||
default: '',
|
||||
options: categoryOptionMap,
|
||||
options: optionMap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +360,14 @@ function init(container) {
|
||||
});
|
||||
}
|
||||
|
||||
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: '🔵' },
|
||||
};
|
||||
|
||||
function renderSystemRecommendations(recommendations) {
|
||||
const entries = Array.isArray(recommendations) ? recommendations : [];
|
||||
if (entries.length === 0) {
|
||||
@@ -381,119 +393,106 @@ function init(container) {
|
||||
return messageKey || String(entry.rule_id || '—');
|
||||
};
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
||||
html += `<th>${esc(t('Action'))}</th>`;
|
||||
html += `<th>${esc(t('Age (h)'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
let html = '<div class="helpdesk-rec-list">';
|
||||
for (const entry of entries) {
|
||||
const ticketNo = String(entry.ticket_no || '');
|
||||
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
||||
const ageHours = Number.isFinite(Number(entry.age_hours)) ? String(entry.age_hours) : '—';
|
||||
const rowAttrs = href ? ` class="app-clickable-row" data-href="${esc(href)}" tabindex="0"` : '';
|
||||
html += `<tr${rowAttrs}>`;
|
||||
html += ticketNo && href
|
||||
? `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`
|
||||
: `<td>${esc(ticketNo || '—')}</td>`;
|
||||
html += `<td>${esc(formatRecommendationMessage(entry))}</td>`;
|
||||
html += `<td>${esc(ageHours)}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
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);
|
||||
|
||||
html += '</tbody></table>';
|
||||
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}>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSupportContracts(entries) {
|
||||
const contracts = Array.isArray(entries) ? entries : [];
|
||||
function renderSupportContractKpis(contracts) {
|
||||
const entries = Array.isArray(contracts) ? contracts : [];
|
||||
const wrapper = document.getElementById('support-contract-kpis-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
if (contracts.length === 0) {
|
||||
return renderEmptyState({
|
||||
message: t('No contracts found for this customer.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
wrapper.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const amountFormatter = new Intl.NumberFormat(undefined, {
|
||||
const amountFmt = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Contract No.'))}</th>`;
|
||||
html += `<th>${esc(t('Product type'))}</th>`;
|
||||
html += `<th>${esc(t('State'))}</th>`;
|
||||
html += `<th>${esc(t('Next invoicing'))}</th>`;
|
||||
html += `<th>${esc(t('Amount'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
const activeCount = entries.filter(c => String(c.state || '').toLowerCase() !== 'ended').length;
|
||||
const totalVolume = entries.reduce((sum, c) => sum + Number(c.total_payoff_amount ?? 0), 0);
|
||||
const totalSupportHours = entries.reduce((sum, c) => sum + Number(c.support_hours ?? 0), 0);
|
||||
|
||||
for (const contract of contracts.slice(0, 20)) {
|
||||
const amount = Number(contract.total_payoff_amount ?? 0);
|
||||
html += '<tr>';
|
||||
html += `<td>${esc(contract.contract_no || '')}</td>`;
|
||||
html += `<td>${esc(contract.product_type || '—')}</td>`;
|
||||
html += `<td>${esc(contract.state || '—')}</td>`;
|
||||
html += `<td>${esc(formatDate(contract.next_invoicing_date))}</td>`;
|
||||
html += `<td>${esc(amountFormatter.format(Number.isFinite(amount) ? amount : 0))}</td>`;
|
||||
html += '</tr>';
|
||||
// Find nearest next invoicing
|
||||
const now = Date.now();
|
||||
let nearestInvoicing = null;
|
||||
for (const c of entries) {
|
||||
const raw = String(c.next_invoicing_date || '').trim();
|
||||
if (!raw || raw.startsWith('0001-01-01')) continue;
|
||||
const date = new Date(raw);
|
||||
if (!Number.isNaN(date.getTime()) && date.getTime() > now) {
|
||||
if (!nearestInvoicing || date < nearestInvoicing) nearestInvoicing = date;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
setText('support-kpi-active-contracts', activeCount);
|
||||
const totalContracts = entries.length;
|
||||
if (totalContracts > activeCount) {
|
||||
setHtml('support-kpi-active-contracts-trend',
|
||||
`<span class="helpdesk-kpi-trend-neutral">${esc(t('of {total} total').replace('{total}', totalContracts))}</span>`);
|
||||
}
|
||||
|
||||
setText('support-kpi-monthly-volume', amountFmt.format(totalVolume));
|
||||
|
||||
if (totalSupportHours > 0) {
|
||||
setText('support-kpi-support-hours',
|
||||
(Number.isInteger(totalSupportHours) ? totalSupportHours : totalSupportHours.toFixed(1)) + 'h');
|
||||
} else {
|
||||
setText('support-kpi-support-hours', '—');
|
||||
}
|
||||
|
||||
if (nearestInvoicing) {
|
||||
setText('support-kpi-next-invoicing', nearestInvoicing.toLocaleDateString());
|
||||
const daysUntil = Math.ceil((nearestInvoicing.getTime() - now) / 86400000);
|
||||
if (daysUntil >= 0) {
|
||||
const cls = daysUntil <= 7 ? 'helpdesk-kpi-trend-negative'
|
||||
: daysUntil <= 30 ? 'helpdesk-kpi-trend-neutral'
|
||||
: 'helpdesk-kpi-trend-positive';
|
||||
setHtml('support-kpi-next-invoicing-trend',
|
||||
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
||||
}
|
||||
} else {
|
||||
setText('support-kpi-next-invoicing', '—');
|
||||
}
|
||||
|
||||
wrapper.hidden = false;
|
||||
}
|
||||
|
||||
function contractTypeLabel(productType) {
|
||||
return String(productType || '').trim() || '—';
|
||||
}
|
||||
|
||||
const ACCENT_COUNT = 8;
|
||||
const accentTypeMap = new Map();
|
||||
|
||||
function contractAccentClass(productType) {
|
||||
const key = String(productType || '').trim().toUpperCase();
|
||||
if (key === '') return 'accent-0';
|
||||
if (!accentTypeMap.has(key)) {
|
||||
accentTypeMap.set(key, accentTypeMap.size % ACCENT_COUNT);
|
||||
}
|
||||
return 'accent-' + accentTypeMap.get(key);
|
||||
}
|
||||
|
||||
function contractIcon(productType) {
|
||||
const type = String(productType || '').trim().toLowerCase();
|
||||
if (type === 'wa') return 'bi-tools';
|
||||
if (type === 'fb') return 'bi-cash-stack';
|
||||
if (type === 'lz') return 'bi-key';
|
||||
if (type === 'ho') return 'bi-cloud';
|
||||
if (type === 'su') return 'bi-headset';
|
||||
if (type === 'sw') return 'bi-code-square';
|
||||
return 'bi-file-earmark-text';
|
||||
}
|
||||
|
||||
// Shared formatters for contract rendering
|
||||
// Shared formatter for contract rendering (dialog, timeline)
|
||||
const contractAmountFmt = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const contractAmountFmtShort = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
function contractFormatDate(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || raw.startsWith('0001-01-01')) return '—';
|
||||
@@ -502,13 +501,6 @@ function init(container) {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function contractStateClass(state) {
|
||||
const s = String(state || '').toLowerCase();
|
||||
if (s === 'aktiv') return 'state-active';
|
||||
if (s === 'vorbereitung') return 'state-prep';
|
||||
return 'state-ended';
|
||||
}
|
||||
|
||||
function contractStateVariant(state) {
|
||||
const s = String(state || '').toLowerCase();
|
||||
if (s === 'aktiv') return 'success';
|
||||
@@ -519,55 +511,7 @@ function init(container) {
|
||||
/** Store all contracts for dialog access */
|
||||
let salesContracts = [];
|
||||
|
||||
function renderContractCards(contracts) {
|
||||
if (!Array.isArray(contracts) || contracts.length === 0) {
|
||||
return renderEmptyState({
|
||||
message: t('No contract details available.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
salesContracts = contracts;
|
||||
|
||||
let html = '<div class="helpdesk-contract-cards">';
|
||||
for (let i = 0; i < contracts.length; i++) {
|
||||
const contract = contracts[i];
|
||||
const accent = contractAccentClass(contract.product_type);
|
||||
const stateClass = contractStateClass(contract.state);
|
||||
const monthlyAmt = Number(contract.monthly_amount ?? 0);
|
||||
const lines = Array.isArray(contract.lines) ? contract.lines : [];
|
||||
const typeLabel = contractTypeLabel(contract.product_type);
|
||||
|
||||
html += `<button type="button" class="helpdesk-contract-card ${accent} ${stateClass}" data-contract-index="${i}">`;
|
||||
|
||||
// Status badge — top-right
|
||||
html += `<span class="helpdesk-contract-card-status">`;
|
||||
html += `<span class="helpdesk-contract-card-dot"></span>${esc(contract.state || '—')}`;
|
||||
html += `</span>`;
|
||||
|
||||
// Type label (uppercase, prominent)
|
||||
html += `<span class="helpdesk-contract-card-type">${esc(typeLabel)}</span>`;
|
||||
|
||||
// Amount hero — or last invoicing date for inactive contracts
|
||||
if (monthlyAmt > 0) {
|
||||
html += `<span class="helpdesk-contract-card-amount">${esc(contractAmountFmtShort.format(monthlyAmt))}</span>`;
|
||||
html += `<span class="helpdesk-contract-card-period">${esc(t('per month'))}</span>`;
|
||||
} else if (stateClass === 'state-ended' && contract.next_invoicing_date) {
|
||||
const dateStr = contractFormatDate(contract.next_invoicing_date);
|
||||
if (dateStr !== '—') {
|
||||
html += `<span class="helpdesk-contract-card-amount helpdesk-contract-card-amount-muted">${esc(dateStr)}</span>`;
|
||||
html += `<span class="helpdesk-contract-card-period">${esc(t('Last invoicing'))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderContractLinesTable(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
@@ -674,110 +618,52 @@ function init(container) {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// Delegate click on contract cards
|
||||
// Delegate click on contract cards and timeline rows
|
||||
container.addEventListener('click', (e) => {
|
||||
const card = e.target.closest('.helpdesk-contract-card[data-contract-index]');
|
||||
if (!card) return;
|
||||
const index = Number(card.dataset.contractIndex);
|
||||
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);
|
||||
if (salesContracts[index]) {
|
||||
openContractDialog(salesContracts[index]);
|
||||
}
|
||||
});
|
||||
|
||||
// KPI tile click → scroll to ticket list and set status filter
|
||||
// KPI tile click → scroll to ticket list and apply filter or sort
|
||||
container.addEventListener('click', (e) => {
|
||||
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter]');
|
||||
const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]');
|
||||
if (!kpi) return;
|
||||
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 }));
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
const ticketSection = document.querySelector('.helpdesk-support-ticket-list');
|
||||
if (ticketSection) {
|
||||
ticketSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
function renderEscalatedTickets(entries, meta) {
|
||||
const escalatedEntries = Array.isArray(entries) ? entries : [];
|
||||
const escalationMeta = (meta && typeof meta === 'object') ? meta : {};
|
||||
|
||||
if (escalationMeta.available === false) {
|
||||
return errorNotice(t('Could not load escalated tickets.'));
|
||||
}
|
||||
|
||||
if (escalatedEntries.length === 0) {
|
||||
return renderEmptyState({
|
||||
message: t('No escalated tickets right now.'),
|
||||
size: 'compact',
|
||||
align: 'center',
|
||||
icon: false,
|
||||
});
|
||||
}
|
||||
|
||||
const formatDateTime = (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.toLocaleString();
|
||||
};
|
||||
|
||||
const formatDuration = (secondsValue) => {
|
||||
const totalSeconds = Number(secondsValue);
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const seconds = Math.floor(totalSeconds);
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
const dayLabel = days === 1 ? t('day') : t('days');
|
||||
return hours > 0 ? `${days} ${dayLabel} ${hours} ${t('hours')}` : `${days} ${dayLabel}`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours} ${t('hours')}`;
|
||||
}
|
||||
|
||||
return `${Math.max(1, minutes)} min`;
|
||||
};
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
||||
html += `<th>${esc(t('Escalation code'))}</th>`;
|
||||
html += `<th>${esc(t('SLA target'))}</th>`;
|
||||
html += `<th>${esc(t('Last activity'))}</th>`;
|
||||
html += `<th>${esc(t('Overdue by'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const entry of escalatedEntries.slice(0, 25)) {
|
||||
const ticketNo = String(entry.ticket_no || '');
|
||||
const href = ticketNo ? ticketUrl(ticketNo) : '';
|
||||
html += `<tr class="app-clickable-row" data-href="${esc(href)}" tabindex="0">`;
|
||||
html += `<td><a href="${esc(href)}">${esc(ticketNo)}</a></td>`;
|
||||
html += `<td>${esc(String(entry.escalation_code || '—'))}</td>`;
|
||||
html += `<td>${esc(formatDuration(entry.target_seconds))}</td>`;
|
||||
html += `<td>${esc(formatDateTime(entry.last_activity_iso))}</td>`;
|
||||
html += `<td>${esc(formatDuration(entry.overdue_seconds))}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function updateBadge(id, count) {
|
||||
const badge = document.getElementById(id);
|
||||
if (badge) {
|
||||
badge.textContent = String(count);
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -804,7 +690,6 @@ function init(container) {
|
||||
function showLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.setAttribute('aria-busy', 'true');
|
||||
el.hidden = false;
|
||||
}
|
||||
}
|
||||
@@ -812,7 +697,6 @@ function init(container) {
|
||||
function hideLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.removeAttribute('aria-busy');
|
||||
el.hidden = true;
|
||||
}
|
||||
}
|
||||
@@ -844,6 +728,16 @@ function init(container) {
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -875,8 +769,10 @@ function init(container) {
|
||||
}
|
||||
|
||||
try {
|
||||
const categoryResponse = await fetchTicketCategories(config, appBase);
|
||||
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
|
||||
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 || []);
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
@@ -953,8 +849,6 @@ function init(container) {
|
||||
showContent('support-content');
|
||||
|
||||
const recommendationsEl = document.getElementById('support-system-recommendations');
|
||||
const contractsEl = document.getElementById('support-contracts-widget');
|
||||
const escalationEl = document.getElementById('support-escalation-widget');
|
||||
if (!result?.ok) {
|
||||
setText('support-kpi-open-tickets', '—');
|
||||
setText('support-kpi-created-30d', '—');
|
||||
@@ -963,12 +857,6 @@ function init(container) {
|
||||
if (recommendationsEl) {
|
||||
recommendationsEl.innerHTML = errorNotice(t('Could not load tickets.'));
|
||||
}
|
||||
if (contractsEl) {
|
||||
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
|
||||
}
|
||||
if (escalationEl) {
|
||||
escalationEl.innerHTML = errorNotice(t('Could not load escalated tickets.'));
|
||||
}
|
||||
supportTabRendered = true;
|
||||
return;
|
||||
}
|
||||
@@ -1037,54 +925,21 @@ function init(container) {
|
||||
ageHintEl.innerHTML = `<span class="helpdesk-kpi-trend-negative">${criticalTickets} ${t('critical')}</span>`;
|
||||
}
|
||||
|
||||
// Recommendations widget
|
||||
// Contract KPIs (second row)
|
||||
const contracts = Array.isArray(result.contracts) ? result.contracts : [];
|
||||
renderSupportContractKpis(contracts);
|
||||
|
||||
// Recommendations + escalations merged — only show section when there are entries
|
||||
const recommendationsWrapper = document.getElementById('support-recommendations-wrapper');
|
||||
if (recommendationsEl) {
|
||||
const recommendations = Array.isArray(result.recommendations)
|
||||
? result.recommendations
|
||||
: (Array.isArray(result.actions) ? result.actions : []);
|
||||
if (recommendations.length > 0) {
|
||||
recommendationsEl.innerHTML = renderSystemRecommendations(recommendations);
|
||||
} else {
|
||||
recommendationsEl.innerHTML = renderEmptyState({
|
||||
message: t('No system recommendations right now.'),
|
||||
size: 'compact',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Contracts widget
|
||||
if (contractsEl) {
|
||||
const contracts = Array.isArray(result.contracts) ? result.contracts : [];
|
||||
const contractsMeta = (result.contracts_meta && typeof result.contracts_meta === 'object')
|
||||
? result.contracts_meta
|
||||
: {};
|
||||
|
||||
if (contracts.length === 0 && contractsMeta.available === false) {
|
||||
contractsEl.innerHTML = errorNotice(t('Could not load contracts.'));
|
||||
} else if (contracts.length > 0) {
|
||||
contractsEl.innerHTML = renderSupportContracts(contracts);
|
||||
} else {
|
||||
contractsEl.innerHTML = renderEmptyState({
|
||||
message: t('No contracts found for this customer.'),
|
||||
size: 'compact',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation widget
|
||||
if (escalationEl) {
|
||||
const escalationEntries = Array.isArray(result.escalation_entries) ? result.escalation_entries : [];
|
||||
const escalationMeta = (result.escalation_meta && typeof result.escalation_meta === 'object')
|
||||
? result.escalation_meta
|
||||
: {};
|
||||
|
||||
if (escalationEntries.length > 0 || escalationMeta.available === false) {
|
||||
escalationEl.innerHTML = renderEscalatedTickets(escalationEntries, escalationMeta);
|
||||
} else {
|
||||
escalationEl.innerHTML = renderEmptyState({
|
||||
message: t('No escalated tickets right now.'),
|
||||
size: 'compact',
|
||||
});
|
||||
if (recommendationsWrapper) recommendationsWrapper.hidden = false;
|
||||
} else if (recommendationsWrapper) {
|
||||
recommendationsWrapper.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1188,12 +1043,8 @@ function init(container) {
|
||||
`<span class="${cls}">${esc(t('in {days} days').replace('{days}', daysUntil))}</span>`);
|
||||
}
|
||||
|
||||
// Render contracts
|
||||
const detailEl = document.getElementById('sales-contracts-detail');
|
||||
if (detailEl) {
|
||||
detailEl.innerHTML = `<h3 class="helpdesk-support-section-title">${esc(t('Contract overview'))}</h3>`
|
||||
+ renderContractCards(result.contracts || []);
|
||||
}
|
||||
// Contract timeline (swimlane view — click on bar opens contract dialog)
|
||||
renderContractTimeline(result.contracts || []);
|
||||
|
||||
// Init contacts grid (server-side, same pattern as tickets)
|
||||
initContactsGrid();
|
||||
@@ -1201,6 +1052,144 @@ function init(container) {
|
||||
salesTabRendered = true;
|
||||
}
|
||||
|
||||
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}</span>
|
||||
</div>`;
|
||||
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)}</span>
|
||||
<span class="helpdesk-tl-amount">${esc(amtLabel ? amtLabel + '/M' : '')}</span>
|
||||
</div>
|
||||
<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>' : ''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
chartEl.innerHTML = `
|
||||
<div class="helpdesk-tl">
|
||||
<div class="helpdesk-tl-body">
|
||||
<div class="helpdesk-tl-guides">${guidesHtml}</div>
|
||||
<div class="helpdesk-tl-rows">${rowsHtml}</div>
|
||||
</div>
|
||||
<div class="helpdesk-tl-axis">${ticksHtml}</div>
|
||||
</div>
|
||||
<div class="helpdesk-tl-legend">
|
||||
<span class="helpdesk-tl-legend-item">
|
||||
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-active"></span>
|
||||
${esc(t('active'))}
|
||||
</span>
|
||||
<span class="helpdesk-tl-legend-item">
|
||||
<span class="helpdesk-tl-legend-dot helpdesk-tl-dot-ended"></span>
|
||||
${esc(t('ended'))}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderDebitorCommunicationAside() {
|
||||
const feedEl = document.getElementById('debitor-communication-feed');
|
||||
const loadingEl = document.getElementById('debitor-communication-loading');
|
||||
@@ -1358,8 +1347,7 @@ function init(container) {
|
||||
// Trend chart
|
||||
renderTrendChart(result.trend || []);
|
||||
|
||||
// Efficiency
|
||||
renderEfficiencyWidget(result.efficiency || {});
|
||||
|
||||
|
||||
// Risk indicators
|
||||
renderControllingRiskIndicators(result.risk_indicators || [], result.risk_summary || {});
|
||||
@@ -1371,6 +1359,7 @@ function init(container) {
|
||||
const chartEl = document.getElementById('controlling-trend-chart');
|
||||
if (!chartEl || !trend.length) return;
|
||||
|
||||
// Find max value for Y-axis scaling
|
||||
let max = 0;
|
||||
for (const bucket of trend) {
|
||||
if (bucket.created > max) max = bucket.created;
|
||||
@@ -1378,74 +1367,102 @@ function init(container) {
|
||||
}
|
||||
if (max === 0) max = 1;
|
||||
|
||||
let rowsHtml = '';
|
||||
// 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}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Bar groups
|
||||
let barsHtml = '';
|
||||
for (const bucket of trend) {
|
||||
const createdPct = Math.round((bucket.created / max) * 100);
|
||||
const closedPct = Math.round((bucket.closed / max) * 100);
|
||||
rowsHtml += `<div class="helpdesk-hbar-row">
|
||||
<span class="helpdesk-hbar-label">${esc(bucket.label)}</span>
|
||||
<div class="helpdesk-hbar-track">
|
||||
<div class="helpdesk-hbar helpdesk-hbar-created" style="width:${createdPct}%">
|
||||
<span class="helpdesk-hbar-value">${bucket.created}</span>
|
||||
</div>
|
||||
<div class="helpdesk-hbar helpdesk-hbar-closed" style="width:${closedPct}%">
|
||||
<span class="helpdesk-hbar-value">${bucket.closed}</span>
|
||||
</div>
|
||||
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"></div>
|
||||
<div class="helpdesk-vchart-bar helpdesk-vchart-bar-closed" style="height:${closedPct}%" data-tooltip="${bucket.closed}" data-tooltip-pos="top"></div>
|
||||
</div>
|
||||
<span class="helpdesk-vchart-label">${esc(bucket.label)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
chartEl.innerHTML = `
|
||||
<div class="helpdesk-hbar-chart">${rowsHtml}</div>
|
||||
<div class="helpdesk-hbar-legend">
|
||||
<span class="helpdesk-hbar-legend-item">
|
||||
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-created"></span>
|
||||
<div class="helpdesk-vchart-area">
|
||||
${gridHtml}
|
||||
<div class="helpdesk-vchart-groups">${barsHtml}</div>
|
||||
</div>
|
||||
<div class="helpdesk-vchart-legend">
|
||||
<span class="helpdesk-vchart-legend-item">
|
||||
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-created"></span>
|
||||
${esc(t('Created'))}
|
||||
</span>
|
||||
<span class="helpdesk-hbar-legend-item">
|
||||
<span class="helpdesk-hbar-legend-dot helpdesk-hbar-closed"></span>
|
||||
<span class="helpdesk-vchart-legend-item">
|
||||
<span class="helpdesk-vchart-legend-dot helpdesk-vchart-dot-closed"></span>
|
||||
${esc(t('Closed'))}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEfficiencyWidget(efficiency) {
|
||||
const el = document.getElementById('controlling-efficiency');
|
||||
if (!el) return;
|
||||
|
||||
const rows = [
|
||||
[t('Avg. resolution'), efficiency.resolution_hours != null ? fmtHours(efficiency.resolution_hours) : '—'],
|
||||
[t('Oldest open'), efficiency.oldest_open_hours != null ? fmtHours(efficiency.oldest_open_hours) : '—'],
|
||||
[t('Without assignee'), String(efficiency.unassigned_count ?? 0)],
|
||||
];
|
||||
|
||||
el.innerHTML = '<table class="helpdesk-controlling-kv-table">' +
|
||||
rows.map(([label, value]) => `<tr><td>${esc(label)}</td><td>${esc(value)}</td></tr>`).join('') +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function renderControllingRiskIndicators(indicators, summary) {
|
||||
const el = document.getElementById('controlling-risk-indicators');
|
||||
if (!el || !indicators.length) return;
|
||||
|
||||
const level = summary.level || 'low';
|
||||
const levelLabel = t(level.charAt(0).toUpperCase() + level.slice(1));
|
||||
const levelCls = level === 'high' ? 'helpdesk-kpi-trend-negative'
|
||||
: level === 'medium' ? 'helpdesk-kpi-trend-neutral'
|
||||
: 'helpdesk-kpi-trend-positive';
|
||||
// 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-controlling-risk-summary">
|
||||
<span class="${levelCls}">${esc(levelLabel)}</span>
|
||||
<span class="helpdesk-controlling-risk-summary-count">${summary.triggered ?? 0}/${summary.total ?? 0} ${esc(t('flags'))}</span>
|
||||
let html = `<div class="helpdesk-risk-header">
|
||||
<span class="helpdesk-risk-header-icon ${summaryCls}">${triggered === 0 ? '✓' : '⚠'}</span>
|
||||
<span class="helpdesk-risk-header-label">${esc(summaryLabel)}</span>
|
||||
</div>`;
|
||||
html += '<div class="helpdesk-controlling-risk-list">';
|
||||
html += '<div class="helpdesk-risk-checks">';
|
||||
for (const ind of indicators) {
|
||||
const dotCls = ind.triggered ? 'helpdesk-controlling-risk-dot-triggered' : 'helpdesk-controlling-risk-dot-ok';
|
||||
html += `<div class="helpdesk-controlling-risk-row">
|
||||
<span class="helpdesk-controlling-risk-dot ${dotCls}"></span>
|
||||
<span class="helpdesk-controlling-risk-label">${esc(t(ind.label) || ind.label)}</span>
|
||||
<span class="helpdesk-controlling-risk-value">${esc(ind.value)} / ${esc(ind.threshold)}</span>
|
||||
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}</span>
|
||||
<span class="helpdesk-risk-check-label">${esc(t(ind.label) || ind.label)}</span>
|
||||
</div>
|
||||
<div class="helpdesk-risk-check-value">${displayValue}</div>
|
||||
${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}%"></div>
|
||||
</div>
|
||||
<div class="helpdesk-risk-bar-limit" style="left:${limitPct}%"></div>
|
||||
</div>
|
||||
<div class="helpdesk-risk-bar-legend">${esc(limitLabel)}</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
Reference in New Issue
Block a user