reports.blade.php 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287
  1. {{-- resources/views/livewire/reports.blade.php --}}
  2. <div>
  3. <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  4. <link rel="stylesheet" href="/css/chart-reports.css">
  5. <div class="dashboard-container">
  6. <div class="chart-row" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start;">
  7. <div class="chart-card">
  8. <div class="chart-header">
  9. <h3 class="chart-title">Entrate/Uscite totali</h3>
  10. </div>
  11. <div class="chart-body">
  12. <div class="yearly-table-container" id="yearly-table"></div>
  13. </div>
  14. </div>
  15. <div class="chart-card">
  16. <div class="chart-header">
  17. <h3 class="chart-title">Tesserati per Stagione</h3>
  18. </div>
  19. <div class="chart-body">
  20. <div class="members-table-container" id="members-table"></div>
  21. </div>
  22. </div>
  23. </div>
  24. <div class="controls-section">
  25. <div class="control-group">
  26. <label for="season-filter">Stagione di Riferimento:</label>
  27. <select class="form-select" wire:model="seasonFilter" wire:change="updateCharts">
  28. @foreach($this->getAvailableSeasons() as $season)
  29. <option value="{{ $season }}">{{ $season }}</option>
  30. @endforeach
  31. </select>
  32. </div>
  33. </div>
  34. @php
  35. $summary = $this->getYearlySummary();
  36. @endphp
  37. <div class="summary-cards">
  38. <div class="summary-card income">
  39. <h3>Entrate Totali</h3>
  40. <div class="value">€{{ number_format($summary['totalIncome'], 2, ',', '.') }}</div>
  41. </div>
  42. <div class="summary-card expense">
  43. <h3>Uscite Totali</h3>
  44. <div class="value">€{{ number_format($summary['totalExpenses'], 2, ',', '.') }}</div>
  45. </div>
  46. <div class="summary-card delta {{ $summary['delta'] < 0 ? 'negative' : '' }}">
  47. <h3>Bilancio Netto</h3>
  48. <div class="value">€{{ number_format($summary['delta'], 2, ',', '.') }}</div>
  49. </div>
  50. </div>
  51. <div wire:ignore>
  52. <div class="chart-row">
  53. <div class="chart-card">
  54. <div class="chart-header">
  55. <h3 class="chart-title">Entrate e Uscite Mensili - <span id="monthly-season-title">{{ $seasonFilter }}</span></h3>
  56. </div>
  57. <div class="chart-body">
  58. <div style="display: grid; grid-template-columns: 1fr 300px; align-items: start;">
  59. <div class="chart-container">
  60. <canvas id="monthly-chart-{{ str_replace('-', '', $seasonFilter) }}"></canvas>
  61. </div>
  62. <div class="monthly-table-container" id="monthly-table">
  63. </div>
  64. </div>
  65. </div>
  66. </div>
  67. </div>
  68. <div class="chart-row">
  69. <div class="chart-card">
  70. <div class="chart-header">
  71. <h3 class="chart-title">Redditività per Causale - <span id="causals-season-title">{{ $seasonFilter }}</span></h3>
  72. </div>
  73. <div class="chart-body">
  74. <div style="display: grid; grid-template-columns: 1fr 700px; gap: 1rem; align-items: start;">
  75. <div class="causals-table-container" id="causals-table">
  76. </div>
  77. <div class="chart-container">
  78. <canvas id="causals-chart-{{ str_replace('-', '', $seasonFilter) }}"></canvas>
  79. </div>
  80. </div>
  81. </div>
  82. </div>
  83. </div>
  84. <div class="chart-row">
  85. <div class="chart-card">
  86. <div class="chart-header">
  87. <h3 class="chart-title">Tesserati per Stagione</h3>
  88. </div>
  89. <div class="chart-body">
  90. <div style="display: grid; grid-template-columns: 1fr 500px; gap: 1rem; align-items: start;">
  91. <div class="chart-container">
  92. <canvas id="members-chart"></canvas>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. </div>
  98. </div>
  99. {{-- @if(false) --}}
  100. <div class="chart-row">
  101. <div class="chart-card modern-course-card">
  102. <div class="chart-header">
  103. <h3 class="chart-title">Analisi Corsi</h3>
  104. </div>
  105. <div class="chart-body">
  106. <div class="course-controls">
  107. <div class="control-group">
  108. <label>Seleziona Corso ({{ $seasonFilter }}):</label>
  109. <select class="form-select modern-select" wire:model.live="selectedCourse">
  110. <option value="">Seleziona un Corso</option>
  111. @foreach($this->getCoursesForSelect() as $course)
  112. <option value="{{ $course['id'] }}">{{ $course['full_name'] }}</option>
  113. @endforeach
  114. </select>
  115. </div>
  116. </div>
  117. @if($selectedCourse)
  118. <div wire:ignore wire:key="course-chart-{{ $seasonFilter }}-{{ $selectedCourse }}">
  119. <div class="modern-chart-layout">
  120. <div class="course-delta-table" id="course-delta-table-{{ str_replace('-', '', $seasonFilter) }}-{{ $selectedCourse }}">
  121. </div>
  122. <div class="modern-chart-container">
  123. <canvas id="courses-chart-{{ str_replace('-', '', $seasonFilter) }}-{{ $selectedCourse }}"></canvas>
  124. </div>
  125. </div>
  126. </div>
  127. @else
  128. <div class="chart-placeholder">
  129. <div style="text-align: center;">
  130. <div style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.3;">📊</div>
  131. <p style="font-size: 1.25rem; font-weight: 600; margin: 0;">Seleziona un corso per
  132. visualizzare il grafico</p>
  133. <p style="font-size: 1rem; opacity: 0.7; margin-top: 0.5rem;">Usa il menu a tendina sopra
  134. per scegliere un corso</p>
  135. </div>
  136. </div>
  137. @endif
  138. </div>
  139. </div>
  140. </div>
  141. {{-- @endif --}}
  142. </div>
  143. <script>
  144. window.ReportsChartManager = window.ReportsChartManager || {
  145. charts: {},
  146. currentSeason: null,
  147. destroyChart: function (chartId) {
  148. if (this.charts[chartId]) {
  149. this.charts[chartId].destroy();
  150. delete this.charts[chartId];
  151. }
  152. },
  153. destroyAllCharts: function () {
  154. Object.keys(this.charts).forEach(chartId => {
  155. this.destroyChart(chartId);
  156. });
  157. },
  158. destroySeasonCharts: function (oldSeasonKey) {
  159. const chartsToDestroy = Object.keys(this.charts).filter(chartId =>
  160. chartId.includes(oldSeasonKey)
  161. );
  162. chartsToDestroy.forEach(chartId => this.destroyChart(chartId));
  163. },
  164. updateMainCharts: function () {
  165. console.log('=== updateMainCharts called ===');
  166. const seasonFilter = @this.get('seasonFilter');
  167. const monthlyTitle = document.getElementById('monthly-season-title');
  168. const causalsTitle = document.getElementById('causals-season-title');
  169. if (monthlyTitle) {
  170. monthlyTitle.textContent = seasonFilter;
  171. }
  172. if (causalsTitle) {
  173. causalsTitle.textContent = seasonFilter;
  174. }
  175. const originalSeasonKey = '{{ str_replace('-', '', $seasonFilter) }}';
  176. console.log('Using original season key for canvas IDs:', originalSeasonKey);
  177. @this.call('getMonthlyTotals').then(monthlyData => {
  178. console.log('Got monthly data:', monthlyData);
  179. this.createMonthlyChart(originalSeasonKey, monthlyData);
  180. this.updateMonthlyTable(monthlyData);
  181. });
  182. @this.call('getYearlyTotals').then(yearlyData => {
  183. console.log('Got yearly data:', yearlyData);
  184. this.updateYearlyTable(yearlyData);
  185. });
  186. @this.call('getTopCausalsByAmount').then(causalsData => {
  187. console.log('Got causals data:', causalsData);
  188. this.createCausalsChart(originalSeasonKey, causalsData);
  189. });
  190. @this.call('getTesseratiData').then(membersData => {
  191. console.log('Got members data:', membersData);
  192. this.createMembersChart(originalSeasonKey, membersData);
  193. this.updateMembersTable(membersData);
  194. });
  195. },
  196. forceUpdateCharts: function () {
  197. console.log('Force updating charts...');
  198. this.currentSeason = null;
  199. this.updateMainCharts();
  200. },
  201. createMonthlyChart: function (seasonKey, monthlyData) {
  202. const chartId = `monthly-chart-${seasonKey}`;
  203. const canvas = document.getElementById(chartId);
  204. if (!canvas) {
  205. console.error('Canvas not found for ID:', chartId);
  206. return;
  207. }
  208. this.destroyChart(chartId);
  209. const ctx = canvas.getContext('2d');
  210. const incomeGradient = ctx.createLinearGradient(0, 0, 0, 400);
  211. incomeGradient.addColorStop(0, 'rgba(0, 184, 148, 1)');
  212. incomeGradient.addColorStop(1, 'rgba(0, 184, 148, 1)');
  213. const expenseGradient = ctx.createLinearGradient(0, 0, 0, 400);
  214. expenseGradient.addColorStop(0, 'rgba(255, 107, 107, 1)');
  215. expenseGradient.addColorStop(1, 'rgba(255, 107, 107, 1)');
  216. this.charts[chartId] = new Chart(ctx, {
  217. type: 'bar',
  218. data: {
  219. labels: monthlyData.labels,
  220. datasets: [
  221. {
  222. label: 'Entrate',
  223. data: monthlyData.datasets[0].data,
  224. backgroundColor: incomeGradient,
  225. borderColor: '#00b894',
  226. borderWidth: 0,
  227. borderRadius: 0,
  228. borderSkipped: false,
  229. barThickness: "flex",
  230. barPercentage: 0.65,
  231. categoryPercentage: 0.4,
  232. },
  233. {
  234. label: 'Uscite',
  235. data: monthlyData.datasets[1].data,
  236. backgroundColor: expenseGradient,
  237. borderColor: '#ff6b6b',
  238. borderWidth: 0,
  239. borderRadius: 0,
  240. borderSkipped: false,
  241. barThickness: "flex",
  242. barPercentage: 0.65,
  243. categoryPercentage: 0.4,
  244. }
  245. ]
  246. },
  247. options: {
  248. responsive: true,
  249. maintainAspectRatio: false,
  250. interaction: {
  251. mode: 'index',
  252. intersect: false,
  253. },
  254. plugins: {
  255. legend: {
  256. position: 'bottom',
  257. labels: {
  258. usePointStyle: true,
  259. padding: 20,
  260. pointStyle: "rect",
  261. font: { weight: '500' }
  262. }
  263. },
  264. tooltip: {
  265. backgroundColor: 'rgba(255, 255, 255, 1)',
  266. titleColor: '#212529',
  267. bodyColor: '#495057',
  268. borderColor: '#e9ecef',
  269. borderWidth: 2,
  270. cornerRadius: 0,
  271. callbacks: {
  272. label: function (context) {
  273. return context.dataset.label + ': €' +
  274. new Intl.NumberFormat('it-IT').format(context.parsed.y);
  275. }
  276. }
  277. },
  278. },
  279. scales: {
  280. x: {
  281. grid: { display: false },
  282. ticks: { font: { weight: '500' } }
  283. },
  284. y: {
  285. beginAtZero: true,
  286. grid: { color: 'rgba(0, 0, 0, 0.05)' },
  287. ticks: {
  288. callback: function (value) {
  289. return '€' + new Intl.NumberFormat('it-IT').format(value);
  290. }
  291. }
  292. }
  293. },
  294. elements: {
  295. bar: {
  296. borderRadius: {
  297. topLeft: 12,
  298. topRight: 12,
  299. bottomLeft: 0,
  300. bottomRight: 0
  301. }
  302. }
  303. },
  304. animation: {
  305. duration: 1000,
  306. easing: 'easeOutQuart'
  307. }
  308. }
  309. });
  310. },
  311. createCausalsChart: function (seasonKey, causalsData) {
  312. const chartId = `causals-chart-${seasonKey}`;
  313. const canvas = document.getElementById(chartId);
  314. if (!canvas) return;
  315. this.destroyChart(chartId);
  316. const ctx = canvas.getContext('2d');
  317. const colors = [
  318. 'rgba(59, 91, 219, 0.8)',
  319. 'rgba(0, 184, 148, 0.8)',
  320. 'rgba(34, 184, 207, 0.8)',
  321. 'rgba(255, 212, 59, 0.8)',
  322. 'rgba(255, 107, 107, 0.8)',
  323. 'rgba(142, 68, 173, 0.8)',
  324. 'rgba(230, 126, 34, 0.8)',
  325. 'rgba(149, 165, 166, 0.8)',
  326. 'rgba(241, 196, 15, 0.8)',
  327. 'rgba(231, 76, 60, 0.8)'
  328. ];
  329. const dataValues = causalsData.inData.map(item => parseFloat(item.value));
  330. const total = dataValues.reduce((sum, value) => sum + value, 0);
  331. // alwaysTooltip
  332. const alwaysShowTooltip = {
  333. id: "alwaysShowTooltip",
  334. afterDraw(chart) {
  335. const { ctx } = chart;
  336. ctx.save();
  337. const linePad = 12; // distanza dal bordo della ciambella
  338. const textOffset = 20; // distanza fissa del testo dal bordo
  339. const lineGap = 8; // margine tra linea e testo
  340. const lineWidth = 1;
  341. chart.data.datasets.forEach((dataset, di) => {
  342. const meta = chart.getDatasetMeta(di);
  343. const total = dataset.data.reduce((s, v) => s + v, 0);
  344. meta.data.forEach((datapoint, index) => {
  345. const value = dataset.data[index];
  346. const perc = total > 0 ? (value / total) * 100 : 0;
  347. const text = `${perc.toFixed(1)}%`;
  348. // calcolo angolo e anchor
  349. const { x, y } = datapoint.tooltipPosition();
  350. const coords = outsideLabelPoint({
  351. cx: datapoint.x,
  352. cy: datapoint.y,
  353. px: x,
  354. py: y,
  355. radius: datapoint.outerRadius,
  356. pad: linePad,
  357. });
  358. const ux = Math.cos(coords.theta);
  359. const uy = Math.sin(coords.theta);
  360. const ax = coords.anchor.x;
  361. const ay = coords.anchor.y;
  362. // centro testo
  363. const tx = ax + ux * textOffset;
  364. const ty = ay + uy * textOffset;
  365. // fine della linea = poco prima del testo
  366. const lx = tx - ux * lineGap;
  367. const ly = ty - uy * lineGap;
  368. // linea: anchor -> vicino al testo
  369. ctx.lineWidth = lineWidth;
  370. ctx.strokeStyle = datapoint.options.borderColor;
  371. ctx.beginPath();
  372. ctx.moveTo(ax, ay);
  373. ctx.lineTo(lx, ly);
  374. ctx.stroke();
  375. // testo
  376. ctx.font = "12px greycliff-cf, sans-serif";
  377. ctx.fillStyle = datapoint.options.borderColor;
  378. ctx.textAlign = "center";
  379. ctx.textBaseline = "middle";
  380. ctx.fillText(text, tx, ty);
  381. });
  382. });
  383. ctx.restore();
  384. },
  385. };
  386. function outsideLabelPoint({ cx, cy, px, py, radius, pad = 20 }) {
  387. const theta = Math.atan2(py - cy, px - cx); // angolo rad
  388. const anchor = { // punto sul bordo del pie
  389. x: cx + radius * Math.cos(theta),
  390. y: cy + radius * Math.sin(theta)
  391. };
  392. const label = { // punto esterno per il tooltip/etichetta
  393. x: cx + (radius + pad) * Math.cos(theta),
  394. y: cy + (radius + pad) * Math.sin(theta)
  395. };
  396. const align = {
  397. textAlign: Math.cos(theta) >= 0 ? 'left' : 'right',
  398. textBaseline: Math.sin(theta) > 0 ? 'top' : 'bottom'
  399. };
  400. return { theta, anchor, label, align };
  401. }
  402. this.charts[chartId] = new Chart(ctx, {
  403. type: 'doughnut',
  404. data: {
  405. labels: causalsData.inLabels,
  406. datasets: [{
  407. label: 'Importo',
  408. data: dataValues,
  409. backgroundColor: colors,
  410. borderColor: colors.map(color => color.replace('0.8', '1')),
  411. borderWidth: 2,
  412. hoverOffset: 8
  413. }]
  414. },
  415. options: {
  416. responsive: true,
  417. maintainAspectRatio: false,
  418. cutout: '30%',
  419. layout: {
  420. padding: {
  421. top: 30,
  422. right: 30,
  423. bottom: 30,
  424. left: 30
  425. }
  426. },
  427. plugins: {
  428. legend: {
  429. display: false
  430. },
  431. tooltip: {
  432. backgroundColor: 'rgba(255, 255, 255, 1)',
  433. titleColor: '#212529',
  434. bodyColor: '#495057',
  435. borderColor: '#e9ecef',
  436. borderWidth: 2,
  437. cornerRadius: 0,
  438. titleFont: {
  439. size: 13,
  440. weight: 'bold'
  441. },
  442. bodyFont: {
  443. size: 12,
  444. weight: '500'
  445. },
  446. padding: 12,
  447. callbacks: {
  448. title: function (context) {
  449. return context[0].label;
  450. },
  451. label: function (context) {
  452. const value = context.raw;
  453. const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
  454. return [
  455. `Importo: €${new Intl.NumberFormat('it-IT', {
  456. minimumFractionDigits: 2,
  457. maximumFractionDigits: 2
  458. }).format(value)}`,
  459. `Percentuale: ${percentage}%`
  460. ];
  461. }
  462. }
  463. },
  464. },
  465. animation: {
  466. animateRotate: true,
  467. duration: 1000
  468. }
  469. },
  470. plugins: [alwaysShowTooltip]
  471. });
  472. this.updateCausalsTable(causalsData, dataValues, total);
  473. },
  474. updateCausalsTable: function (causalsData, dataValues, total) {
  475. const container = document.getElementById('causals-table');
  476. if (!container) return;
  477. const colors = [
  478. '#3b5bdb', '#00b894', '#22b8cf', '#ffd43b', '#ff6b6b',
  479. '#8e44ad', '#e67e22', '#95a5a6', '#f1c40f', '#e74c3c'
  480. ];
  481. let tableHtml = `
  482. <div class="causals-table compact">
  483. <div class="table-header">
  484. <div class="table-cell causale">Causale</div>
  485. <div class="table-cell euro">Importo</div>
  486. <!-- <div class="table-cell percent">%</div> -->
  487. </div>
  488. `;
  489. causalsData.inLabels.forEach((label, index) => {
  490. const value = dataValues[index] || 0;
  491. const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
  492. const color = colors[index % colors.length];
  493. tableHtml += `
  494. <div class="table-row">
  495. <div class="table-cell causale">
  496. <span class="causale-indicator" style="background-color: ${color}"></span>
  497. ${label}
  498. </div>
  499. <div class="table-cell euro">€${new Intl.NumberFormat('it-IT', {
  500. minimumFractionDigits: 2,
  501. maximumFractionDigits: 2
  502. }).format(value)}</div>
  503. <!-- <div class="table-cell percent">${percentage}%</div> -->
  504. </div>
  505. `;
  506. });
  507. tableHtml += '</div>';
  508. container.innerHTML = tableHtml;
  509. },
  510. createMembersChart: function (seasonKey, membersData) {
  511. const chartId = `members-chart`;
  512. const canvas = document.getElementById(chartId);
  513. if (!canvas) return;
  514. this.destroyChart(chartId);
  515. const ctx = canvas.getContext('2d');
  516. const gradient = ctx.createLinearGradient(0, 0, 0, 400);
  517. gradient.addColorStop(0, 'rgba(59, 91, 219, 0.3)');
  518. gradient.addColorStop(1, 'rgba(59, 91, 219, 0.05)');
  519. const processedDatasets = membersData.datasets.map((dataset, index) => {
  520. if (dataset.label === 'Totale Membri Tesserati') {
  521. return {
  522. ...dataset,
  523. // backgroundColor: gradient,
  524. backgroundColor: dataset.borderColor,
  525. borderColor: dataset.borderColor,
  526. borderWidth: 0,
  527. type: 'bar',
  528. order: 1,
  529. fill: true,
  530. barThickness: "flex",
  531. barPercentage: 0.65,
  532. categoryPercentage: 0.4,
  533. };
  534. } else {
  535. return {
  536. ...dataset,
  537. backgroundColor: dataset.borderColor,
  538. borderColor: dataset.borderColor,
  539. borderWidth: 0,
  540. type: 'bar',
  541. order: 2,
  542. fill: true,
  543. barThickness: "flex",
  544. barPercentage: 0.65,
  545. categoryPercentage: 0.4,
  546. };
  547. }
  548. });
  549. let options = {
  550. type: 'bar',
  551. data: {
  552. labels: membersData.labels,
  553. datasets: processedDatasets
  554. },
  555. options: {
  556. responsive: true,
  557. maintainAspectRatio: false,
  558. interaction: {
  559. mode: 'index',
  560. intersect: false,
  561. },
  562. plugins: {
  563. legend: {
  564. position: 'bottom',
  565. labels: {
  566. usePointStyle: true,
  567. padding: 20,
  568. pointStyle: "rect",
  569. font: { weight: '500' }
  570. }
  571. },
  572. tooltip: {
  573. backgroundColor: 'rgba(255, 255, 255, 1)',
  574. titleColor: '#212529',
  575. bodyColor: '#495057',
  576. borderColor: '#e9ecef',
  577. borderWidth: 2,
  578. cornerRadius: 0,
  579. callbacks: {
  580. title: function (context) {
  581. return 'Stagione: ' + context[0].label;
  582. },
  583. label: function (context) {
  584. return context.dataset.label + ': ' + context.parsed.y;
  585. }
  586. }
  587. }
  588. },
  589. scales: {
  590. x: {
  591. grid: { display: false },
  592. ticks: {
  593. font: { weight: '500' }
  594. }
  595. },
  596. y: {
  597. beginAtZero: true,
  598. grid: { color: 'rgba(0, 0, 0, 0.05)' },
  599. ticks: {
  600. precision: 0,
  601. callback: function (value) {
  602. return Math.floor(value); // Ensure integer values
  603. }
  604. }
  605. }
  606. },
  607. animation: {
  608. duration: 1000,
  609. easing: 'easeOutQuart'
  610. }
  611. }
  612. };
  613. this.charts[chartId] = new Chart(ctx, options);
  614. },
  615. updateMonthlyTable: function (monthlyData) {
  616. const container = document.getElementById('monthly-table');
  617. if (!container) return;
  618. const incomeData = monthlyData.datasets[0].data;
  619. const expenseData = monthlyData.datasets[1].data;
  620. const monthNames = monthlyData.labels;
  621. let tableHtml = `
  622. <div class="monthly-table">
  623. <div class="table-header">
  624. <div class="table-cell month">Mese</div>
  625. <div class="table-cell">Entrate</div>
  626. <div class="table-cell">Uscite</div>
  627. <div class="table-cell">Delta</div>
  628. </div>
  629. `;
  630. monthNames.forEach((month, index) => {
  631. const income = parseFloat(incomeData[index] || 0);
  632. const expense = parseFloat(expenseData[index] || 0);
  633. const net = income - expense;
  634. const rowClass = net < 0 ? 'negative' : (net > 0 ? 'positive' : 'neutral');
  635. tableHtml += `
  636. <div class="table-row ${rowClass}">
  637. <div class="table-cell month-name">${month}</div>
  638. <div class="table-cell income">€${new Intl.NumberFormat('it-IT').format(income)}</div>
  639. <div class="table-cell expense">€${new Intl.NumberFormat('it-IT').format(expense)}</div>
  640. <div class="table-cell net">€${new Intl.NumberFormat('it-IT').format(net)}</div>
  641. </div>
  642. `;
  643. });
  644. tableHtml += '</div>';
  645. container.innerHTML = tableHtml;
  646. },
  647. updateYearlyTable: function (yearlyData) {
  648. const container = document.getElementById('yearly-table');
  649. if (!container) return;
  650. const incomeData = yearlyData.datasets[0].data;
  651. const expenseData = yearlyData.datasets[1].data;
  652. const years = yearlyData.labels;
  653. let tableHtml = `
  654. <div class="monthly-table">
  655. <div class="table-header">
  656. <div class="table-cell month">Anno</div>
  657. <div class="table-cell">Entrate</div>
  658. <div class="table-cell">Uscite</div>
  659. <div class="table-cell">Delta</div>
  660. </div>
  661. `;
  662. years.forEach((year, index) => {
  663. const income = parseFloat(incomeData[index] || 0);
  664. const expense = parseFloat(expenseData[index] || 0);
  665. const net = income - expense;
  666. const rowClass = net < 0 ? 'negative' : (net > 0 ? 'positive' : 'neutral');
  667. tableHtml += `
  668. <div class="table-row ${rowClass}">
  669. <div class="table-cell month-name">${year}</div>
  670. <div class="table-cell income">€${new Intl.NumberFormat('it-IT').format(income)}</div>
  671. <div class="table-cell expense">€${new Intl.NumberFormat('it-IT').format(expense)}</div>
  672. <div class="table-cell net">€${new Intl.NumberFormat('it-IT').format(net)}</div>
  673. </div>
  674. `;
  675. });
  676. tableHtml += '</div>';
  677. container.innerHTML = tableHtml;
  678. },
  679. updateMembersTable: function (membersData) {
  680. const container = document.getElementById('members-table');
  681. if (!container) return;
  682. const seasonLabels = membersData.labels;
  683. const totalDataset = membersData.datasets.find(d => d.label === 'Totale Membri Tesserati');
  684. const cardTypeDatasets = membersData.datasets.filter(d => d.label !== 'Totale Membri Tesserati');
  685. const memberCounts = totalDataset ? totalDataset.data : [];
  686. let tableHtml = `
  687. <div class="members-table">
  688. <div class="table-header">
  689. <div class="table-cell">Stagione</div>
  690. <div class="table-cell">Totale</div>
  691. <div class="table-cell">Variazione</div>
  692. <div class="table-cell">Ente</div>
  693. </div>
  694. `;
  695. seasonLabels.forEach((season, index) => {
  696. const current = parseInt(memberCounts[index] || 0);
  697. const previous = index > 0 ? parseInt(memberCounts[index - 1] || 0) : 0;
  698. const variation = index > 0 ? current - previous : 0;
  699. const variationPercent = previous > 0 ? Math.round((variation / previous) * 100 * 10) / 10 : 0;
  700. const rowClass = variation > 0 ? 'positive' : (variation < 0 ? 'negative' : 'neutral');
  701. let variationText = '—';
  702. if (index > 0) {
  703. if (variation > 0) {
  704. variationText = `<span class="variation-positive">+${variation} (+${variationPercent}%)</span>`;
  705. } else if (variation < 0) {
  706. variationText = `<span class="variation-negative">${variation} (${variationPercent}%)</span>`;
  707. } else {
  708. variationText = `<span class="variation-neutral">${variation}</span>`;
  709. }
  710. }
  711. // Build card type breakdown
  712. let cardTypeBreakdown = '';
  713. cardTypeDatasets.forEach((dataset, datasetIndex) => {
  714. const count = dataset.data[index] || 0;
  715. if (count > 0) {
  716. const color = dataset.borderColor || '#6b7280';
  717. cardTypeBreakdown += `
  718. <div class="card-type-item">
  719. <span class="card-type-indicator" style="background-color: ${color}"></span>
  720. <span class="card-type-name">${dataset.label}</span>
  721. <span class="card-type-count">${count}</span>
  722. </div>
  723. `;
  724. }
  725. });
  726. if (!cardTypeBreakdown) {
  727. cardTypeBreakdown = '<div class="no-card-types">Nessun dettaglio</div>';
  728. }
  729. tableHtml += `
  730. <div class="table-row ${rowClass}">
  731. <div class="table-cell season-name">${season}</div>
  732. <div class="table-cell members-count">${new Intl.NumberFormat('it-IT').format(current)}</div>
  733. <div class="table-cell variation">${variationText}</div>
  734. <div class="table-cell card-types">
  735. <div class="card-types-container">
  736. ${cardTypeBreakdown}
  737. </div>
  738. </div>
  739. </div>
  740. `;
  741. });
  742. tableHtml += '</div>';
  743. container.innerHTML = tableHtml;
  744. },
  745. createCourseChart: function () {
  746. console.log('Creating course chart...');
  747. const seasonFilter = '{{ $seasonFilter }}';
  748. const selectedCourse = '{{ $selectedCourse ?? '' }}';
  749. const seasonKey = '{{ str_replace('-', '', $seasonFilter) }}';
  750. console.log('Selected course:', selectedCourse, 'for season:', seasonFilter);
  751. if (!selectedCourse || selectedCourse.trim() === '') {
  752. console.log('No course selected, skipping chart creation');
  753. return;
  754. }
  755. const chartId = `courses-chart-${seasonKey}-${selectedCourse}`;
  756. const canvas = document.getElementById(chartId);
  757. if (!canvas) return;
  758. this.destroyChart(chartId);
  759. const courseData = @json($this->getCourseMonthlyEarnings());
  760. const ctx = canvas.getContext('2d');
  761. this.charts[chartId] = new Chart(ctx, {
  762. type: 'bar',
  763. data: {
  764. labels: courseData.labels,
  765. datasets: courseData.datasets.map(dataset => {
  766. if (dataset.type === 'line') {
  767. return {
  768. ...dataset,
  769. type: 'line',
  770. fill: false,
  771. backgroundColor: 'transparent'
  772. };
  773. }
  774. return dataset;
  775. })
  776. },
  777. options: {
  778. responsive: true,
  779. maintainAspectRatio: false,
  780. interaction: {
  781. mode: 'index',
  782. intersect: false,
  783. },
  784. scales: {
  785. x: {
  786. grid: { display: false },
  787. ticks: { font: { weight: '500' } }
  788. },
  789. y: {
  790. beginAtZero: true,
  791. grid: {
  792. color: 'rgba(0, 0, 0, 1)',
  793. borderDash: [5, 5]
  794. },
  795. ticks: {
  796. callback: function (value) {
  797. return '€' + new Intl.NumberFormat('it-IT').format(value);
  798. }
  799. }
  800. }
  801. },
  802. plugins: {
  803. legend: {
  804. position: 'bottom',
  805. labels: {
  806. usePointStyle: true,
  807. padding: 20,
  808. pointStyle: "rect",
  809. font: { weight: '500' }
  810. }
  811. },
  812. tooltip: {
  813. backgroundColor: 'rgba(255, 255, 255, 1)',
  814. titleColor: '#212529',
  815. bodyColor: '#495057',
  816. borderColor: '#e9ecef',
  817. borderWidth: 2,
  818. cornerRadius: 0,
  819. callbacks: {
  820. label: function (context) {
  821. return context.dataset.label + ': €' +
  822. new Intl.NumberFormat('it-IT').format(context.parsed.y);
  823. }
  824. }
  825. }
  826. },
  827. animation: {
  828. duration: 1000,
  829. easing: 'easeOutQuart'
  830. }
  831. }
  832. });
  833. },
  834. createCourseChartWithValue: function (selectedCourseValue) {
  835. console.log('Creating modern course chart with value:', selectedCourseValue);
  836. const seasonFilter = '{{ $seasonFilter }}';
  837. const seasonKey = '{{ str_replace('-', '', $seasonFilter) }}';
  838. const chartId = `courses-chart-${seasonKey}-${selectedCourseValue}`;
  839. const tableId = `course-delta-table-${seasonKey}-${selectedCourseValue}`;
  840. let canvas = document.getElementById(chartId);
  841. const tableContainer = document.getElementById(tableId);
  842. if (!canvas) {
  843. console.log('Canvas not found for chart ID:', chartId);
  844. const chartContainer = document.querySelector('.modern-chart-container');
  845. if (chartContainer) {
  846. chartContainer.innerHTML = `
  847. <div class="chart-empty-state">
  848. <div style="text-align: center; padding: 4rem 2rem;">
  849. <div style="font-size: 4rem; margin-bottom: 1.5rem; opacity: 0.3;">📊</div>
  850. <h3 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">
  851. Grafico non disponibile
  852. </h3>
  853. <p style="font-size: 1rem; opacity: 0.7; margin: 0; max-width: 400px; margin-left: auto; margin-right: auto; line-height: 1.5;">
  854. Il grafico per questo corso non può essere visualizzato nella stagione selezionata.
  855. </p>
  856. </div>
  857. </div>
  858. `;
  859. }
  860. if (tableContainer) {
  861. tableContainer.innerHTML = '';
  862. }
  863. return;
  864. }
  865. this.destroyChart(chartId);
  866. @this.call('getCourseData', selectedCourseValue).then(courseData => {
  867. console.log('Received course data:', courseData);
  868. if (courseData.isEmpty) {
  869. console.log('No data available for course, showing message');
  870. if (tableContainer) {
  871. tableContainer.innerHTML = '';
  872. }
  873. const chartContainer = canvas.parentElement;
  874. chartContainer.innerHTML = `
  875. <div class="chart-empty-state">
  876. <div style="text-align: center; padding: 4rem 2rem;">
  877. <div style="font-size: 4rem; margin-bottom: 1.5rem; opacity: 0.3;">📊</div>
  878. <h3 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">
  879. ${courseData.message}
  880. </h3>
  881. <p style="font-size: 1rem; opacity: 0.7; margin: 0; max-width: 400px; margin-left: auto; margin-right: auto; line-height: 1.5;">
  882. Questo corso non ha pagamenti registrati per la stagione selezionata.
  883. </p>
  884. </div>
  885. </div>
  886. `;
  887. return;
  888. }
  889. if (!courseData || !courseData.labels || courseData.labels.length === 0) {
  890. console.log('No data available for chart');
  891. return;
  892. }
  893. let canvasElement = document.getElementById(chartId);
  894. if (!canvasElement) {
  895. const chartContainer = canvas.parentElement;
  896. chartContainer.innerHTML = `<canvas id="${chartId}"></canvas>`;
  897. canvasElement = document.getElementById(chartId);
  898. }
  899. this.updateCourseTable(tableContainer, courseData.tableData);
  900. const participantData = courseData.datasets.find(d => d.participantData)?.participantData || [];
  901. const ctx = canvasElement.getContext('2d');
  902. const earnedGradient = ctx.createLinearGradient(0, 0, 0, 400);
  903. earnedGradient.addColorStop(0, 'rgba(16, 185, 129, 1)');
  904. earnedGradient.addColorStop(1, 'rgba(16, 185, 129, 1)');
  905. const totalData = courseData.datasets.find(d => d.label === 'Pagamenti Attesi')?.data || [];
  906. const earnedData = courseData.datasets.find(d => d.label === 'Pagamenti Effettuati')?.data || [];
  907. this.charts[chartId] = new Chart(ctx, {
  908. type: 'bar',
  909. data: {
  910. labels: courseData.labels,
  911. datasets: [
  912. {
  913. label: 'Pagamenti Effettuati',
  914. backgroundColor: earnedGradient,
  915. borderColor: 'rgba(16, 185, 129, 1)',
  916. borderWidth: 0,
  917. borderRadius: 8,
  918. borderSkipped: false,
  919. data: earnedData,
  920. type: 'bar',
  921. order: 2
  922. },
  923. {
  924. label: 'Pagamenti Attesi',
  925. backgroundColor: 'transparent',
  926. borderColor: 'rgba(59, 130, 246, 1)',
  927. borderWidth: 3,
  928. pointBackgroundColor: 'rgba(59, 130, 246, 1)',
  929. pointBorderColor: '#ffffff',
  930. pointBorderWidth: 3,
  931. pointRadius: 7,
  932. pointHoverRadius: 9,
  933. data: totalData,
  934. type: 'line',
  935. tension: 0.3,
  936. order: 1,
  937. participantData: participantData
  938. }
  939. ]
  940. },
  941. options: {
  942. responsive: true,
  943. maintainAspectRatio: false,
  944. interaction: {
  945. mode: 'index',
  946. intersect: false,
  947. },
  948. layout: {
  949. padding: {
  950. top: 20,
  951. right: 20,
  952. bottom: 20,
  953. left: 10
  954. }
  955. },
  956. scales: {
  957. x: {
  958. grid: {
  959. display: false
  960. },
  961. ticks: {
  962. font: {
  963. weight: '600',
  964. size: 13
  965. },
  966. color: '#6b7280'
  967. },
  968. border: {
  969. display: false
  970. }
  971. },
  972. y: {
  973. beginAtZero: true,
  974. grid: {
  975. color: 'rgba(156, 163, 175, 0.15)',
  976. borderDash: [3, 3]
  977. },
  978. border: {
  979. display: false
  980. },
  981. ticks: {
  982. font: {
  983. size: 12,
  984. weight: '500'
  985. },
  986. color: '#6b7280',
  987. callback: function (value) {
  988. return '€' + new Intl.NumberFormat('it-IT', {
  989. minimumFractionDigits: 0,
  990. maximumFractionDigits: 0
  991. }).format(value);
  992. }
  993. }
  994. }
  995. },
  996. plugins: {
  997. legend: {
  998. position: 'bottom',
  999. labels: {
  1000. usePointStyle: true,
  1001. padding: 20,
  1002. pointStyle: "rect",
  1003. font: { weight: '500' }
  1004. }
  1005. },
  1006. tooltip: {
  1007. backgroundColor: 'rgba(255, 255, 255, 1)',
  1008. titleColor: '#111827',
  1009. bodyColor: '#374151',
  1010. borderColor: 'rgba(229, 231, 235, 0.8)',
  1011. borderWidth: 2,
  1012. cornerRadius: 0,
  1013. titleFont: {
  1014. weight: 'bold',
  1015. size: 15
  1016. },
  1017. bodyFont: {
  1018. size: 14,
  1019. weight: '500'
  1020. },
  1021. padding: 16,
  1022. boxPadding: 8,
  1023. usePointStyle: true,
  1024. displayColors: true,
  1025. callbacks: {
  1026. title: function (context) {
  1027. return context[0].label;
  1028. },
  1029. label: function (context) {
  1030. let label = context.dataset.label + ': €' +
  1031. new Intl.NumberFormat('it-IT').format(context.parsed.y);
  1032. if (context.dataset.label === 'Pagamenti Effettuati') {
  1033. const earnedValue = parseFloat(context.parsed.y) || 0;
  1034. const totalValue = parseFloat(totalData[context.dataIndex]) || 0;
  1035. const missingValue = Math.max(0, totalValue - earnedValue);
  1036. if (participantData[context.dataIndex]) {
  1037. label += '\n👥 Partecipanti: ' + participantData[context.dataIndex];
  1038. }
  1039. if (missingValue > 0) {
  1040. label += '\n🔴 Mancanti: €' + new Intl.NumberFormat('it-IT').format(missingValue);
  1041. }
  1042. }
  1043. if (context.dataset.label === 'Pagamenti Attesi' && participantData[context.dataIndex]) {
  1044. label += '\n👥 Partecipanti: ' + participantData[context.dataIndex];
  1045. }
  1046. return label;
  1047. }
  1048. }
  1049. }
  1050. },
  1051. animation: {
  1052. duration: 1500,
  1053. easing: 'easeOutCubic'
  1054. },
  1055. elements: {
  1056. bar: {
  1057. borderRadius: {
  1058. topLeft: 8,
  1059. topRight: 8,
  1060. bottomLeft: 0,
  1061. bottomRight: 0
  1062. }
  1063. },
  1064. line: {
  1065. borderCapStyle: 'round',
  1066. borderJoinStyle: 'round'
  1067. },
  1068. point: {
  1069. hoverBorderWidth: 4,
  1070. borderWidth: 3
  1071. }
  1072. }
  1073. }
  1074. });
  1075. }).catch(error => {
  1076. console.error('Error calling getCourseData:', error);
  1077. });
  1078. },
  1079. updateCourseTable: function (container, tableData) {
  1080. if (!container || !tableData) return;
  1081. let tableHtml = `
  1082. <div class="course-table">
  1083. <div class="table-header">
  1084. <div class="table-cell month">Mese</div>
  1085. <div class="table-cell participants">👥</div>
  1086. <div class="table-cell delta">Mancanti</div>
  1087. <div class="table-cell percentage">%</div>
  1088. </div>
  1089. `;
  1090. tableData.forEach(row => {
  1091. const earned = parseFloat(row.earned) || 0;
  1092. const total = parseFloat(row.total) || 0;
  1093. const delta = Math.max(0, total - earned);
  1094. let percentage = 0;
  1095. let percentageDisplay = '—';
  1096. let percentageClass = 'neutral';
  1097. if (total > 0) {
  1098. percentage = Math.round((earned / total) * 100);
  1099. percentageDisplay = percentage + '%';
  1100. // Color based on completion
  1101. if (percentage >= 100) {
  1102. percentageClass = 'good';
  1103. } else if (percentage >= 80) {
  1104. percentageClass = 'warning';
  1105. } else {
  1106. percentageClass = 'bad';
  1107. }
  1108. }
  1109. // Delta styling: positive when delta is 0 (fully paid), negative when there's missing amount
  1110. const deltaClass = (total > 0 && delta === 0) ? 'positive' :
  1111. (delta > 0) ? 'negative' : 'neutral';
  1112. tableHtml += `
  1113. <div class="table-row">
  1114. <div class="table-cell month">${row.month}</div>
  1115. <div class="table-cell participants">${row.participants}</div>
  1116. <div class="table-cell delta ${deltaClass}">€${new Intl.NumberFormat('it-IT').format(delta)}</div>
  1117. <div class="table-cell percentage ${percentageClass}">${percentageDisplay}</div>
  1118. </div>
  1119. `;
  1120. });
  1121. tableHtml += '</div>';
  1122. container.innerHTML = tableHtml;
  1123. },
  1124. updateCourseChart: function () {
  1125. if (this.selectedCourse) {
  1126. const seasonFilter = @json($seasonFilter);
  1127. const seasonKey = seasonFilter.replace('-', '');
  1128. this.createCourseChartWithValue(this.selectedCourse);
  1129. }
  1130. }
  1131. };
  1132. document.addEventListener('DOMContentLoaded', function () {
  1133. setTimeout(() => {
  1134. window.ReportsChartManager.updateMainCharts();
  1135. }, 100);
  1136. });
  1137. document.addEventListener('livewire:navigated', function () {
  1138. setTimeout(() => {
  1139. window.ReportsChartManager.updateMainCharts();
  1140. }, 100);
  1141. });
  1142. document.addEventListener('livewire:updated', function (event) {
  1143. console.log('Livewire updated, waiting for component to fully update...');
  1144. setTimeout(() => {
  1145. console.log('Now updating charts after delay');
  1146. window.ReportsChartManager.forceUpdateCharts();
  1147. }, 800);
  1148. });
  1149. document.addEventListener('livewire:load', function () {
  1150. Livewire.on('courseSelected', (courseId) => {
  1151. console.log('Course selected event received:', courseId);
  1152. setTimeout(() => {
  1153. window.ReportsChartManager.createCourseChartWithValue(courseId);
  1154. }, 200);
  1155. });
  1156. Livewire.on('chartsUpdated', () => {
  1157. console.log('Charts updated event received from Livewire');
  1158. setTimeout(() => {
  1159. window.ReportsChartManager.forceUpdateCharts();
  1160. }, 200);
  1161. });
  1162. });
  1163. </script>
  1164. </div>