|
- import '../styles/main.scss';
- import {
- Chart,
- BarController,
- BarElement,
- CategoryScale,
- LinearScale,
- Tooltip,
- } from 'chart.js';
- import { createTranslator } from './utils.js';
-
- Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip);
-
- const t = createTranslator('Statistics');
- const months = window.Statistics.monthsShort;
- const weekdays = window.Statistics.weekdaysShort;
-
- let chart = null;
- let cachedData = null;
-
- function formatLabel(key, groupBy) {
- if (groupBy === 'month') {
- const [year, month] = key.split('-');
- return months[parseInt(month, 10) - 1] + ' ' + year.slice(2);
- }
- if (groupBy === 'week') {
- const week = key.split('-')[1];
- return t('weekShort') + ' ' + parseInt(week, 10);
- }
- const d = new Date(key + 'T00:00:00');
- const dayOfWeek = weekdays[(d.getDay() + 6) % 7];
- const day = d.getDate();
- const mon = months[d.getMonth()];
- return dayOfWeek + ' ' + day + '. ' + mon;
- }
-
- function formatHours(value) {
- const h = Math.floor(value);
- const m = Math.round((value - h) * 60);
- return h + ':' + String(m).padStart(2, '0');
- }
-
- function formatCurrency(value) {
- return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
- }
-
- function getWeekNumber(dateStr) {
- const d = new Date(dateStr + 'T00:00:00');
- const dayNum = d.getDay() || 7;
- d.setDate(d.getDate() + 4 - dayNum);
- const yearStart = new Date(d.getFullYear(), 0, 1);
- return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
- }
-
- function getBrandColor() {
- return getComputedStyle(document.documentElement)
- .getPropertyValue('--color-primary').trim() || '#4a90d9';
- }
-
- function renderChart(data, metric) {
- const canvas = document.getElementById('stats-chart');
- if (!canvas) return;
-
- if (chart) {
- chart.destroy();
- chart = null;
- }
-
- const isRevenue = metric === 'revenue';
- const brandColor = getBrandColor();
- const greyColor = 'rgba(170, 184, 198, 0.55)';
- const labels = data.labels.map(l => formatLabel(l, data.groupBy));
-
- const dataset1 = isRevenue ? data.billableRevenue : data.billable;
- const dataset2 = isRevenue ? data.nonBillableRevenue : data.nonBillable;
-
- let weekBands = [];
- if (data.groupBy === 'day') {
- let currentWeek = null;
- let bandStart = null;
- let bandIdx = 0;
-
- data.labels.forEach((key, i) => {
- const wk = getWeekNumber(key);
- if (wk !== currentWeek) {
- if (currentWeek !== null && bandIdx % 2 === 1) {
- weekBands.push([bandStart, i - 1]);
- }
- currentWeek = wk;
- bandStart = i;
- bandIdx++;
- }
- });
- if (bandIdx % 2 === 1) {
- weekBands.push([bandStart, data.labels.length - 1]);
- }
- }
-
- chart = new Chart(canvas, {
- type: 'bar',
- data: {
- labels,
- datasets: [
- {
- label: t('billable'),
- data: dataset1,
- backgroundColor: brandColor,
- borderRadius: 3,
- borderSkipped: false,
- },
- {
- label: t('nonBillable'),
- data: dataset2,
- backgroundColor: greyColor,
- borderRadius: 3,
- borderSkipped: false,
- },
- ],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- animation: {
- duration: 600,
- easing: 'easeOutQuart',
- },
- interaction: {
- mode: 'index',
- intersect: false,
- },
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: '#1a2a3a',
- titleFont: { weight: '600' },
- bodyFont: { size: 13 },
- padding: 10,
- cornerRadius: 6,
- callbacks: {
- label(ctx) {
- if (isRevenue) {
- return ctx.dataset.label + ': ' + formatCurrency(ctx.parsed.y);
- }
- return ctx.dataset.label + ': ' + formatHours(ctx.parsed.y) + ' ' + t('hours');
- },
- },
- },
- },
- scales: {
- x: {
- grid: { display: false },
- ticks: {
- font: { size: 11 },
- maxRotation: data.groupBy === 'day' ? 45 : 0,
- autoSkip: true,
- autoSkipPadding: 8,
- },
- },
- y: {
- beginAtZero: true,
- grid: { color: 'rgba(0,0,0,0.06)' },
- ticks: {
- font: { size: 11 },
- callback(value) {
- if (isRevenue) return formatCurrency(value);
- return formatHours(value);
- },
- },
- },
- },
- },
- plugins: data.groupBy === 'day' ? [{
- id: 'weekBands',
- beforeDraw(chartInstance) {
- const { ctx, chartArea, scales } = chartInstance;
- if (!chartArea || weekBands.length === 0) return;
-
- ctx.save();
- ctx.fillStyle = 'rgba(0, 0, 0, 0.03)';
- weekBands.forEach(([start, end]) => {
- const x1 = scales.x.getPixelForValue(start) - (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2;
- const x2 = scales.x.getPixelForValue(end) + (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2;
- ctx.fillRect(x1, chartArea.top, x2 - x1, chartArea.bottom - chartArea.top);
- });
- ctx.restore();
- },
- }] : [],
- });
- }
-
- async function loadAndRender(range, metric, userId) {
- const wrap = document.getElementById('stats-chart-wrap');
- if (!wrap) return;
-
- let url = '/api/statistics?range=' + encodeURIComponent(range);
- if (userId) {
- url += '&userId=' + encodeURIComponent(userId);
- }
-
- try {
- const res = await fetch(url);
- if (!res.ok) throw new Error(res.statusText);
- cachedData = await res.json();
- renderChart(cachedData, metric);
- } catch {
- wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>';
- }
- }
-
- function getSelectedUserId() {
- const el = document.getElementById('stats-user-select');
- return el ? el.value : '';
- }
-
- document.addEventListener('DOMContentLoaded', () => {
- const rangeSelect = document.getElementById('stats-range-select');
- const metricSelect = document.getElementById('stats-metric-select');
- const userSelect = document.getElementById('stats-user-select');
- if (!rangeSelect || !metricSelect) return;
-
- loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
-
- rangeSelect.addEventListener('change', () => {
- loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
- });
-
- if (userSelect) {
- userSelect.addEventListener('change', () => {
- loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
- });
- }
-
- metricSelect.addEventListener('change', () => {
- if (cachedData) {
- renderChart(cachedData, metricSelect.value);
- }
- });
- });
|