Forráskód Böngészése

dynamic_report - creato

ferrari 1 hete
szülő
commit
956ad00cd6

+ 283 - 0
app/Http/Livewire/DynamicReport.php

@@ -0,0 +1,283 @@
+<?php
+
+namespace App\Http\Livewire;
+
+use App\Http\Middleware\TenantMiddleware;
+use Livewire\Component;
+use Carbon\Carbon;
+use Carbon\CarbonPeriod;
+
+class DynamicReport extends Component
+{
+    public $filters = [
+        'courses' => [],
+        'levels' => [],
+        // 'types' => [],
+        'seasons' => [],
+        'months' => [],
+    ];
+
+    public $filter_options = [
+        'courses' => [],
+        'levels' => [],
+        // 'types' => [],
+        'seasons' => [],
+        'months' => [],
+    ];
+
+    public $chart = [
+        'labels' => [],
+        'datasets' => [],
+    ];
+
+    public function boot()
+    {
+        app(TenantMiddleware::class)->setupTenantConnection();
+    }
+
+    public function mount()
+    {
+        $this->resetReport();
+        $this->initFiltersOptions();
+    }
+
+    public function resetReport()
+    {
+        $this->chart = [
+            'labels' => [],
+            'datasets' => []
+        ];
+    }
+
+    public function initFiltersOptions()
+    {
+        // courses
+        $this->filter_options['courses'] = \App\Models\Course::query()
+            ->select('name')
+            ->where('enabled', true)
+            ->whereNull('deleted_at')
+            ->groupBy('name')
+            ->orderBy('name')
+            ->pluck('name')
+            ->values()
+            ->toArray();
+
+        // levels
+        $this->filter_options['levels'] = \App\Models\CourseLevel::query()
+            ->where('enabled', true)
+            ->orderBy('name')
+            ->get(['id', 'name'])
+            ->toArray();
+
+        // types
+        // $this->filter_options['types'] = \App\Models\CourseType::query()
+        //     ->where('enabled', true)
+        //     ->orderBy('name')
+        //     ->get(['id', 'name'])
+        //     ->toArray();
+
+        // seasons
+        $this->filter_options['seasons'] = $this->deriveSeasonOptionsFromCourseDateFrom();
+
+        // months
+        $this->filter_options['months'] = array_map(
+            fn($m) => ['id' => $m, 'name' => $this->monthLabels()[$m]],
+            $this->fiscalMonths()
+        );
+    }
+
+    public function applyFilters()
+    {
+        $monthsToShow = $this->fiscalMonths();
+        if (!empty($this->filters['months'])) {
+            $selected = array_map('intval', $this->filters['months']);
+            $monthsToShow = array_values(array_filter(
+                $monthsToShow,
+                fn($m) => in_array($m, $selected, true)
+            ));
+        }
+
+        $labels = array_map(fn($m) => $this->monthLabels()[$m], $monthsToShow);
+
+        $selectedSeasons = $this->filters['seasons'] ?? [];
+        $selectedSeasons = array_values(array_filter($selectedSeasons));
+
+        $rows = \App\Models\MemberCourse::query()
+            ->join('courses', 'courses.id', '=', 'member_courses.course_id')
+            ->leftJoin('course_levels', 'course_levels.id', '=', 'courses.course_level_id')
+            // ->leftJoin('course_types', 'course_types.id', '=', 'courses.course_type_id')
+            ->whereNull('courses.deleted_at')
+            ->where('courses.enabled', 1)
+            ->when(!empty($this->filters['courses']), fn($q) => $q->whereIn('courses.name', $this->filters['courses']))
+            ->when(!empty($this->filters['levels']), fn($q) => $q->whereIn('courses.course_level_id', $this->filters['levels']))
+            // ->when(!empty($this->filters['types']), fn($q) => $q->whereIn('courses.course_type_id', $this->filters['types']))
+            ->select([
+                'member_courses.id as member_course_id',
+                'member_courses.member_id',
+                'member_courses.months',
+                'member_courses.date_from as member_date_from',
+                'member_courses.date_to as member_date_to',
+                'courses.id as course_id',
+                'courses.name as course_name',
+                'courses.date_from',
+                'courses.date_to',
+                'course_levels.name as level_name',
+                // 'course_types.name as type_name',
+            ])
+            ->get();
+
+        // dd($rows->toSql(), $rows->getBindings());
+
+        $bucket = [];
+        foreach ($rows as $r) {
+            $seasonLabel = $this->fiscalSeasonFromDates($r->date_from, $r->date_to);
+
+            if (!empty($selectedSeasons)) {
+                if (!$seasonLabel || !in_array($seasonLabel, $selectedSeasons, true)) {
+                    continue;
+                }
+            }
+
+            $activeMonths = [];
+            $member_date_from = Carbon::parse($r->member_date_from);
+            $member_date_to = Carbon::parse($r->member_date_to);
+            $period = CarbonPeriod::create($member_date_from->startOfMonth(), '1 month', $member_date_to->startOfMonth());
+            foreach ($period as $date) {
+                $activeMonths[] = $date->month;
+            }
+            if (empty($activeMonths)) continue;
+
+            if (!empty($this->filters['months'])) {
+                $activeMonths = array_values(array_intersect($activeMonths, array_map('intval', $this->filters['months'])));
+                if (empty($activeMonths)) continue;
+            }
+
+            $seriesKey = $this->makeSeriesKey($r, $seasonLabel);
+
+            foreach ($activeMonths as $m) {
+                if (!in_array($m, $monthsToShow, true)) continue;
+                $bucket[$seriesKey][$m][$r->member_course_id] = true;
+            }
+        }
+
+        $datasets = [];
+        foreach ($bucket as $seriesKey => $monthsMap) {
+            $data = [];
+            foreach ($monthsToShow as $m) {
+                $data[] = isset($monthsMap[$m]) ? count($monthsMap[$m]) : 0;
+            }
+            $datasets[] = [
+                'label' => $seriesKey,
+                'data'  => $data,
+            ];
+        }
+
+        $this->chart = [
+            'labels'   => $labels,
+            'datasets' => $datasets,
+        ];
+
+        $this->dispatchBrowserEvent('dynamic-report:updated', [
+            'chart' => $this->chart,
+        ]);
+    }
+
+    private function fiscalSeasonFromDates(?string $dateFrom, ?string $dateTo): ?string
+    {
+        if (!$dateFrom) return null;
+
+        $from = \Carbon\Carbon::parse($dateFrom);
+        $to   = $dateTo ? \Carbon\Carbon::parse($dateTo) : null;
+
+        // sicurezza: se date_to esiste ed è prima di date_from, ignoriamo date_to
+        if ($to && $to->lessThan($from)) {
+            $to = null;
+        }
+
+        /**
+         * Strategia:
+         * - la stagione è determinata dal "cuore" del corso
+         * - se date_to esiste, prendiamo il punto medio
+         * - altrimenti usiamo date_from
+         */
+        if ($to) {
+            $midTimestamp = (int) (($from->timestamp + $to->timestamp) / 2);
+            $ref = \Carbon\Carbon::createFromTimestamp($midTimestamp);
+        } else {
+            $ref = $from;
+        }
+
+        $startYear = ($ref->month >= 9) ? $ref->year : ($ref->year - 1);
+
+        return $startYear . '-' . ($startYear + 1);
+    }
+
+
+    private function makeSeriesKey($r, ?string $seasonLabel): string
+    {
+        $parts = [];
+
+        if (!empty($this->filters['courses'])) $parts[] = $r->course_name ?? '';
+        if (!empty($this->filters['levels']))  $parts[] = $r->level_name ?? '';
+        // if (!empty($this->filters['types']))   $parts[] = $r->type_name ?? '';
+
+        if (!empty($this->filters['seasons']) && $seasonLabel) $parts[] = $seasonLabel;
+
+        if (empty(array_filter($parts))) {
+            $parts[] = $r->course_name ?? 'Totale';
+        }
+
+        $parts = array_values(array_filter($parts, fn($p) => trim($p) !== ''));
+        return implode(' - ', $parts);
+    }
+
+    private function deriveSeasonOptionsFromCourseDateFrom()
+    {
+        $dates = \App\Models\Course::query()
+            ->where('enabled', 1)
+            ->whereNull('deleted_at')
+            ->whereNotNull('date_from')
+            ->whereNotNull('date_to')
+            ->pluck('date_to', 'date_from')
+            ->all();
+
+        $seasons = [];
+        foreach ($dates as $d_from => $d_to) {
+            $s = $this->fiscalSeasonFromDates($d_from, $d_to);
+            if ($s) $seasons[$s] = true;
+        }
+
+        $out = array_keys($seasons);
+        rsort($out);
+
+        return $out;
+    }
+
+    private function fiscalMonths()
+    {
+        return [9, 10, 11, 12, 1, 2, 3, 4, 5, 6, 7, 8];
+    }
+
+    private function monthLabels()
+    {
+        return [
+            1 => 'Gen',
+            2 => 'Feb',
+            3 => 'Mar',
+            4 => 'Apr',
+            5 => 'Mag',
+            6 => 'Giu',
+            7 => 'Lug',
+            8 => 'Ago',
+            9 => 'Set',
+            10 => 'Ott',
+            11 => 'Nov',
+            12 => 'Dic',
+        ];
+    }
+
+    public function render()
+    {
+        return view('livewire.dynamic_report');
+    }
+}

