Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 
 

239 linhas
7.6 KiB

  1. import '../styles/main.scss';
  2. import {
  3. Chart,
  4. BarController,
  5. BarElement,
  6. CategoryScale,
  7. LinearScale,
  8. Tooltip,
  9. } from 'chart.js';
  10. import { createTranslator } from './utils.js';
  11. Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip);
  12. const t = createTranslator('Statistics');
  13. const months = window.Statistics.monthsShort;
  14. const weekdays = window.Statistics.weekdaysShort;
  15. let chart = null;
  16. let cachedData = null;
  17. function formatLabel(key, groupBy) {
  18. if (groupBy === 'month') {
  19. const [year, month] = key.split('-');
  20. return months[parseInt(month, 10) - 1] + ' ' + year.slice(2);
  21. }
  22. if (groupBy === 'week') {
  23. const week = key.split('-')[1];
  24. return t('weekShort') + ' ' + parseInt(week, 10);
  25. }
  26. const d = new Date(key + 'T00:00:00');
  27. const dayOfWeek = weekdays[(d.getDay() + 6) % 7];
  28. const day = d.getDate();
  29. const mon = months[d.getMonth()];
  30. return dayOfWeek + ' ' + day + '. ' + mon;
  31. }
  32. function formatHours(value) {
  33. const h = Math.floor(value);
  34. const m = Math.round((value - h) * 60);
  35. return h + ':' + String(m).padStart(2, '0');
  36. }
  37. function formatCurrency(value) {
  38. return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
  39. }
  40. function getWeekNumber(dateStr) {
  41. const d = new Date(dateStr + 'T00:00:00');
  42. const dayNum = d.getDay() || 7;
  43. d.setDate(d.getDate() + 4 - dayNum);
  44. const yearStart = new Date(d.getFullYear(), 0, 1);
  45. return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
  46. }
  47. function getBrandColor() {
  48. return getComputedStyle(document.documentElement)
  49. .getPropertyValue('--color-primary').trim() || '#4a90d9';
  50. }
  51. function renderChart(data, metric) {
  52. const canvas = document.getElementById('stats-chart');
  53. if (!canvas) return;
  54. if (chart) {
  55. chart.destroy();
  56. chart = null;
  57. }
  58. const isRevenue = metric === 'revenue';
  59. const brandColor = getBrandColor();
  60. const greyColor = 'rgba(170, 184, 198, 0.55)';
  61. const labels = data.labels.map(l => formatLabel(l, data.groupBy));
  62. const dataset1 = isRevenue ? data.billableRevenue : data.billable;
  63. const dataset2 = isRevenue ? data.nonBillableRevenue : data.nonBillable;
  64. let weekBands = [];
  65. if (data.groupBy === 'day') {
  66. let currentWeek = null;
  67. let bandStart = null;
  68. let bandIdx = 0;
  69. data.labels.forEach((key, i) => {
  70. const wk = getWeekNumber(key);
  71. if (wk !== currentWeek) {
  72. if (currentWeek !== null && bandIdx % 2 === 1) {
  73. weekBands.push([bandStart, i - 1]);
  74. }
  75. currentWeek = wk;
  76. bandStart = i;
  77. bandIdx++;
  78. }
  79. });
  80. if (bandIdx % 2 === 1) {
  81. weekBands.push([bandStart, data.labels.length - 1]);
  82. }
  83. }
  84. chart = new Chart(canvas, {
  85. type: 'bar',
  86. data: {
  87. labels,
  88. datasets: [
  89. {
  90. label: t('billable'),
  91. data: dataset1,
  92. backgroundColor: brandColor,
  93. borderRadius: 3,
  94. borderSkipped: false,
  95. },
  96. {
  97. label: t('nonBillable'),
  98. data: dataset2,
  99. backgroundColor: greyColor,
  100. borderRadius: 3,
  101. borderSkipped: false,
  102. },
  103. ],
  104. },
  105. options: {
  106. responsive: true,
  107. maintainAspectRatio: false,
  108. animation: {
  109. duration: 600,
  110. easing: 'easeOutQuart',
  111. },
  112. interaction: {
  113. mode: 'index',
  114. intersect: false,
  115. },
  116. plugins: {
  117. legend: { display: false },
  118. tooltip: {
  119. backgroundColor: '#1a2a3a',
  120. titleFont: { weight: '600' },
  121. bodyFont: { size: 13 },
  122. padding: 10,
  123. cornerRadius: 6,
  124. callbacks: {
  125. label(ctx) {
  126. if (isRevenue) {
  127. return ctx.dataset.label + ': ' + formatCurrency(ctx.parsed.y);
  128. }
  129. return ctx.dataset.label + ': ' + formatHours(ctx.parsed.y) + ' ' + t('hours');
  130. },
  131. },
  132. },
  133. },
  134. scales: {
  135. x: {
  136. grid: { display: false },
  137. ticks: {
  138. font: { size: 11 },
  139. maxRotation: data.groupBy === 'day' ? 45 : 0,
  140. autoSkip: true,
  141. autoSkipPadding: 8,
  142. },
  143. },
  144. y: {
  145. beginAtZero: true,
  146. grid: { color: 'rgba(0,0,0,0.06)' },
  147. ticks: {
  148. font: { size: 11 },
  149. callback(value) {
  150. if (isRevenue) return formatCurrency(value);
  151. return formatHours(value);
  152. },
  153. },
  154. },
  155. },
  156. },
  157. plugins: data.groupBy === 'day' ? [{
  158. id: 'weekBands',
  159. beforeDraw(chartInstance) {
  160. const { ctx, chartArea, scales } = chartInstance;
  161. if (!chartArea || weekBands.length === 0) return;
  162. ctx.save();
  163. ctx.fillStyle = 'rgba(0, 0, 0, 0.03)';
  164. weekBands.forEach(([start, end]) => {
  165. const x1 = scales.x.getPixelForValue(start) - (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2;
  166. const x2 = scales.x.getPixelForValue(end) + (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2;
  167. ctx.fillRect(x1, chartArea.top, x2 - x1, chartArea.bottom - chartArea.top);
  168. });
  169. ctx.restore();
  170. },
  171. }] : [],
  172. });
  173. }
  174. async function loadAndRender(range, metric, userId) {
  175. const wrap = document.getElementById('stats-chart-wrap');
  176. if (!wrap) return;
  177. let url = '/api/statistics?range=' + encodeURIComponent(range);
  178. if (userId) {
  179. url += '&userId=' + encodeURIComponent(userId);
  180. }
  181. try {
  182. const res = await fetch(url);
  183. if (!res.ok) throw new Error(res.statusText);
  184. cachedData = await res.json();
  185. renderChart(cachedData, metric);
  186. } catch {
  187. wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>';
  188. }
  189. }
  190. function getSelectedUserId() {
  191. const el = document.getElementById('stats-user-select');
  192. return el ? el.value : '';
  193. }
  194. document.addEventListener('DOMContentLoaded', () => {
  195. const rangeSelect = document.getElementById('stats-range-select');
  196. const metricSelect = document.getElementById('stats-metric-select');
  197. const userSelect = document.getElementById('stats-user-select');
  198. if (!rangeSelect || !metricSelect) return;
  199. loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
  200. rangeSelect.addEventListener('change', () => {
  201. loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
  202. });
  203. if (userSelect) {
  204. userSelect.addEventListener('change', () => {
  205. loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
  206. });
  207. }
  208. metricSelect.addEventListener('change', () => {
  209. if (cachedData) {
  210. renderChart(cachedData, metricSelect.value);
  211. }
  212. });
  213. });