Você não pode selecionar mais de 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.
 
 
 
 
 

357 linhas
12 KiB

  1. import '../styles/main.scss';
  2. import {
  3. Chart,
  4. BarController,
  5. BarElement,
  6. CategoryScale,
  7. LinearScale,
  8. Tooltip,
  9. DoughnutController,
  10. ArcElement,
  11. } from 'chart.js';
  12. import { createTranslator, esc } from './utils.js';
  13. Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, DoughnutController, ArcElement);
  14. const t = createTranslator('Statistics');
  15. const months = window.Statistics.monthsShort;
  16. const weekdays = window.Statistics.weekdaysShort;
  17. const DONUT_COLORS = [
  18. '#4a90d9', '#f0a500', '#2d9e60', '#c83232',
  19. '#8b5cf6', '#e85d75', '#00b4d8', '#ff8c42',
  20. ];
  21. const REST_COLOR = '#d0d8e0';
  22. const THRESHOLD = 0.05;
  23. let chart = null;
  24. let donutCharts = { clients: null, projects: null, services: null };
  25. let cachedData = null;
  26. function formatLabel(key, groupBy) {
  27. if (groupBy === 'month') {
  28. const [year, month] = key.split('-');
  29. return months[parseInt(month, 10) - 1] + ' ' + year.slice(2);
  30. }
  31. if (groupBy === 'week') {
  32. const week = key.split('-')[1];
  33. return t('weekShort') + ' ' + parseInt(week, 10);
  34. }
  35. const d = new Date(key + 'T00:00:00');
  36. const dayOfWeek = weekdays[(d.getDay() + 6) % 7];
  37. const day = d.getDate();
  38. const mon = months[d.getMonth()];
  39. return dayOfWeek + ' ' + day + '. ' + mon;
  40. }
  41. function formatHours(value) {
  42. const h = Math.floor(value);
  43. const m = Math.round((value - h) * 60);
  44. return h + ':' + String(m).padStart(2, '0');
  45. }
  46. function formatCurrency(value) {
  47. return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
  48. }
  49. function getWeekNumber(dateStr) {
  50. const d = new Date(dateStr + 'T00:00:00');
  51. const dayNum = d.getDay() || 7;
  52. d.setDate(d.getDate() + 4 - dayNum);
  53. const yearStart = new Date(d.getFullYear(), 0, 1);
  54. return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
  55. }
  56. function getBrandColor() {
  57. return getComputedStyle(document.documentElement)
  58. .getPropertyValue('--color-primary').trim() || '#4a90d9';
  59. }
  60. function renderChart(data, metric) {
  61. const canvas = document.getElementById('stats-chart');
  62. if (!canvas) return;
  63. if (chart) {
  64. chart.destroy();
  65. chart = null;
  66. }
  67. const isRevenue = metric === 'revenue';
  68. const brandColor = getBrandColor();
  69. const greyColor = 'rgba(170, 184, 198, 0.55)';
  70. const labels = data.labels.map(l => formatLabel(l, data.groupBy));
  71. const dataset1 = isRevenue ? data.billableRevenue : data.billable;
  72. const dataset2 = isRevenue ? data.nonBillableRevenue : data.nonBillable;
  73. let weekBands = [];
  74. if (data.groupBy === 'day') {
  75. let currentWeek = null;
  76. let bandStart = null;
  77. let bandIdx = 0;
  78. data.labels.forEach((key, i) => {
  79. const wk = getWeekNumber(key);
  80. if (wk !== currentWeek) {
  81. if (currentWeek !== null && bandIdx % 2 === 1) {
  82. weekBands.push([bandStart, i - 1]);
  83. }
  84. currentWeek = wk;
  85. bandStart = i;
  86. bandIdx++;
  87. }
  88. });
  89. if (bandIdx % 2 === 1) {
  90. weekBands.push([bandStart, data.labels.length - 1]);
  91. }
  92. }
  93. chart = new Chart(canvas, {
  94. type: 'bar',
  95. data: {
  96. labels,
  97. datasets: [
  98. {
  99. label: t('billable'),
  100. data: dataset1,
  101. backgroundColor: brandColor,
  102. borderRadius: 3,
  103. borderSkipped: false,
  104. },
  105. {
  106. label: t('nonBillable'),
  107. data: dataset2,
  108. backgroundColor: greyColor,
  109. borderRadius: 3,
  110. borderSkipped: false,
  111. },
  112. ],
  113. },
  114. options: {
  115. responsive: true,
  116. maintainAspectRatio: false,
  117. animation: {
  118. duration: 600,
  119. easing: 'easeOutQuart',
  120. },
  121. interaction: {
  122. mode: 'index',
  123. intersect: false,
  124. },
  125. plugins: {
  126. legend: { display: false },
  127. tooltip: {
  128. backgroundColor: '#1a2a3a',
  129. titleFont: { weight: '600' },
  130. bodyFont: { size: 13 },
  131. padding: 10,
  132. cornerRadius: 6,
  133. callbacks: {
  134. label(ctx) {
  135. if (isRevenue) {
  136. return ctx.dataset.label + ': ' + formatCurrency(ctx.parsed.y);
  137. }
  138. return ctx.dataset.label + ': ' + formatHours(ctx.parsed.y) + ' ' + t('hours');
  139. },
  140. },
  141. },
  142. },
  143. scales: {
  144. x: {
  145. grid: { display: false },
  146. ticks: {
  147. font: { size: 11 },
  148. maxRotation: data.groupBy === 'day' ? 45 : 0,
  149. autoSkip: true,
  150. autoSkipPadding: 8,
  151. },
  152. },
  153. y: {
  154. beginAtZero: true,
  155. grid: { color: 'rgba(0,0,0,0.06)' },
  156. ticks: {
  157. font: { size: 11 },
  158. callback(value) {
  159. if (isRevenue) return formatCurrency(value);
  160. return formatHours(value);
  161. },
  162. },
  163. },
  164. },
  165. },
  166. plugins: data.groupBy === 'day' ? [{
  167. id: 'weekBands',
  168. beforeDraw(chartInstance) {
  169. const { ctx, chartArea, scales } = chartInstance;
  170. if (!chartArea || weekBands.length === 0) return;
  171. ctx.save();
  172. ctx.fillStyle = 'rgba(0, 0, 0, 0.03)';
  173. weekBands.forEach(([start, end]) => {
  174. const x1 = scales.x.getPixelForValue(start) - (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2;
  175. const x2 = scales.x.getPixelForValue(end) + (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2;
  176. ctx.fillRect(x1, chartArea.top, x2 - x1, chartArea.bottom - chartArea.top);
  177. });
  178. ctx.restore();
  179. },
  180. }] : [],
  181. });
  182. }
  183. function applyThreshold(items, metric) {
  184. const valueKey = metric === 'revenue' ? 'revenue' : 'hours';
  185. const total = items.reduce((sum, item) => sum + item[valueKey], 0);
  186. if (total === 0) return { labels: [], values: [], colors: [] };
  187. const visible = [];
  188. let restValue = 0;
  189. let colorIdx = 0;
  190. for (const item of items) {
  191. const val = item[valueKey];
  192. if (val / total >= THRESHOLD) {
  193. visible.push({ name: item.name || t('noService'), value: val, color: DONUT_COLORS[colorIdx % DONUT_COLORS.length] });
  194. colorIdx++;
  195. } else {
  196. restValue += val;
  197. }
  198. }
  199. if (restValue > 0) {
  200. visible.push({ name: t('rest'), value: restValue, color: REST_COLOR });
  201. }
  202. return {
  203. labels: visible.map(v => v.name),
  204. values: visible.map(v => v.value),
  205. colors: visible.map(v => v.color),
  206. };
  207. }
  208. function renderDonut(canvasId, legendId, chartKey, items, metric) {
  209. const canvas = document.getElementById(canvasId);
  210. const legendEl = document.getElementById(legendId);
  211. if (!canvas) return;
  212. if (donutCharts[chartKey]) {
  213. donutCharts[chartKey].destroy();
  214. donutCharts[chartKey] = null;
  215. }
  216. const { labels, values, colors } = applyThreshold(items, metric);
  217. const isRevenue = metric === 'revenue';
  218. const total = values.reduce((s, v) => s + v, 0);
  219. if (total === 0) {
  220. if (legendEl) legendEl.innerHTML = '';
  221. return;
  222. }
  223. donutCharts[chartKey] = new Chart(canvas, {
  224. type: 'doughnut',
  225. data: {
  226. labels,
  227. datasets: [{
  228. data: values,
  229. backgroundColor: colors,
  230. borderWidth: 2,
  231. borderColor: '#ffffff',
  232. }],
  233. },
  234. options: {
  235. responsive: true,
  236. maintainAspectRatio: false,
  237. cutout: '62%',
  238. animation: { duration: 600, easing: 'easeOutQuart' },
  239. plugins: {
  240. legend: { display: false },
  241. tooltip: {
  242. backgroundColor: '#1a2a3a',
  243. bodyFont: { size: 13 },
  244. padding: 10,
  245. cornerRadius: 6,
  246. callbacks: {
  247. label(ctx) {
  248. const val = ctx.parsed;
  249. const pct = ((val / total) * 100).toFixed(1) + '%';
  250. const formatted = isRevenue ? formatCurrency(val) : formatHours(val);
  251. return ctx.label + ': ' + formatted + ' (' + pct + ')';
  252. },
  253. },
  254. },
  255. },
  256. },
  257. });
  258. if (legendEl) {
  259. legendEl.innerHTML = labels.map((label, i) => {
  260. const pct = ((values[i] / total) * 100).toFixed(1);
  261. const formatted = isRevenue ? formatCurrency(values[i]) : formatHours(values[i]);
  262. return `<div class="statistics-donut__legend-item">
  263. <span class="statistics-donut__legend-dot" style="background:${colors[i]}"></span>
  264. <span class="statistics-donut__legend-label">${esc(label)}</span>
  265. <span class="statistics-donut__legend-value">${formatted} (${pct}%)</span>
  266. </div>`;
  267. }).join('');
  268. }
  269. }
  270. function renderDonuts(data, metric) {
  271. if (!data?.distribution) return;
  272. const d = data.distribution;
  273. renderDonut('donut-clients', 'donut-legend-clients', 'clients', d.clients, metric);
  274. renderDonut('donut-projects', 'donut-legend-projects', 'projects', d.projects, metric);
  275. renderDonut('donut-services', 'donut-legend-services', 'services', d.services, metric);
  276. }
  277. async function loadAndRender(range, metric, userId) {
  278. const wrap = document.getElementById('stats-chart-wrap');
  279. if (!wrap) return;
  280. let url = '/api/statistics?range=' + encodeURIComponent(range);
  281. if (userId) {
  282. url += '&userId=' + encodeURIComponent(userId);
  283. }
  284. try {
  285. const res = await fetch(url);
  286. if (!res.ok) throw new Error(res.statusText);
  287. cachedData = await res.json();
  288. renderChart(cachedData, metric);
  289. renderDonuts(cachedData, metric);
  290. } catch {
  291. wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>';
  292. }
  293. }
  294. function getSelectedUserId() {
  295. const el = document.getElementById('stats-user-select');
  296. return el ? el.value : '';
  297. }
  298. document.addEventListener('DOMContentLoaded', () => {
  299. const rangeSelect = document.getElementById('stats-range-select');
  300. const metricSelect = document.getElementById('stats-metric-select');
  301. const userSelect = document.getElementById('stats-user-select');
  302. if (!rangeSelect || !metricSelect) return;
  303. loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
  304. rangeSelect.addEventListener('change', () => {
  305. loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
  306. });
  307. if (userSelect) {
  308. userSelect.addEventListener('change', () => {
  309. loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId());
  310. });
  311. }
  312. metricSelect.addEventListener('change', () => {
  313. if (cachedData) {
  314. renderChart(cachedData, metricSelect.value);
  315. renderDonuts(cachedData, metricSelect.value);
  316. }
  317. });
  318. });