+ 23 - 5
resources/views/layouts/app.blade.php

@@ -242,6 +242,8 @@
                 print "Assenze";
             if (Request::is('financial_movements'))
                 print "Movimenti finanziari";
+            if (Request::is('dynamic_report'))
+                print "Report Iscritti";
             @endphp
             </h3>
 
@@ -471,13 +473,29 @@
                         </div>
                     @endif
                     @if(Auth::user()->level == env('LEVEL_ADMIN', 0))
-                        <div class="accordion-item {{Request::is('reports') ? "accordion-item-active" : ""}}">
-                            <h2 class="accordion-header linkMenu">
-                                <a class="accordion-button collapsed" href="/reports">
+                        <div class="accordion-item">
+                            <h2 class="accordion-header linkMenu" id="headingFive">
+                                <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFive" aria-expanded="{{Request::is('reports') || Request::is('dynamic_report') ? 'true' : 'false'}}" aria-controls="collapseFive">
                                     <i class="fas fa-chart-line"></i>
-                                    <span>Reports</span>
-                                </a>
+                                    Reports
+                                </button>
                             </h2>
+                            <div id="collapseFive" class="accordion-collapse collapse {{Request::is('reports') || Request::is('dynamic_report') ? 'show' : ''}}" aria-labelledby="headingFive" data-bs-parent="#accordionExample">
+                                <div class="accordion-body">
+                                    <ul class="nav nav-pills flex-column align-items-center align-items-sm-start w-100" id="menu-contabilita" style="margin-top:0px;">
+                                        <li class="nav-item {{Request::is('reports') ? "nav-item-active" : ""}}">
+                                            <a href="/reports" class="nav-link d-flex align-items-center linkMenu">
+                                                <span class="ms-3 d-md-inline">Reports</span>
+                                            </a>
+                                        </li>
+                                        <li class="nav-item {{Request::is('dynamic_report') ? "nav-item-active" : ""}}">
+                                            <a href="/dynamic_report" class="nav-link d-flex align-items-center linkMenu">
+                                                <span class="ms-3 d-md-inline">Report iscritti</span>
+                                            </a>
+                                        </li>
+                                    </ul>
+                                </div>
+                            </div>
                         </div>
                     @endif
                     @if(Auth::user()->level == env('LEVEL_ADMIN', 0))

