mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
419 lines
18 KiB
JavaScript
419 lines
18 KiB
JavaScript
/*
|
|
* ITFlow - reports (agent/reports/).
|
|
*
|
|
* The filters at the top of a report submit as soon as they change, except
|
|
* "Custom dates", which opens From and To. Charts are declared in the page as
|
|
* <canvas data-report-chart="{...}"> (reportChart() in functions/reports.php
|
|
* documents the settings) and drawn here in the page's own colours, so they
|
|
* follow the theme accent and dark mode. Tables with data-report-sort sort by
|
|
* any column whose heading is clicked. On the Reports page, typing in the
|
|
* search box narrows the list of reports.
|
|
*
|
|
* Loaded once per report page, after includes/footer.php.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ---------- filters ---------- */
|
|
|
|
document.querySelectorAll('[data-report-filters]').forEach(function (form) {
|
|
form.addEventListener('change', function (e) {
|
|
const field = e.target;
|
|
if (field.matches('[data-report-period]')) {
|
|
const custom = form.querySelector('[data-report-custom]');
|
|
const isCustom = field.value === 'custom';
|
|
if (custom) {
|
|
custom.hidden = !isCustom;
|
|
custom.querySelectorAll('input').forEach(function (input) {
|
|
input.disabled = !isCustom;
|
|
});
|
|
}
|
|
if (isCustom) {
|
|
const from = custom && custom.querySelector('input');
|
|
if (from) {
|
|
from.focus();
|
|
}
|
|
return;
|
|
}
|
|
form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
return;
|
|
}
|
|
if (field.matches('[data-report-autosubmit]')) {
|
|
form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ---------- colours and numbers ---------- */
|
|
|
|
const style = getComputedStyle(document.body);
|
|
function cssVar(name, fallback) {
|
|
return (style.getPropertyValue(name) || '').trim() || fallback;
|
|
}
|
|
const tones = {
|
|
accent: cssVar('--itflow-accent', '#007bff'),
|
|
success: cssVar('--bs-success', '#198754'),
|
|
danger: cssVar('--bs-danger', '#dc3545'),
|
|
warning: cssVar('--bs-warning', '#ffc107'),
|
|
info: cssVar('--bs-info', '#0dcaf0'),
|
|
muted: cssVar('--bs-secondary-color', '#6c757d'),
|
|
faint: cssVar('--bs-tertiary-color', '#adb5bd'),
|
|
purple: cssVar('--bs-purple', '#6f42c1'),
|
|
teal: cssVar('--bs-teal', '#20c997'),
|
|
orange: cssVar('--bs-orange', '#fd7e14'),
|
|
indigo: cssVar('--bs-indigo', '#6610f2'),
|
|
pink: cssVar('--bs-pink', '#d63384'),
|
|
dark: cssVar('--bs-emphasis-color', '#212529')
|
|
};
|
|
function tone(name) {
|
|
if (typeof name === 'string' && /^#[0-9a-f]{3,8}$/i.test(name)) {
|
|
return name;
|
|
}
|
|
return tones[name] || tones.accent;
|
|
}
|
|
function alpha(color, a) {
|
|
let m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color);
|
|
if (m) {
|
|
return 'rgba(' + parseInt(m[1], 16) + ',' + parseInt(m[2], 16) + ',' + parseInt(m[3], 16) + ',' + a + ')';
|
|
}
|
|
m = /^rgba?\(([^)]+)\)$/i.exec(color);
|
|
if (m) {
|
|
const parts = m[1].split(',').slice(0, 3);
|
|
return 'rgba(' + parts.join(',') + ',' + a + ')';
|
|
}
|
|
return color;
|
|
}
|
|
|
|
// The legend swatches beside a doughnut take the same colours as its slices
|
|
document.querySelectorAll('[data-report-swatch]').forEach(function (swatch) {
|
|
swatch.style.backgroundColor = tone(swatch.dataset.reportSwatch);
|
|
});
|
|
|
|
function formatter(format, currency, compact) {
|
|
let money;
|
|
try {
|
|
money = new Intl.NumberFormat(undefined, compact
|
|
? { style: 'currency', currency: currency || 'USD', notation: 'compact', maximumFractionDigits: 1 }
|
|
: { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0 });
|
|
} catch (e) {
|
|
money = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
|
|
}
|
|
const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
|
|
return function (value) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return '';
|
|
}
|
|
switch (format) {
|
|
case 'money':
|
|
return money.format(value);
|
|
case 'hours':
|
|
return number.format(value) + ' h';
|
|
case 'percent':
|
|
return number.format(value) + '%';
|
|
case 'count':
|
|
return Number.isInteger(value) ? value.toLocaleString() : (compact ? '' : number.format(value));
|
|
default:
|
|
return number.format(value);
|
|
}
|
|
};
|
|
}
|
|
|
|
/* ---------- charts ---------- */
|
|
|
|
const charts = [];
|
|
|
|
function drawChart(canvas) {
|
|
if (typeof Chart === 'undefined') {
|
|
return;
|
|
}
|
|
let config;
|
|
try {
|
|
config = JSON.parse(canvas.dataset.reportChart);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
const base = typeof itflowChartDefaults === 'function' ? itflowChartDefaults() : { grid: 'rgba(0,0,0,.1)' };
|
|
const type = config.type || 'bar';
|
|
const format = config.format || 'count';
|
|
const tick = formatter(format, config.currency, true);
|
|
const full = formatter(format, config.currency, false);
|
|
const tick2 = formatter(config.format2 || format, config.currency, true);
|
|
const full2 = formatter(config.format2 || format, config.currency, false);
|
|
const horizontal = !!config.horizontal;
|
|
const showLegend = config.legend !== undefined ? !!config.legend : (type !== 'doughnut' && (config.series || []).length > 1);
|
|
|
|
let datasets;
|
|
if (type === 'doughnut') {
|
|
const series = (config.series || [])[0] || { data: [] };
|
|
const colors = (config.colors || []).map(tone);
|
|
datasets = [{
|
|
label: series.label || '',
|
|
data: series.data,
|
|
backgroundColor: series.data.map(function (v, i) { return colors[i] || tone(['accent', 'info', 'success', 'warning', 'purple', 'danger'][i % 6]); }),
|
|
borderColor: cssVar('--bs-body-bg', '#fff'),
|
|
borderWidth: 2
|
|
}];
|
|
} else {
|
|
datasets = (config.series || []).map(function (s, i) {
|
|
const color = tone(s.tone || ['accent', 'success', 'info', 'warning', 'purple', 'danger'][i % 6]);
|
|
const line = s.type === 'line' || type === 'line';
|
|
const set = {
|
|
label: s.label,
|
|
data: s.data,
|
|
type: s.type || undefined,
|
|
yAxisID: s.axis === 'y2' ? 'y2' : (horizontal ? undefined : 'y'),
|
|
xAxisID: horizontal ? 'x' : undefined,
|
|
stack: s.stack,
|
|
hidden: !!s.hidden,
|
|
order: line ? 0 : 1
|
|
};
|
|
if (line) {
|
|
Object.assign(set, {
|
|
borderColor: color,
|
|
backgroundColor: s.fill ? alpha(color, 0.15) : color,
|
|
fill: !!s.fill,
|
|
borderWidth: 2,
|
|
// Monotone curves never swing past the points - a share stays within 0-100%
|
|
cubicInterpolationMode: 'monotone',
|
|
pointRadius: s.data.length > 40 ? 0 : 2,
|
|
pointHoverRadius: 4,
|
|
borderDash: s.dashed ? [5, 4] : undefined,
|
|
spanGaps: true
|
|
});
|
|
} else if (s.ghost) {
|
|
Object.assign(set, {
|
|
backgroundColor: alpha(color, 0.22),
|
|
borderColor: alpha(color, 0.8),
|
|
borderWidth: 1.5,
|
|
borderRadius: 3,
|
|
maxBarThickness: 28
|
|
});
|
|
} else {
|
|
Object.assign(set, {
|
|
// A colour per bar when the series has one (tones), e.g. red where below target
|
|
backgroundColor: Array.isArray(s.tones) ? s.tones.map(tone) : color,
|
|
borderRadius: 3,
|
|
maxBarThickness: horizontal ? 18 : 28
|
|
});
|
|
}
|
|
return set;
|
|
});
|
|
}
|
|
|
|
const valueAxis = {
|
|
beginAtZero: true,
|
|
stacked: !!config.stacked,
|
|
grid: { color: base.grid },
|
|
border: { display: false },
|
|
ticks: { maxTicksLimit: 5, callback: function (v) { return tick(v); } }
|
|
};
|
|
if (config.max !== undefined) {
|
|
valueAxis.max = config.max;
|
|
}
|
|
const categoryAxis = {
|
|
stacked: !!config.stacked,
|
|
grid: { display: false },
|
|
ticks: { maxRotation: 0, autoSkipPadding: 10 }
|
|
};
|
|
if (horizontal) {
|
|
categoryAxis.ticks = { autoSkip: false, callback: function (v) { const l = this.getLabelForValue(v); return l && l.length > 28 ? l.slice(0, 27) + '…' : l; } };
|
|
}
|
|
|
|
const options = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: type === 'doughnut' ? 'nearest' : 'index', intersect: type === 'doughnut' },
|
|
plugins: {
|
|
legend: { display: showLegend, position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true, padding: 14 } },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function (c) {
|
|
const value = horizontal ? c.parsed.x : (type === 'doughnut' ? c.parsed : c.parsed.y);
|
|
const shown = c.dataset.yAxisID === 'y2' ? full2(value) : full(value);
|
|
if (type === 'doughnut') {
|
|
const total = c.dataset.data.reduce(function (a, b) { return a + b; }, 0);
|
|
return ' ' + c.label + ': ' + shown + (total ? ' (' + Math.round(value / total * 100) + '%)' : '');
|
|
}
|
|
return ' ' + (c.dataset.label ? c.dataset.label + ': ' : '') + shown;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if (type === 'doughnut') {
|
|
options.cutout = '68%';
|
|
} else if (horizontal) {
|
|
options.indexAxis = 'y';
|
|
options.scales = { x: valueAxis, y: categoryAxis };
|
|
} else {
|
|
options.scales = { x: categoryAxis, y: valueAxis };
|
|
if (datasets.some(function (d) { return d.yAxisID === 'y2'; })) {
|
|
options.scales.y2 = {
|
|
position: 'right',
|
|
beginAtZero: true,
|
|
grid: { display: false },
|
|
border: { display: false },
|
|
ticks: { maxTicksLimit: 5, callback: function (v) { return tick2(v); } }
|
|
};
|
|
if (config.max2 !== undefined) {
|
|
options.scales.y2.max = config.max2;
|
|
}
|
|
}
|
|
}
|
|
|
|
charts.push(new Chart(canvas, {
|
|
type: type === 'line' ? 'line' : (type === 'doughnut' ? 'doughnut' : 'bar'),
|
|
data: { labels: config.labels || [], datasets: datasets },
|
|
options: options
|
|
}));
|
|
}
|
|
|
|
document.querySelectorAll('canvas[data-report-chart]').forEach(drawChart);
|
|
|
|
// A printed page is narrower than the screen - fit the charts to it, and back
|
|
window.addEventListener('beforeprint', function () {
|
|
charts.forEach(function (chart) { chart.resize(); });
|
|
});
|
|
window.addEventListener('afterprint', function () {
|
|
charts.forEach(function (chart) { chart.resize(); });
|
|
});
|
|
|
|
/* ---------- sortable tables ---------- */
|
|
|
|
document.querySelectorAll('table[data-report-sort]').forEach(function (table) {
|
|
const heads = table.querySelectorAll('thead th');
|
|
heads.forEach(function (th, index) {
|
|
if (!th.dataset.sort) {
|
|
return;
|
|
}
|
|
th.tabIndex = 0;
|
|
th.classList.add('itflow-report-sortable');
|
|
function sort() {
|
|
const tbody = table.tBodies[0];
|
|
const rows = Array.from(tbody.rows).filter(function (r) { return r.cells.length === heads.length; });
|
|
if (rows.length < 2) {
|
|
return;
|
|
}
|
|
const ascending = th.getAttribute('aria-sort') !== 'ascending' && (th.getAttribute('aria-sort') === 'descending' || th.dataset.sort === 'text');
|
|
heads.forEach(function (h) { h.removeAttribute('aria-sort'); });
|
|
th.setAttribute('aria-sort', ascending ? 'ascending' : 'descending');
|
|
const numeric = th.dataset.sort === 'num';
|
|
function key(row) {
|
|
const cell = row.cells[index];
|
|
const raw = cell.dataset.value !== undefined ? cell.dataset.value : cell.textContent.trim().toLowerCase();
|
|
if (numeric) {
|
|
return raw === '' ? null : parseFloat(raw);
|
|
}
|
|
return raw;
|
|
}
|
|
rows.sort(function (a, b) {
|
|
const ka = key(a);
|
|
const kb = key(b);
|
|
// Blanks go last whichever way it is sorted
|
|
if (ka === null || ka === '') { return 1; }
|
|
if (kb === null || kb === '') { return -1; }
|
|
const cmp = numeric ? ka - kb : String(ka).localeCompare(String(kb), undefined, { numeric: true });
|
|
return ascending ? cmp : -cmp;
|
|
});
|
|
rows.forEach(function (row) { tbody.appendChild(row); });
|
|
}
|
|
th.addEventListener('click', sort);
|
|
th.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
sort();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ---------- the budget editor ---------- */
|
|
|
|
const budget = document.querySelector('[data-budget-form]');
|
|
if (budget) {
|
|
let money;
|
|
try {
|
|
money = new Intl.NumberFormat(undefined, { style: 'currency', currency: budget.dataset.currency || 'USD' });
|
|
} catch (e) {
|
|
money = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
const amount = function (input) {
|
|
const value = parseFloat(String(input.value).replace(/,/g, ''));
|
|
return isNaN(value) ? 0 : value;
|
|
};
|
|
const totals = function () {
|
|
const months = {};
|
|
let all = 0;
|
|
budget.querySelectorAll('[data-budget-row]').forEach(function (row) {
|
|
let sum = 0;
|
|
row.querySelectorAll('[data-budget-cell]').forEach(function (cell, i) {
|
|
const value = amount(cell);
|
|
sum += value;
|
|
months[i + 1] = (months[i + 1] || 0) + value;
|
|
});
|
|
all += sum;
|
|
row.querySelector('[data-budget-row-total]').textContent = money.format(sum);
|
|
});
|
|
budget.querySelectorAll('[data-budget-month-total]').forEach(function (cell) {
|
|
cell.textContent = money.format(months[cell.dataset.budgetMonthTotal] || 0);
|
|
});
|
|
const total = budget.querySelector('[data-budget-total]');
|
|
if (total) {
|
|
total.textContent = money.format(all);
|
|
}
|
|
};
|
|
budget.addEventListener('input', function (e) {
|
|
if (e.target.matches('[data-budget-cell]')) {
|
|
totals();
|
|
}
|
|
});
|
|
budget.addEventListener('click', function (e) {
|
|
const fill = e.target.closest('[data-budget-fill]');
|
|
if (!fill) {
|
|
return;
|
|
}
|
|
const row = fill.closest('[data-budget-row]');
|
|
const source = row.querySelector('[data-budget-fill-value]');
|
|
const value = amount(source);
|
|
row.querySelectorAll('[data-budget-cell]').forEach(function (cell) {
|
|
cell.value = value > 0 ? value.toFixed(2) : '';
|
|
});
|
|
source.value = '';
|
|
totals();
|
|
});
|
|
// Enter in "every month" fills the row instead of saving the form
|
|
budget.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && e.target.matches('[data-budget-fill-value]')) {
|
|
e.preventDefault();
|
|
e.target.closest('[data-budget-row]').querySelector('[data-budget-fill]').click();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- finding a report ---------- */
|
|
|
|
const find = document.querySelector('[data-report-find]');
|
|
if (find) {
|
|
find.addEventListener('input', function () {
|
|
const words = find.value.toLowerCase().split(/\s+/).filter(Boolean);
|
|
let shown = 0;
|
|
document.querySelectorAll('[data-report-group]').forEach(function (group) {
|
|
let inGroup = 0;
|
|
group.querySelectorAll('[data-report-card]').forEach(function (card) {
|
|
const text = card.dataset.reportText || '';
|
|
const match = words.every(function (w) { return text.indexOf(w) !== -1; });
|
|
card.hidden = !match;
|
|
inGroup += match ? 1 : 0;
|
|
});
|
|
group.hidden = inGroup === 0;
|
|
shown += inGroup;
|
|
});
|
|
const none = document.querySelector('[data-report-none]');
|
|
if (none) {
|
|
none.hidden = shown > 0;
|
|
}
|
|
});
|
|
}
|
|
})();
|