+ 257 - 0
resources/views/livewire/dynamic_report.blade.php

@@ -0,0 +1,257 @@
+{{-- resources/views/livewire/dynamic_report.blade.php --}}
+<div id="card--dashboard">
+    <div>
+        <div class="chart-row mb-3">
+            <div class="chart-card">
+                <div class="chart-body" style="padding: 20px">
+                    <div class="row row-gap-3">
+                        <div class="col-4">
+                            <div wire:ignore>
+                                <label for="filter-courses">Corso</label>
+                                <select id="filter-courses" multiple class="form-select">
+                                    @foreach ($filter_options['courses'] as $course)
+                                        <option value="{{ $course }}">{{ $course }}</option>
+                                    @endforeach
+                                </select>
+                            </div>
+                        </div>
+
+                        <div class="col-4">
+                            <div wire:ignore>
+                                <label for="filter-levels">Livello</label>
+                                <select id="filter-levels" multiple class="form-select">
+                                    @foreach ($filter_options['levels'] as $level)
+                                        <option value="{{ $level['id'] }}">{{ $level['name'] }}</option>
+                                    @endforeach
+                                </select>
+                            </div>
+                        </div>
+
+                        {{-- <div class="col-4">
+                            <div wire:ignore>
+                                <label for="filter-types">Tipo</label>
+                                <select id="filter-types" multiple class="form-select">
+                                    @foreach ($filter_options['types'] as $type)
+                                        <option value="{{ $type['id'] }}">{{ $type['name'] }}</option>
+                                    @endforeach
+                                </select>
+                            </div>
+                        </div> --}}
+
+                        <div class="col-4">
+                            <div wire:ignore>
+                                <label for="filter-seasons">Stagione</label>
+                                <select id="filter-seasons" multiple class="form-select">
+                                    @foreach ($filter_options['seasons'] as $season)
+                                        <option value="{{ $season }}">{{ $season }}</option>
+                                    @endforeach
+                                </select>
+                            </div>
+                        </div>
+
+                        <div class="col-4">
+                            <div wire:ignore>
+                                <label for="filter-months">Mesi</label>
+                                <select id="filter-months" multiple class="form-select">
+                                    @foreach ($filter_options['months'] as $month)
+                                        <option value="{{ $month['id'] }}">{{ $month['name'] }}</option>
+                                    @endforeach
+                                </select>
+                            </div>
+                        </div>
+
+                        <div class="col text-end mt-4">
+                            <button type="button" class="btn--ui" onclick="applyFilters()">
+                                Applica filtri
+                            </button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="chart-row mb-0">
+            <div class="chart-card">
+                <div class="chart-body" style="padding: 20px" wire:ignore>
+                    <div class="chart-wrapper" style="height: calc(100dvh - 450px)">
+                        <div class="missing-filter-warning" id="filter_msg" wire:ignore>
+                            <div class="chart-placeholder" style="margin-top: 0">
+                                <div style="text-align: center;">
+                                    <div style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.3;">📊</div>
+                                    <p style="font-size: 1.25rem; font-weight: 600; margin: 0;">Seleziona uno o più criteri per visualizzare il report</p>
+                                </div>
+                            </div>
+                        </div>
+                        <canvas id="dynamicReportChart" style="height: 0"></canvas>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+@push('css')
+    <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
+    <link rel="stylesheet" href="/css/chart-reports.css">
+    <style>
+        .select2-container--default .select2-selection--multiple {
+            min-height: 40px !important;
+        }
+
+        .select2-container--default .select2-search--inline .select2-search__field {
+            margin-top: 0;
+        }
+
+        .select2-container--default .select2-search--inline {
+            display: inline-block;
+            padding-top: 0;
+            height: 26px !important;
+        }
+    </style>
+@endpush
+
+@push('scripts')
+    <script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+@endpush
+
+@push('scripts')
+<script>
+document.addEventListener("livewire:load", function () {
+	function initSelect2() {
+        const select2Options = {
+			language: {
+				noResults: function () {
+					return "Nessun risultato";
+				},
+			},
+		};
+
+		const $courses = $("#filter-courses").select2(select2Options);
+		$courses.off('change.dynamicReport').on('change.dynamicReport', function () {
+			@this.set("filters.courses", $(this).val() ?? []);
+		});
+
+		const $levels = $("#filter-levels").select2(select2Options);
+		$levels.off('change.dynamicReport').on('change.dynamicReport', function () {
+			@this.set("filters.levels", $(this).val() ?? []);
+		});
+
+		/* const $types = $("#filter-types").select2(select2Options);
+		$types.off('change.dynamicReport').on('change.dynamicReport', function () {
+			@this.set("filters.types", $(this).val() ?? []);
+		}); */
+
+		const $seasons = $("#filter-seasons").select2(select2Options);
+		$seasons.off('change.dynamicReport').on('change.dynamicReport', function () {
+			@this.set("filters.seasons", $(this).val() ?? []);
+		});
+
+		const $months = $("#filter-months").select2(select2Options);
+		$months.off('change.dynamicReport').on('change.dynamicReport', function () {
+			@this.set("filters.months", $(this).val() ?? []);
+		});
+	}
+	initSelect2();
+});
+</script>
+@endpush
+
+@push('scripts')
+    <script>
+        let dynamicReportChart = null;
+
+        function buildOrUpdateChart(payload) {
+            const ctx = document.getElementById('dynamicReportChart');
+            if (!ctx) return;
+
+            let data = payload.chart ?? { labels: [], datasets: [] };
+            data = applyDatasetStyles(data);
+
+            if (dynamicReportChart) {
+                dynamicReportChart.data.labels = data.labels;
+                dynamicReportChart.data.datasets = data.datasets;
+                dynamicReportChart.update();
+                return;
+            }
+
+            dynamicReportChart = new Chart(ctx, {
+                type: 'bar',
+                data: data,
+                options: {
+                    responsive: true,
+                    maintainAspectRatio: false,
+
+                    interaction: {
+                        mode: 'index',
+                        intersect: false,
+                    },
+
+                    plugins: {
+                        tooltip: {
+                            mode: 'index',
+                            intersect: false,
+                            itemSort: (a, b) => b.raw - a.raw,
+                        },
+                        legend: { display: true, position: 'bottom' },
+                    },
+
+                    scales: {
+                        y: { beginAtZero: true }
+                    }
+                }
+            });
+        }
+
+        function applyDatasetStyles(data) {
+            if (!data?.datasets) return data;
+
+            data.datasets = data.datasets.map((ds, i) => {
+                const hue = (i * 47) % 360;
+
+                return {
+                    ...ds,
+                    barPercentage: 0.65,
+                    categoryPercentage: 0.8,
+                    barThickness: 'flex',
+                    maxBarThickness: 12,
+                    minBarLength: 0,
+                    backgroundColor: `hsla(${hue}, 70%, 55%, 0.85)`,
+                    borderColor: `hsl(${hue}, 70%, 45%)`,
+                    borderRadius: {
+                        topLeft: 8,
+                        topRight: 8,
+                        bottomLeft: 0,
+                        bottomRight: 0,
+                    },
+                    options: {
+                        plugins: {
+                            tooltip: {
+                                backgroundColor: 'rgba(255, 255, 255, 1)',
+                                titleColor: '#212529',
+                                bodyColor: '#495057',
+                                borderColor: '#e9ecef',
+                                borderWidth: 2,
+                                cornerRadius: 0,
+                            },
+                        }
+                    }
+                };
+            });
+
+            return data;
+        }
+
+        function applyFilters() {
+            let filter_msg = document.getElementById("filter_msg");
+            if (filter_msg) filter_msg.style.display = "none";
+
+            @this.applyFilters();
+        }
+
+        window.addEventListener('dynamic-report:updated', (e) => {
+            buildOrUpdateChart(e.detail);
+        });
+    </script>
+@endpush

+ 1 - 0
routes/web.php

@@ -139,6 +139,7 @@ Route::group(['middleware' => 'tenant'], function () {
     Route::get('/presences', \App\Http\Livewire\Presence::class);
     Route::get('/presence_reports', \App\Http\Livewire\PresenceReport::class);
     Route::get('/absence_reports', \App\Http\Livewire\AbsenceReport::class);
+    Route::get('/dynamic_report', \App\Http\Livewire\DynamicReport::class);
 
     Route::get('/receipt/{id}', function ($id) {
         $receipt = \App\Models\Receipt::findOrFail($id);