Просмотр исходного кода

Merge branch 'fabio_develop' into multi_db

FabioFratini 7 месяцев назад
Родитель
Сommit
a1a7511709
34 измененных файлов с 7694 добавлено и 1357 удалено
  1. 64 0
      app/Events/ExportCompleted.php
  2. 58 0
      app/Events/ExportFailed.php
  3. 67 37
      app/Http/Livewire/Azienda.php
  4. 166 172
      app/Http/Livewire/CourseList.php
  5. 212 68
      app/Http/Livewire/Member.php
  6. 722 54
      app/Http/Livewire/Record.php
  7. 192 156
      app/Http/Livewire/RecordINOUT.php
  8. 448 112
      app/Http/Livewire/RecordOUT.php
  9. 104 41
      app/Http/Livewire/Reports.php
  10. 378 0
      app/Jobs/ExportPrimaNota.php
  11. 249 0
      app/Jobs/ProcessRecordAttachment.php
  12. 66 0
      app/Mail/ExportNotification.php
  13. 9 0
      app/Models/Course.php
  14. 350 0
      app/Services/LogoUploadServices.php
  15. 424 0
      app/Services/MemberFileService.php
  16. 416 0
      app/Services/RecordFileService.php
  17. 210 0
      app/Traits/HandlesS3Files.php
  18. 1 0
      composer.json
  19. 285 4
      composer.lock
  20. 7 7
      config/filesystems.php
  21. 36 0
      database/migrations/2025_06_12_080802_create_jobs_table.php
  22. 33 0
      database/migrations/2025_06_12_130008_add_attachment_status_to_records_table.php
  23. 287 12
      public/css/chart-reports.css
  24. 138 0
      public/css/style.css
  25. 206 0
      resources/views/emails/export-notification.blade.php
  26. 102 17
      resources/views/emails/receipt.blade.php
  27. 113 21
      resources/views/emails/receipt_delete.blade.php
  28. 1 1
      resources/views/layouts/app.blade.php
  29. 202 163
      resources/views/livewire/azienda.blade.php
  30. 86 23
      resources/views/livewire/member.blade.php
  31. 837 234
      resources/views/livewire/records.blade.php
  32. 905 132
      resources/views/livewire/records_out.blade.php
  33. 200 57
      resources/views/livewire/reports.blade.php
  34. 120 46
      resources/views/receipt.blade.php

+ 64 - 0
app/Events/ExportCompleted.php

@@ -0,0 +1,64 @@
+<?php
+
+// File: app/Events/ExportCompleted.php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ExportCompleted implements ShouldBroadcast
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public $userId;
+    public $filename;
+    public $emailAddress;
+    public $message;
+
+    /**
+     * Create a new event instance.
+     */
+    public function __construct($userId, $filename, $emailAddress)
+    {
+        $this->userId = $userId;
+        $this->filename = $filename;
+        $this->emailAddress = $emailAddress;
+        $this->message = 'Export completato! Controlla la tua email.';
+    }
+
+    /**
+     * Get the channels the event should broadcast on.
+     */
+    public function broadcastOn()
+    {
+        return new PrivateChannel('exports.' . $this->userId);
+    }
+
+    /**
+     * Get the data to broadcast.
+     */
+    public function broadcastWith()
+    {
+        return [
+            'type' => 'export_completed',
+            'message' => $this->message,
+            'filename' => $this->filename,
+            'email' => $this->emailAddress,
+            'timestamp' => now()->toISOString()
+        ];
+    }
+
+    /**
+     * The event's broadcast name.
+     */
+    public function broadcastAs()
+    {
+        return 'export.completed';
+    }
+}

+ 58 - 0
app/Events/ExportFailed.php

@@ -0,0 +1,58 @@
+<?php
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ExportFailed implements ShouldBroadcast
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public $userId;
+    public $errorMessage;
+    public $message;
+
+    /**
+     * Create a new event instance.
+     */
+    public function __construct($userId, $errorMessage)
+    {
+        $this->userId = $userId;
+        $this->errorMessage = $errorMessage;
+        $this->message = 'Export fallito. Riprova o contatta il supporto.';
+    }
+
+    /**
+     * Get the channels the event should broadcast on.
+     */
+    public function broadcastOn()
+    {
+        return new PrivateChannel('exports.' . $this->userId);
+    }
+
+    /**
+     * Get the data to broadcast.
+     */
+    public function broadcastWith()
+    {
+        return [
+            'type' => 'export_failed',
+            'message' => $this->message,
+            'error' => $this->errorMessage,
+            'timestamp' => now()->toISOString()
+        ];
+    }
+
+    /**
+     * The event's broadcast name.
+     */
+    public function broadcastAs()
+    {
+        return 'export.failed';
+    }
+}

+ 67 - 37
app/Http/Livewire/Azienda.php

@@ -7,8 +7,10 @@ use Livewire\WithFileUploads;
 use Illuminate\Support\Str;
 
 use App\Models\Azienda as AziendaModel;
+use App\Services\LogoUploadServices;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
+
 class Azienda extends Component
 {
     use WithFileUploads;
@@ -215,6 +217,7 @@ class Azienda extends Component
     {
         $this->validate();
         Log::info('Saving discipline: ' . json_encode(implode('; ', $this->selectedDisciplines)));
+
         try {
             $data = [
                 'ragione_sociale' => $this->ragione_sociale,
@@ -245,47 +248,41 @@ class Azienda extends Component
                 'codice_fiscale' => $this->codice_fiscale,
                 'codice_sdi' => $this->codice_sdi,
             ];
-            Log::info('Data logo: ' . json_encode(value: $this->temp_logo));
-            if ($this->temp_logo) {
-                $folderName = Str::slug($this->nome_associazione);
-                Log::info('Folder name: ' . $folderName);
-                $path = 'img/' . $folderName;
-                $fullPath = storage_path('app/public/' . $path);
-                Log::info('Full path: ' . $fullPath);
-                Log::info('Computed Full path: ' . $fullPath);
-
-                Log::info('Directory exists check: ' . (file_exists($fullPath) ? 'Yes' : 'No'));
-
-                if (!file_exists($fullPath)) {
-                    $result = mkdir($fullPath, 0755, true);
-                    if ($result === false) {
-                        Log::error('Failed to create directory: ' . $fullPath);
-                        session()->flash('error', 'Errore durante la creazione della cartella del logo.');
-                        return;
-                    }
-                    Log::info('Directory created: ' . $fullPath);
-                }
 
-                try {
-                    $logoPath = $this->temp_logo->store($path, 'public');
-                    $data['logo'] = $logoPath;
-                    Log::info('Logo path: ' . $logoPath);
-                } catch (\Exception $e) {
-                    Log::error('Error saving logo: ' . $e->getMessage());
-                    session()->flash('error', 'Errore durante il salvataggio del logo: ' . $e->getMessage());
-                    return;
-                }
-            }
+                      // Create or update azienda first
             if ($this->azienda) {
                 $this->azienda->update($data);
-                session()->flash('message', 'Dati aziendali aggiornati con successo!');
             } else {
                 $this->azienda = AziendaModel::create($data);
-                session()->flash('message', 'Dati aziendali creati con successo!');
             }
 
+            // Handle logo upload using the service
+            if ($this->temp_logo) {
+                try {
+                    Log::info('Starting logo upload with service');
+                    $logoService = app(LogoUploadServices::class);
+                    $logoPath = $logoService->uploadLogo($this->temp_logo, $this->azienda);
+                    Log::info('Logo uploaded successfully to: ' . $logoPath);
+
+                    // Reset temp logo after successful upload
+                    $this->temp_logo = null;
+
+                } catch (\Exception $e) {
+                    Log::error('Error uploading logo via service: ' . $e->getMessage());
+                    session()->flash('error', 'Errore durante il caricamento del logo: ' . $e->getMessage());
+                    return;
+                }
+            }
+
+            session()->flash('message', $this->azienda->wasRecentlyCreated ?
+                'Dati aziendali creati con successo!' :
+                'Dati aziendali aggiornati con successo!'
+            );
+
             $this->update = false;
+
         } catch (\Exception $ex) {
+            Log::error('Error in save method: ' . $ex->getMessage());
             session()->flash('error', 'Errore: ' . $ex->getMessage());
         }
     }
@@ -339,13 +336,46 @@ class Azienda extends Component
         $this->loadDisciplines();
     }
 
-    public function removeLogo()
+      public function removeLogo()
+    {
+        if ($this->azienda) {
+            try {
+                $logoService = app(LogoUploadServices::class);
+                if ($logoService->deleteLogo($this->azienda)) {
+                    $this->logo = null;
+                    session()->flash('message', 'Logo rimosso con successo!');
+                    Log::info('Logo removed successfully for azienda: ' . $this->azienda->id);
+                } else {
+                    session()->flash('error', 'Nessun logo da rimuovere.');
+                }
+            } catch (\Exception $e) {
+                Log::error('Error removing logo: ' . $e->getMessage());
+                session()->flash('error', 'Errore durante la rimozione del logo: ' . $e->getMessage());
+            }
+        }
+    }
+
+    /**
+     * Get logo URL for display
+     */
+    public function getLogoUrlProperty()
     {
         if ($this->azienda && $this->azienda->logo) {
-            Storage::disk('public')->delete($this->azienda->logo);
-            $this->azienda->logo = null;
-            $this->azienda->save();
-            session()->flash('message', 'Logo rimosso con successo!');
+            $logoService = app(LogoUploadServices::class);
+            return $logoService->getLogoUrl($this->azienda);
+        }
+        return null;
+    }
+
+    /**
+     * Check if logo exists
+     */
+    public function getHasLogoProperty()
+    {
+        if ($this->azienda) {
+            $logoService = app(LogoUploadServices::class);
+            return $logoService->logoExists($this->azienda);
         }
+        return false;
     }
 }

+ 166 - 172
app/Http/Livewire/CourseList.php

@@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 use App\Models\MemberCourse;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
 
 class CourseList extends Component
 {
@@ -47,6 +48,8 @@ class CourseList extends Component
     public $totals = [];
     public $totalIsc = [];
 
+    public $selectedCourseId;
+    public $selectedMemberId;
     public $aaa;
 
     public $months = array('Set', 'Ott', 'Nov', 'Dic', 'Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago');
@@ -70,27 +73,33 @@ class CourseList extends Component
 
     }
 
-    public function updatedfilterCourse($value){
+    public function updatedfilterCourse($value)
+    {
         $this->emit('load-data-table');
     }
 
-    public function updatedfilterLevel($value){
+    public function updatedfilterLevel($value)
+    {
         $this->emit('load-data-table');
     }
 
-    public function updatedfilterFrequency($value){
+    public function updatedfilterFrequency($value)
+    {
         $this->emit('load-data-table');
     }
 
-    public function updatedfilterType($value){
+    public function updatedfilterType($value)
+    {
         $this->emit('load-data-table');
     }
 
-    public function updatedfilterDuration($value){
+    public function updatedfilterDuration($value)
+    {
         $this->emit('load-data-table');
     }
 
-    public function updatedpageLength($value){
+    public function updatedpageLength($value)
+    {
         $this->emit('load-data-table');
     }
 
@@ -100,13 +109,10 @@ class CourseList extends Component
 
         $member_course = \App\Models\MemberCourse::with('member')->with('course');
 
-        if ($this->hasFilter)
-        {
-            if (isset($_GET["search"]["value"]))
-            {
+        if ($this->hasFilter) {
+            if (isset($_GET["search"]["value"])) {
 
-                if ($_GET["search"]["value"] != '')
-                {
+                if ($_GET["search"]["value"] != '') {
                     $v = str_replace("'", "\'", stripcslashes($_GET["search"]["value"]));
                     $member_ids = \App\Models\Member::where(function ($query) use ($v) {
                         $query->whereRaw("CONCAT(first_name, ' ', last_name) like '%" . $v . "%'")
@@ -122,39 +128,32 @@ class CourseList extends Component
                 }
             }
 
-            if (sizeof($this->filterCourse) > 0)
-            {
+            if (sizeof($this->filterCourse) > 0) {
                 $course_ids = [];
                 $courses = $this->filterCourse;
-                foreach($courses as $c)
-                {
+                foreach ($courses as $c) {
                     $all = \App\Models\Course::where('name', 'like', '%' . $c . "%")->get();
-                    foreach($all as $a)
-                    {
+                    foreach ($all as $a) {
                         $course_ids[] = $a->id;
                     }
                 }
                 $member_course = $member_course->whereIn('course_id', $course_ids);
             }
 
-            if (sizeof($this->filterLevel) > 0)
-            {
+            if (sizeof($this->filterLevel) > 0) {
                 $course_ids = \App\Models\Course::whereIn('course_level_id', $this->filterLevel)->pluck('id');
                 $member_course = $member_course->whereIn('course_id', $course_ids);
             }
 
-            if (sizeof($this->filterFrequency) > 0)
-            {
+            if (sizeof($this->filterFrequency) > 0) {
                 $course_ids = \App\Models\Course::whereIn('course_frequency_id', $this->filterFrequency)->pluck('id');
                 $member_course = $member_course->whereIn('course_id', $course_ids);
             }
-            if (sizeof($this->filterType) > 0)
-            {
+            if (sizeof($this->filterType) > 0) {
                 $course_ids = \App\Models\Course::whereIn('course_type_id', $this->filterType)->pluck('id');
                 $member_course = $member_course->whereIn('course_id', $course_ids);
             }
-            if (sizeof($this->filterDuration) > 0)
-            {
+            if (sizeof($this->filterDuration) > 0) {
                 $course_ids = \App\Models\Course::whereIn('course_duration_id', $this->filterDuration)->pluck('id');
                 $member_course = $member_course->whereIn('course_id', $course_ids);
             }
@@ -177,61 +176,44 @@ class CourseList extends Component
 
         $column = '';
         $sort_value = 0;
-        if ($this->sort != '')
-        {
+        if ($this->sort != '') {
             $f = $this->sort;
             $d = $this->dir;
-            if ($f >= 5 && $f <= 16)
-            {
+            if ($f >= 5 && $f <= 16) {
                 $column = 'column_' . ($f - 2);
-                if (session()->get('sort_column'))
-                {
-                    if (session()->get('sort_column') != $f)
-                    {
+                if (session()->get('sort_column')) {
+                    if (session()->get('sort_column') != $f) {
                         session()->put('sort_column', $f);
                         session()->put('sort_order', $d);
                         session()->put('sort_value', 0);
                         $sort_value = 0;
-                    }
-                    else
-                    {
-                        if (session()->get('sort_order') == $d)
-                        {
+                    } else {
+                        if (session()->get('sort_order') == $d) {
                             //session()->put('sort_value', 0);
                             $sort_value = session()->get('sort_value', 0);
-                        }
-                        else
-                        {
-                            if (session()->get('sort_value', 0) == 0)
-                            {
+                        } else {
+                            if (session()->get('sort_value', 0) == 0) {
                                 $sort_value = 1;
                             }
-                            if (session()->get('sort_value', 0) == 1)
-                            {
+                            if (session()->get('sort_value', 0) == 1) {
                                 $sort_value = 2;
                             }
-                            if (session()->get('sort_value', 0) == 2)
-                            {
+                            if (session()->get('sort_value', 0) == 2) {
                                 $sort_value = 3;
                             }
-                            if (session()->get('sort_value', 0) == 3)
-                            {
+                            if (session()->get('sort_value', 0) == 3) {
                                 $sort_value = 0;
                             }
                             session()->put('sort_value', $sort_value);
-
                         }
                         session()->put('sort_order', $d);
                     }
-                }
-                else
-                {
+                } else {
                     session()->put('sort_column', $f);
                     session()->put('sort_order', $d);
                     session()->put('sort_value', 0);
                     $sort_value = 0;
                 }
-
             }
         }
 
@@ -241,29 +223,24 @@ class CourseList extends Component
         $prices = [];
 
         $member_course_totals = $member_course->get();
-        foreach($member_course_totals as $x)
-        {
+        foreach ($member_course_totals as $x) {
 
             $price = 0;
             $price = $x->price; // $x->course->price;
             $subPrice = $x->subscription_price; // $x->course->subscription_price;
             $records = \App\Models\Record::where('member_course_id', $x->id)->where('deleted', 0)->get();
             $prices = [];
-            foreach ($records as $record)
-            {
-                foreach ($record->rows as $row)
-                {
+            foreach ($records as $record) {
+                foreach ($record->rows as $row) {
 
                     if ($row->causal_id == $x->course->sub_causal_id) //  || str_contains(strtolower($row->note), 'iscrizione'))
                     //if (str_contains(strtolower($row->note), 'iscrizione'))
                     {
                         $subPrice = $row->amount;
                     }
-                    if ($row->causal_id == $x->course->causal_id && !str_contains(strtolower($row->note), 'iscrizione'))
-                    {
+                    if ($row->causal_id == $x->course->causal_id && !str_contains(strtolower($row->note), 'iscrizione')) {
                         $tot = sizeof(json_decode($row->when));
-                        foreach(json_decode($row->when) as $m)
-                        {
+                        foreach (json_decode($row->when) as $m) {
                             if (isset($prices[$m->month]))
                                 $prices[$m->month] += $row->amount / $tot;
                             else
@@ -273,30 +250,24 @@ class CourseList extends Component
                 }
             }
 
-            for($i=1; $i<=12; $i++)
-            {
+            for ($i = 1; $i <= 12; $i++) {
 
                 $cls = $this->getColor($x->months, $i, isset($prices[$i]) && $prices[$i] == $price);
-                if ($cls != 'wgrey')
-                {
+                if ($cls != 'wgrey') {
 
-                    if (!isset($totals[$i]))
-                    {
+                    if (!isset($totals[$i])) {
                         $totals[$i]['green'] = 0;
                         $totals[$i]['orange'] = 0;
                         $totals[$i]['yellow'] = 0;
                     }
-                    if ($cls == 'yellow')
-                    {
+                    if ($cls == 'yellow') {
                         $totals[$i][$cls] += 1;
-                    }
-                    else
-                    {
+                    } else {
                         $p = isset($prices[$i]) ? $prices[$i] : $price;
                         //if (isset($totals[$i][$cls]))
-                            $totals[$i][$cls] += $p;
+                        $totals[$i][$cls] += $p;
                         //else
-                            //$totals[$i][$cls] = $p;
+                        //$totals[$i][$cls] = $p;
                     }
                 }
             }
@@ -308,8 +279,7 @@ class CourseList extends Component
                 $totalIsc[$sub] = $subPrice;
 
             $s = 0;
-            if ($column != '')
-            {
+            if ($column != '') {
                 $z = 0;
                 switch ($column) {
                     case 'column_3':
@@ -353,8 +323,7 @@ class CourseList extends Component
                         break;
                 }
                 $c = getColor($x->months, $z);
-                if ($sort_value == 0)
-                {
+                if ($sort_value == 0) {
                     switch ($c) {
                         case 'wgrey':
                             $s = 0;
@@ -373,8 +342,7 @@ class CourseList extends Component
                             break;
                     }
                 }
-                if ($sort_value == 1)
-                {
+                if ($sort_value == 1) {
                     switch ($c) {
                         case 'wgrey':
                             $s = 3;
@@ -393,8 +361,7 @@ class CourseList extends Component
                             break;
                     }
                 }
-                if ($sort_value == 2)
-                {
+                if ($sort_value == 2) {
                     switch ($c) {
                         case 'wgrey':
                             $s = 2;
@@ -413,8 +380,7 @@ class CourseList extends Component
                             break;
                     }
                 }
-                if ($sort_value == 3)
-                {
+                if ($sort_value == 3) {
                     switch ($c) {
                         case 'wgrey':
                             $s = 1;
@@ -433,7 +399,6 @@ class CourseList extends Component
                             break;
                     }
                 }
-
             }
 
             $datas[] = array(
@@ -459,7 +424,6 @@ class CourseList extends Component
                 "column_18" => $xxx++,
                 "column_20" => $s
             );
-
         }
 
         $count = $member_course->count();
@@ -470,20 +434,17 @@ class CourseList extends Component
         //$js = '';
         $xx = 4;
         $str = '';
-        if ($count > 0)
-        {
+        if ($count > 0) {
             $str .= "<a style='width:100%;float:right; text-align:right; display:block;' class=green><small>" . (isset($totalIsc["Y"]) ? formatPrice($totalIsc["Y"]) : 0) . "</small></a><br>";
             $str .= "<a style='width:100%;float:right; text-align:right; display:block;' class=orange><small>" . (isset($totalIsc["N"]) ? formatPrice($totalIsc["N"]) : 0) . "</small></a><br>";
             $str .= "<a style='width:100%;float:right; text-align:right; display:block;' class=yellow><small>0</small></a><br>";
 
             $this->totSExcel[] = array('green' => (isset($totalIsc["Y"]) ? formatPrice($totalIsc["Y"]) : 0), 'orange' => (isset($totalIsc["N"]) ? formatPrice($totalIsc["N"]) : 0), 'yellow' => 0);
-
         }
         $this->totS[] = $str;
 
         $str = "";
-        foreach($totals as $z => $t)
-        {
+        foreach ($totals as $z => $t) {
             if ($z == 1) $xx = 5;
             if ($z == 2) $xx = 6;
             if ($z == 3) $xx = 7;
@@ -498,8 +459,7 @@ class CourseList extends Component
             if ($z == 12) $xx = 4;
             $str = '';
             $aaa = [];
-            foreach($t as $x => $c)
-            {
+            foreach ($t as $x => $c) {
                 $y = $x == 'yellow' ? $c : formatPrice($c);
                 $str .= "<a style='width:100%;float:right; text-align:right; display:block;' class=" . $x . "><small>" . $y . "</small></a><br>";
                 $aaa[$x] = $y;
@@ -512,24 +472,21 @@ class CourseList extends Component
             $xx += 1;
         }
 
-        for($e=sizeof($this->totS);$e<=12;$e++)
-        {
+        for ($e = sizeof($this->totS); $e <= 12; $e++) {
             $this->totS[] = '';
         }
 
-        if ($this->sort != '')
-        {
+        if ($this->sort != '') {
             $s = $this->sort;
             if ($s == 1) $s = 21;
             if ($column != '')
-                array_multisort(array_column($datas, 'column_20'), SORT_ASC, SORT_NATURAL|SORT_FLAG_CASE, $datas);
+                array_multisort(array_column($datas, 'column_20'), SORT_ASC, SORT_NATURAL | SORT_FLAG_CASE, $datas);
             else
-                array_multisort(array_column($datas, 'column_' . ($s - 2)), $this->dir == "ASC" ? SORT_ASC : SORT_DESC, SORT_NATURAL|SORT_FLAG_CASE, $datas);
+                array_multisort(array_column($datas, 'column_' . ($s - 2)), $this->dir == "ASC" ? SORT_ASC : SORT_DESC, SORT_NATURAL | SORT_FLAG_CASE, $datas);
         }
 
         $xxx = 1;
-        foreach($datas as $yyy => $d)
-        {
+        foreach ($datas as $yyy => $d) {
             $datas[$yyy]["column_18"] = $xxx++;
         }
 
@@ -549,7 +506,6 @@ class CourseList extends Component
         //$this->totS = $js;
 
         return view('livewire.course_list');
-
     }
 
     public function setPage($page)
@@ -580,24 +536,18 @@ class CourseList extends Component
     public function getColor($months, $m, $all)
     {
         $class = "wgrey";
-        foreach(json_decode($months) as $mm)
-        {
-            if ($mm->m == $m)
-            {
-                if ($mm->status == "")
-                {
+        foreach (json_decode($months) as $mm) {
+            if ($mm->m == $m) {
+                if ($mm->status == "") {
                     $class = "orange";
                 }
-                if ($mm->status == "1")
-                {
+                if ($mm->status == "1") {
                     $class = "green";
                 }
-                if ($mm->status == "2")
-                {
+                if ($mm->status == "2") {
                     $class = "yellow";
                 }
-                if (!$all && $class == "green")
-                {
+                if (!$all && $class == "green") {
                     $class = "orange";
                 }
             }
@@ -609,11 +559,9 @@ class CourseList extends Component
     {
 
         $newMonths = array();
-        if ($months != '')
-        {
+        if ($months != '') {
             $mm = explode(",", $months);
-            foreach($mm as $month)
-            {
+            foreach ($mm as $month) {
                 if ($month < 5) $month += 12;
                 if ($month >= 5) $month -= 4;
                 $newMonths[] = $month;
@@ -627,21 +575,16 @@ class CourseList extends Component
         $subscription_price = $m->subscription_price;
 
         $records = \App\Models\Record::where('member_course_id', $m->id)->where('deleted', 0)->get();
-        foreach ($records as $record)
-        {
+        foreach ($records as $record) {
 
-            if (in_array($month, json_decode($record->months)))
-            {
+            if (in_array($month, json_decode($record->months))) {
 
-                foreach ($record->rows as $row)
-                {
+                foreach ($record->rows as $row) {
 
-                    
-                    if ($row->causal_id == $c->causal_id && !str_contains(strtolower($row->note), 'iscrizione'))
-                    {
+
+                    if ($row->causal_id == $c->causal_id && !str_contains(strtolower($row->note), 'iscrizione')) {
                         $tot = sizeof(json_decode($row->when));
-                        foreach(json_decode($row->when) as $m)
-                        {
+                        foreach (json_decode($row->when) as $m) {
                             $price -= $row->amount / $tot;
                         }
                     }
@@ -650,7 +593,6 @@ class CourseList extends Component
         }
 
         return redirect()->to('/in?new=1&memberId=' . $member_id . (sizeof($newMonths) > 0 ? '&causalId=' . $c->causal_id : '') . '&subCausalId=' . $c->sub_causal_id . '&createSubscription=' . ($subscription ? 1 : 0) . (sizeof($newMonths) > 0 ? '&months=' . implode("|", $newMonths) : '') . (sizeof($newMonths) > 0 ? ('&price=' . $price) : '') . '&subscription_price=' . $subscription_price . "&courseId=" . $id);
-
     }
 
     public function suspendPayment($course_id, $month, $member_id, $id, $subscription)
@@ -743,7 +685,7 @@ class CourseList extends Component
         }
 
         $monthsData = json_decode($memberCourse->months, true);
-        Log::info('data mese',$monthsData);
+        Log::info('data mese', $monthsData);
         if (!is_array($monthsData)) {
             return response()->json(['error' => 'Invalid months data format'], 400);
         }
@@ -777,7 +719,6 @@ class CourseList extends Component
     {
         $c = \App\Models\Course::findOrFail($course_id);
         return redirect()->to('/in?new=1&memberId=' . $member_id . '&causalId=' . $c->causal_id . '&subCausalId=' . $c->sub_causal_id . '&createSubscription=1&price=0.00&subscription_price=' . $c->subscription_price . "&courseId=" . $id);
-
     }
 
     public function disableSearch()
@@ -788,7 +729,6 @@ class CourseList extends Component
         $this->filterDuration = [];
         $this->filterFrequency = [];
         $this->hasFilter = false;
-
     }
 
     public function export()
@@ -817,8 +757,7 @@ class CourseList extends Component
         $activeWorksheet->setCellValue('P1', "Agosto");
 
         $count = 2;
-        foreach($this->recordsNoPaginate as $idx => $record)
-        {
+        foreach ($this->recordsNoPaginate as $idx => $record) {
 
             $activeWorksheet->setCellValue('A' . $count, $record["column_19"]);
             $activeWorksheet->setCellValue('B' . $count, $record["column_0"]);
@@ -826,9 +765,9 @@ class CourseList extends Component
             list($color, $value) = explode("§", $record["column_2"]);
             $activeWorksheet->setCellValue('D' . $count, $value);
             $c = '#FFFFFF';
-            if($color == 0)
+            if ($color == 0)
                 $c = 'ffa500';
-            if($color == 1)
+            if ($color == 1)
                 $c = '00ff00';
 
             $activeWorksheet->getStyle('D' . $count . ':D' . $count)
@@ -837,21 +776,19 @@ class CourseList extends Component
                 ->getStartColor()
                 ->setARGB($c);
 
-            for($ii=3; $ii<=14; $ii++)
-            {
+            for ($ii = 3; $ii <= 14; $ii++) {
                 list($color, $value) = explode("§", $record["column_" . $ii]);
                 $c = 'FFFFFF';
-                if($color == 'orange')
+                if ($color == 'orange')
                     $c = 'ffa500';
-                if($color == 'green')
+                if ($color == 'green')
                     $c = '00ff00';
-                if($color == 'yellow')
-                {
+                if ($color == 'yellow') {
                     $c = '5088bf';
                     $activeWorksheet->getStyle($letters[$ii + 1] . $count . ':' . $letters[$ii + 1] . $count)
-                    ->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_WHITE);;
+                        ->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_WHITE);;
                 }
-                if($color == 'wgrey')
+                if ($color == 'wgrey')
                     $value = '';
 
                 $activeWorksheet->setCellValue($letters[$ii + 1] . $count, $value);
@@ -868,7 +805,7 @@ class CourseList extends Component
                 });*/
             }
 
-            $activeWorksheet->getStyle("A1:P1")->getFont()->setBold( true );
+            $activeWorksheet->getStyle("A1:P1")->getFont()->setBold(true);
 
             $count++;
         }
@@ -877,44 +814,101 @@ class CourseList extends Component
         $activeWorksheet->setCellValue('A' . (1 + $count), '');
         $activeWorksheet->setCellValue('B' . (1 + $count), '');
         $activeWorksheet->setCellValue('C' . (1 + $count), '');
-        for($x=0; $x<=sizeof($this->totSExcel); $x++)
-        {
+        for ($x = 0; $x <= sizeof($this->totSExcel); $x++) {
 
-            if (isset($this->totSExcel[$x]))
-            {
+            if (isset($this->totSExcel[$x])) {
 
                 $activeWorksheet->setCellValue($letters[$x + 3] . (1 + $count), isset($this->totSExcel[$x]['green']) ? $this->totSExcel[$x]['green'] : 0);
                 $activeWorksheet->getStyle($letters[$x + 3] . (1 + $count) . ':' . $letters[$x + 3] . (1 + $count))
-                        ->getFill()
-                        ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
-                        ->getStartColor()
-                        ->setARGB('00ff00');
+                    ->getFill()
+                    ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+                    ->getStartColor()
+                    ->setARGB('00ff00');
 
                 $activeWorksheet->setCellValue($letters[$x + 3] . (2 + $count), isset($this->totSExcel[$x]['orange']) ? $this->totSExcel[$x]['orange'] : 0);
                 $activeWorksheet->getStyle($letters[$x + 3] . (2 + $count) . ':' . $letters[$x + 3] . (2 + $count))
-                        ->getFill()
-                        ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
-                        ->getStartColor()
-                        ->setARGB('ffa500');
+                    ->getFill()
+                    ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+                    ->getStartColor()
+                    ->setARGB('ffa500');
 
                 $activeWorksheet->setCellValue($letters[$x + 3] . (3 + $count), isset($this->totSExcel[$x]['yellow']) ? $this->totSExcel[$x]['yellow'] : 0);
                 $activeWorksheet->getStyle($letters[$x + 3] . (3 + $count) . ':' . $letters[$x + 3] . (3 + $count))
-                        ->getFill()
-                        ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
-                        ->getStartColor()
-                        ->setARGB('5088bf');
+                    ->getFill()
+                    ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+                    ->getStartColor()
+                    ->setARGB('5088bf');
                 $activeWorksheet->getStyle($letters[$x + 3] . (3 + $count) . ':' . $letters[$x + 3] . (3 + $count))
-                        ->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_WHITE);;
+                    ->getFont()->getColor()->setARGB(\PhpOffice\PhpSpreadsheet\Style\Color::COLOR_WHITE);;
             }
-
         }
 
-        $writer = new Xlsx($spreadsheet);
-        $writer->save($path = storage_path('pagamento_corsi_' . date("YmdHis") . '.xlsx'));
+        try {
+            $currentClient = session('currentClient', 'default');
 
-        $this->emit('load-data-table');
+            $filename = 'pagamento_corsi_' . date("YmdHis") . '.xlsx';
+
+            $tempPath = sys_get_temp_dir() . '/' . $filename;
+
+            $writer = new Xlsx($spreadsheet);
+            $writer->save($tempPath);
+
+            $disk = Storage::disk('s3');
+
+            $s3Path = $currentClient . '/exports/' . $filename;
+
+            $exportFolderPath = $currentClient . '/exports/.gitkeep';
+            if (!$disk->exists($exportFolderPath)) {
+                $disk->put($exportFolderPath, '');
+                Log::info("Created exports folder for client: {$currentClient}");
+            }
+
+            $fileContent = file_get_contents($tempPath);
+            $uploaded = $disk->put($s3Path, $fileContent, 'private');
+
+            if (!$uploaded) {
+                throw new \Exception('Failed to upload file to Wasabi S3');
+            }
+
+            Log::info("Excel file uploaded to Wasabi", [
+                'client' => $currentClient,
+                'path' => $s3Path,
+                'size' => filesize($tempPath)
+            ]);
 
-        return response()->download($path)->deleteFileAfterSend();
+            $downloadUrl = $disk->temporaryUrl($s3Path, now()->addHour());
 
+            if (file_exists($tempPath)) {
+                unlink($tempPath);
+            }
+
+            $this->emit('load-data-table');
+
+            return redirect($downloadUrl);
+        } catch (\Exception $e) {
+            Log::error('Error exporting to Wasabi S3', [
+                'error' => $e->getMessage(),
+                'client' => session('currentClient', 'unknown')
+            ]);
+
+            $currentClient = session('currentClient', 'default');
+            $clientFolder = storage_path('app/exports/' . $currentClient);
+
+            if (!is_dir($clientFolder)) {
+                mkdir($clientFolder, 0755, true);
+                Log::info("Created local client folder: {$clientFolder}");
+            }
+
+            $localPath = $clientFolder . '/pagamento_corsi_' . date("YmdHis") . '.xlsx';
+            $writer = new Xlsx($spreadsheet);
+            $writer->save($localPath);
+
+            $this->emit('load-data-table');
+
+            // Show error message to user
+            session()->flash('warning', 'File saved locally due to cloud storage error.');
+
+            return response()->download($localPath)->deleteFileAfterSend();
+        }
     }
 }

+ 212 - 68
app/Http/Livewire/Member.php

@@ -144,6 +144,8 @@ class Member extends Component
     public $filterCertScaduto = 0;
     public $filterCertInScadenza = 0;
     public $already_existing = false;
+    private $fileService;
+
     protected $rules = [
         'first_name' => 'required',
         'last_name' => 'required',
@@ -409,45 +411,91 @@ class Member extends Component
 
     public function removeDocument($idx, $type)
     {
-        if ($type == 'father') {
-            unset($this->father_document_files[$idx]);
-        } elseif ($type == 'mother') {
-            unset($this->mother_document_files[$idx]);
-        } else {
-            unset($this->document_files[$idx]);
+        try {
+            if ($type == 'father') {
+                if (isset($this->father_document_files[$idx])) {
+                    $filePath = $this->father_document_files[$idx];
+                    $this->fileService->deleteFile($filePath);
+                    unset($this->father_document_files[$idx]);
+                    $this->father_document_files = array_values($this->father_document_files);
+                }
+            } elseif ($type == 'mother') {
+                if (isset($this->mother_document_files[$idx])) {
+                    $filePath = $this->mother_document_files[$idx];
+                    $this->fileService->deleteFile($filePath);
+                    unset($this->mother_document_files[$idx]);
+                    $this->mother_document_files = array_values($this->mother_document_files);
+                }
+            } else {
+                if (isset($this->document_files[$idx])) {
+                    $filePath = $this->document_files[$idx];
+                    $this->fileService->deleteFile($filePath);
+                    unset($this->document_files[$idx]);
+                    $this->document_files = array_values($this->document_files);
+                }
+            }
+        } catch (\Exception $e) {
+            session()->flash('error', 'Error removing document: ' . $e->getMessage());
         }
     }
 
+
     public function updatedDocuments()
     {
-        foreach ($this->documents as $document) {
-            $name = $document->getClientOriginalName();
-            $document->storeAs('public', $name);
-            $this->document_files[] = $name;
+        try {
+            foreach ($this->documents as $document) {
+                if ($this->dataId > 0) {
+                    $s3Path = $this->fileService->uploadDocument($document, $this->dataId, 'self');
+                    $this->document_files[] = $s3Path;
+                } else {
+                    $name = $document->getClientOriginalName();
+                    $document->storeAs('public', $name);
+                    $this->document_files[] = $name;
+                }
+            }
+            $this->documents = [];
+        } catch (\Exception $e) {
+            session()->flash('error', 'Error uploading documents: ' . $e->getMessage());
         }
-        $this->documents = [];
     }
 
     public function updatedFatherDocuments()
     {
-        foreach ($this->father_documents as $document) {
-            $name = $document->getClientOriginalName();
-            $document->storeAs('public', $name);
-            $this->father_document_files[] = $name;
+        try {
+            foreach ($this->father_documents as $document) {
+                if ($this->dataId > 0) {
+                    $s3Path = $this->fileService->uploadDocument($document, $this->dataId, 'father');
+                    $this->father_document_files[] = $s3Path;
+                } else {
+                    $name = $document->getClientOriginalName();
+                    $document->storeAs('public', $name);
+                    $this->father_document_files[] = $name;
+                }
+            }
+            $this->father_documents = [];
+        } catch (\Exception $e) {
+            session()->flash('error', 'Error uploading father documents: ' . $e->getMessage());
         }
-        $this->father_documents = [];
     }
 
     public function updatedMotherDocuments()
     {
-        foreach ($this->mother_documents as $document) {
-            $name = $document->getClientOriginalName();
-            $document->storeAs('public', $name);
-            $this->mother_document_files[] = $name;
+        try {
+            foreach ($this->mother_documents as $document) {
+                if ($this->dataId > 0) {
+                    $s3Path = $this->fileService->uploadDocument($document, $this->dataId, 'mother');
+                    $this->mother_document_files[] = $s3Path;
+                } else {
+                    $name = $document->getClientOriginalName();
+                    $document->storeAs('public', $name);
+                    $this->mother_document_files[] = $name;
+                }
+            }
+            $this->mother_documents = [];
+        } catch (\Exception $e) {
+            session()->flash('error', 'Error uploading mother documents: ' . $e->getMessage());
         }
-        $this->mother_documents = [];
     }
-
     public function resetCategoryFields()
     {
         $this->category_category_id = null;
@@ -524,6 +572,11 @@ class Member extends Component
         $this->birthCities = \App\Models\City::where('province_id', 178)->orderBy('name')->orderBy('name')->get();*/
     }
 
+    public function boot(){
+        $this->fileService = app(\App\Services\MemberFileService::class);
+
+    }
+
     public function updated()
     {
         if ($this->isSaving) {
@@ -927,16 +980,14 @@ class Member extends Component
         }
         try {
 
-            $name = '';
+            $imageName = '';
             if ($this->image) {
-                $name = md5($this->image . microtime()) . '.' . $this->image->extension();
-                $this->image->storeAs('public', $name);
+                $imageName = md5($this->image . microtime()) . '.' . $this->image->extension();
+                $this->image->storeAs('public', $imageName);
             }
 
             $docs = implode("|", $this->document_files);
-
             $father_docs = implode("|", $this->father_document_files);
-
             $mother_docs = implode("|", $this->mother_document_files);
 
 
@@ -982,10 +1033,18 @@ class Member extends Component
                 'phone2' => $this->phone2,
                 'phone3' => $this->phone3,
                 'email' => strtolower($this->email),
-                'image' => $name,
+                'image' => $imageName,
                 'to_complete' => false,
                 'enabled' => $this->enabled
             ]);
+            $this->fileService->createMemberFolders($member->id);
+
+            if ($this->image) {
+                $s3ImagePath = $this->fileService->uploadProfileImage($this->image, $member->id);
+                $member->update(['image' => $s3ImagePath]);
+            }
+            $this->migrateTemporaryFiles($member->id);
+
 
             session()->flash('success, Tesserato creato');
             updateMemberData($member->id);
@@ -1001,6 +1060,104 @@ class Member extends Component
         }
     }
 
+    private function migrateTemporaryFiles($memberId)
+    {
+        try {
+            $updatedPaths = [];
+
+            // Migrate document files
+            $newDocumentFiles = [];
+            foreach ($this->document_files as $filePath) {
+                if (strpos($filePath, '/members/') === false) {
+                    // This is a temporary local file, move to S3
+                    $localPath = storage_path('app/public/' . $filePath);
+                    if (file_exists($localPath)) {
+                        $uploadedFile = new \Illuminate\Http\UploadedFile(
+                            $localPath,
+                            basename($filePath),
+                            mime_content_type($localPath),
+                            filesize($localPath),
+                            0,
+                            true
+                        );
+                        $s3Path = $this->fileService->uploadDocument($uploadedFile, $memberId, 'self');
+                        $newDocumentFiles[] = $s3Path;
+                        // Delete temporary file
+                        unlink($localPath);
+                    }
+                } else {
+                    $newDocumentFiles[] = $filePath;
+                }
+            }
+            if (!empty($newDocumentFiles)) {
+                $updatedPaths['document_files'] = implode('|', $newDocumentFiles);
+            }
+
+            // Migrate father document files
+            $newFatherFiles = [];
+            foreach ($this->father_document_files as $filePath) {
+                if (strpos($filePath, '/members/') === false) {
+                    $localPath = storage_path('app/public/' . $filePath);
+                    if (file_exists($localPath)) {
+                        $uploadedFile = new \Illuminate\Http\UploadedFile(
+                            $localPath,
+                            basename($filePath),
+                            mime_content_type($localPath),
+                            filesize($localPath),
+                            0,
+                            true
+                        );
+                        $s3Path = $this->fileService->uploadDocument($uploadedFile, $memberId, 'father');
+                        $newFatherFiles[] = $s3Path;
+                        unlink($localPath);
+                    }
+                } else {
+                    $newFatherFiles[] = $filePath;
+                }
+            }
+            if (!empty($newFatherFiles)) {
+                $updatedPaths['father_document_files'] = implode('|', $newFatherFiles);
+            }
+
+            // Migrate mother document files
+            $newMotherFiles = [];
+            foreach ($this->mother_document_files as $filePath) {
+                if (strpos($filePath, '/members/') === false) {
+                    $localPath = storage_path('app/public/' . $filePath);
+                    if (file_exists($localPath)) {
+                        $uploadedFile = new \Illuminate\Http\UploadedFile(
+                            $localPath,
+                            basename($filePath),
+                            mime_content_type($localPath),
+                            filesize($localPath),
+                            0,
+                            true
+                        );
+                        $s3Path = $this->fileService->uploadDocument($uploadedFile, $memberId, 'mother');
+                        $newMotherFiles[] = $s3Path;
+                        unlink($localPath);
+                    }
+                } else {
+                    $newMotherFiles[] = $filePath;
+                }
+            }
+            if (!empty($newMotherFiles)) {
+                $updatedPaths['mother_document_files'] = implode('|', $newMotherFiles);
+            }
+
+            // Update member with new S3 paths
+            if (!empty($updatedPaths)) {
+                \App\Models\Member::whereId($memberId)->update($updatedPaths);
+            }
+        } catch (\Exception $e) {
+            Log::error('Error migrating temporary files', [
+                'member_id' => $memberId,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+
     public function duplicate($id)
     {
         $member = \App\Models\Member::findOrFail($id);
@@ -1181,12 +1338,12 @@ class Member extends Component
 
         try {
 
-            $name = '';
+            $imagePath = $this->image_old; // Keep existing if no new image
             if ($this->image) {
-                $name = md5($this->image . microtime()) . '.' . $this->image->extension();
-                $this->image->storeAs('public', $name);
+                $imagePath = $this->fileService->uploadProfileImage($this->image, $this->dataId);
             }
 
+
             $docs = implode("|", $this->document_files);
             $father_docs = implode("|", $this->father_document_files);
             $mother_docs = implode("|", $this->mother_document_files);
@@ -1228,7 +1385,7 @@ class Member extends Component
                 'nation_id' => $this->nation_id > 0 ? $this->nation_id : null,
                 'province_id' => $this->province_id > 0 ? $this->province_id : null,
                 'city_id' => $this->city_id > 0 ? $this->city_id : null,
-                'image' => $name != '' ? $name : $this->image_old,
+                'image' => $imagePath,
                 'phone' => $this->phone,
                 'phone2' => $this->phone2,
                 'phone3' => $this->phone3,
@@ -1760,43 +1917,26 @@ class Member extends Component
 
     public function storeCertificate()
     {
-
         $this->validate(['certificate_expire_date' => 'required']);
-        // $this->validate();
-        try {
-
-            $name = '';
-            try {
 
-                if ($this->certificate_filename) {
-                    $name = md5($this->certificate_filename . microtime()) . '.' . $this->certificate_filename->extension();
-                    $this->certificate_filename->storeAs('public', $name);
-                }
-            } catch (\Exception $ex) {
-                //session()->flash('error','Errore (' . $ex->getMessage() . ')');
+        try {
+            $certificatePath = '';
+            if ($this->certificate_filename) {
+                $certificatePath = $this->fileService->uploadCertificate($this->certificate_filename, $this->dataId);
             }
 
             if ($this->dataId > -1) {
                 \App\Models\MemberCertificate::create([
                     'member_id' => $this->dataId,
                     'type' => $this->certificate_type,
-                    'filename' => $name,
+                    'filename' => $certificatePath,
                     'expire_date' => $this->certificate_expire_date,
                     'status' => $this->certificate_status
                 ]);
                 updateMemberData($this->dataId);
             }
-            /*else
-            {
-                $this->certificateTmp = new \App\Models\MemberCertificate();
-                $this->certificateTmp->type = $this->certificate_type;
-                $this->certificateTmp->filename = $name;
-                $this->certificateTmp->expire_date = $this->certificate_expire_date;
-                $this->certificateTmp->status = $this->certificate_status;
-                $this->certificateTmp->status = $this->certificate_status;
-                // s    $this->member_certificates[] = $certificateTmp;
-            }*/
-            session()->flash('success, Tesserato creato');
+
+            session()->flash('success', 'Certificato creato');
             $this->resetCertificateFields();
             $this->addCertificate = false;
         } catch (\Exception $ex) {
@@ -1827,28 +1967,23 @@ class Member extends Component
     public function updateCertificate()
     {
         $this->validate(['certificate_expire_date' => 'required']);
-        try {
 
-            $name = '';
-            try {
-
-                if ($this->certificate_filename) {
-                    $name = md5($this->certificate_filename . microtime()) . '.' . $this->certificate_filename->extension();
-                    $this->certificate_filename->storeAs('public', $name);
-                }
-            } catch (\Exception $ex) {
-                //session()->flash('error','Errore (' . $ex->getMessage() . ')');
+        try {
+            $certificatePath = $this->certificate_filename_old; // Keep existing if no new file
+            if ($this->certificate_filename) {
+                $certificatePath = $this->fileService->uploadCertificate($this->certificate_filename, $this->dataId);
             }
 
             \App\Models\MemberCertificate::whereId($this->cardCertificateId)->update([
                 'member_id' => $this->dataId,
                 'type' => $this->certificate_type,
-                'filename' => $name != '' ? $name : $this->certificate_filename_old,
+                'filename' => $certificatePath,
                 'expire_date' => $this->certificate_expire_date,
                 'status' => $this->certificate_status
             ]);
+
             updateMemberData($this->dataId);
-            session()->flash('success', 'Tesserato aggiornato');
+            session()->flash('success', 'Certificato aggiornato');
             $this->resetCertificateFields();
             $this->updateCertificate = false;
         } catch (\Exception $ex) {
@@ -1856,6 +1991,15 @@ class Member extends Component
         }
     }
 
+    public function getFileUrl($filePath)
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        return $this->fileService->getFileUrl($filePath);
+    }
+
     public function cancelCertificate()
     {
         $this->addCertificate = false;

+ 722 - 54
app/Http/Livewire/Record.php

@@ -9,28 +9,48 @@ use DateTime;
 
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
-
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Mail;
+use App\Mail\ExportNotification;
+use App\Jobs\ExportPrimaNota;
 
 class Record extends Component
 {
     public $records, $dataId, $totals;
-
     public $in;
     public $out;
-
     public $payments = [];
-
     public $fromDate;
     public $toDate;
-
+    public $appliedFromDate;
+    public $appliedToDate;
+    public $exportFromDate;
+    public $exportToDate;
+    public $isExporting = false;
+    public $selectedPeriod = 'OGGI';
     public $filterCausals = null;
     public $filterMember = null;
-
+    public $isFiltering = false;
     public array $recordDatas = [];
     public array $labels = [];
-
     public array $causals = [];
     public $members = array();
+    public $sendViaEmail = false;
+    public $exportEmailAddress = '';
+    public $exportEmailSubject = 'Prima Nota - Export';
+
+    protected $rules = [
+        'exportEmailAddress' => 'required_if:sendViaEmail,true|email',
+        'exportEmailSubject' => 'required_if:sendViaEmail,true|string|max:255',
+    ];
+
+    protected $messages = [
+        'exportEmailAddress.required_if' => 'L\'indirizzo email è obbligatorio quando si sceglie di inviare via email.',
+        'exportEmailAddress.email' => 'Inserisci un indirizzo email valido.',
+        'exportEmailSubject.required_if' => 'L\'oggetto dell\'email è obbligatorio.',
+        'exportEmailSubject.max' => 'L\'oggetto dell\'email non può superare i 255 caratteri.',
+    ];
 
     public function hydrate()
     {
@@ -39,10 +59,17 @@ class Record extends Component
 
     public function mount()
     {
-
         $this->fromDate = date("Y-m-d");
         $this->toDate = date("Y-m-d");
 
+        $this->appliedFromDate = date("Y-m-d");
+        $this->appliedToDate = date("Y-m-d");
+
+        $this->exportFromDate = date("Y-m-d");
+        $this->exportToDate = date("Y-m-d");
+
+        $this->exportEmailSubject = 'Prima Nota - Export del ' . date('d/m/Y');
+
 
         $this->getCausals(\App\Models\Causal::select('id', 'name')->where('parent_id', null)->get(), 0);
 
@@ -51,6 +78,316 @@ class Record extends Component
         $this->payments = \App\Models\PaymentMethod::select('id', 'name', 'type')->where('enabled', true)->where('money', false)->get();
     }
 
+
+    private function generateExportData($fromDate, $toDate)
+    {
+        $exportRecords = array();
+        $exportTotals = array();
+
+        $exclude_from_records = \App\Models\Member::where('exclude_from_records', true)->pluck('id')->toArray();
+
+        $datas = \App\Models\Record::with('member', 'supplier', 'payment_method')
+            ->select(
+                'records.*',
+                'records_rows.id as row_id',
+                'records_rows.record_id',
+                'records_rows.causal_id',
+                'records_rows.amount',
+                'records_rows.note',
+                'records_rows.when',
+                'records_rows.vat_id',
+                'records_rows.imponibile',
+                'records_rows.aliquota_iva',
+                'records_rows.imposta',
+                'records_rows.divisa',
+                'records_rows.numero_linea',
+                'records_rows.prezzo_unitario',
+                'records_rows.quantita',
+                'records_rows.created_at as row_created_at',
+                'records_rows.updated_at as row_updated_at'
+            )
+            ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
+            ->whereBetween('date', [$fromDate, $toDate])
+            ->where(function ($query) {
+                $query->where('type', 'OUT')
+                    ->orWhere(function ($query) {
+                        $query->where('records.corrispettivo_fiscale', true)
+                            ->orWhere('records.commercial', false);
+                    });
+            })
+            ->where(function ($query) use ($exclude_from_records) {
+                $query->where('type', 'OUT')
+                    ->orWhere(function ($subquery) use ($exclude_from_records) {
+                        $subquery->whereNotIn('member_id', $exclude_from_records);
+                    });
+            });
+
+        if ($this->filterCausals != null && sizeof($this->filterCausals) > 0) {
+            $causals = array();
+            foreach ($this->filterCausals as $z) {
+                $causals[] = $z;
+                $childs = \App\Models\Causal::where('parent_id', $z)->get();
+                foreach ($childs as $c) {
+                    $causals[] = $c->id;
+                    $childsX = \App\Models\Causal::where('parent_id', $c->id)->get();
+                    foreach ($childsX as $cX) {
+                        $causals[] = $cX->id;
+                    }
+                }
+            }
+            $datas->whereIn('causal_id', $causals);
+        }
+        if ($this->filterMember != null && $this->filterMember > 0) {
+            $datas->where('member_id', $this->filterMember);
+        }
+        $datas = $datas->orderBy('date', 'ASC')
+            ->orderBy('records.created_at', 'ASC')
+            ->orderBy('records_rows.id', 'ASC')
+            ->get();
+
+        $groupedData = [];
+        $causalsCount = [];
+
+        foreach ($datas as $idx => $data) {
+            $causalCheck = \App\Models\Causal::findOrFail($data->causal_id);
+            $paymentCheck = $data->payment_method->money;
+
+            if (!$paymentCheck && ($causalCheck->no_first == null || !$causalCheck->no_first)) {
+                if (!$data->deleted) {
+                    $amount = $data->amount;
+                    $amount += getVatValue($amount, $data->vat_id);
+                } else {
+                    $amount = $data->amount;
+                }
+
+                $isCommercial = ($data->commercial == 1 || $data->commercial === '1' || $data->commercial === true);
+                $typeLabel = $isCommercial ? 'Commerciale' : 'Non Commerciale';
+
+                $nominativo = '';
+                if ($data->type == "IN") {
+                    if ($data->member) {
+                        $nominativo = $data->member->last_name . " " . $data->member->first_name;
+                    }
+                } else {
+                    if ($data->supplier) {
+                        $nominativo = $data->supplier->name;
+                    }
+                }
+
+                $groupKey = $data->date . '|' . $typeLabel . '|' . $data->payment_method->name . '|' . $data->type . '|' . $nominativo;
+
+                if (!isset($groupedData[$groupKey])) {
+                    $groupedData[$groupKey] = [
+                        'date' => $data->date,
+                        'type_label' => $typeLabel,
+                        'payment_method' => $data->payment_method->name,
+                        'transaction_type' => $data->type,
+                        'nominativo' => $nominativo,
+                        'amount' => 0,
+                        'deleted' => false,
+                        'causals' => [],
+                        'notes' => []
+                    ];
+                    $causalsCount[$groupKey] = [];
+                }
+
+                $groupedData[$groupKey]['amount'] += $amount;
+                $causalsCount[$groupKey][$causalCheck->getTree()] = true;
+
+                if (!empty($data->note)) {
+                    $groupedData[$groupKey]['notes'][] = $data->note;
+                }
+
+                if ($data->deleted) {
+                    $groupedData[$groupKey]['deleted'] = true;
+                }
+            }
+        }
+
+        foreach ($groupedData as $groupKey => $group) {
+            $causalsInGroup = array_keys($causalsCount[$groupKey]);
+
+            $causalDisplay = $group['type_label'];
+
+            if (count($causalsInGroup) > 1) {
+                $detailDisplay = 'Varie|' . implode('|', $causalsInGroup);
+            } else {
+                $detailDisplay = $causalsInGroup[0];
+            }
+
+            $recordKey = $group['date'] . "§" . $causalDisplay . "§" . $group['nominativo'] . "§" . $detailDisplay . "§" . ($group['deleted'] ? 'DELETED' : '') . "§";
+
+            if (!isset($exportRecords[$recordKey][$group['payment_method']][$group['transaction_type']])) {
+                $exportRecords[$recordKey][$group['payment_method']][$group['transaction_type']] = 0;
+            }
+
+            $exportRecords[$recordKey][$group['payment_method']][$group['transaction_type']] += $group['amount'];
+
+            if (!isset($exportTotals[$group['payment_method']])) {
+                $exportTotals[$group['payment_method']]["IN"] = 0;
+                $exportTotals[$group['payment_method']]["OUT"] = 0;
+            }
+
+            if (!$group['deleted'])
+                $exportTotals[$group['payment_method']][$group['transaction_type']] += $group['amount'];
+        }
+
+        return $exportRecords;
+    }
+
+    private function generateExportTotals($fromDate, $toDate)
+    {
+        $exportTotals = array();
+
+        $exclude_from_records = \App\Models\Member::where('exclude_from_records', true)->pluck('id')->toArray();
+
+        $datas = \App\Models\Record::with('member', 'supplier', 'payment_method')
+            ->select(
+                'records.*',
+                'records_rows.id as row_id',
+                'records_rows.record_id',
+                'records_rows.causal_id',
+                'records_rows.amount',
+                'records_rows.note',
+                'records_rows.when',
+                'records_rows.vat_id',
+                'records_rows.imponibile',
+                'records_rows.aliquota_iva',
+                'records_rows.imposta'
+            )
+            ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
+            ->whereBetween('date', [$fromDate, $toDate])
+            ->where(function ($query) {
+                $query->where('type', 'OUT')
+                    ->orWhere(function ($query) {
+                        $query->where('records.corrispettivo_fiscale', true)
+                            ->orWhere('records.commercial', false);
+                    });
+            })
+            ->where(function ($query) use ($exclude_from_records) {
+                $query->where('type', 'OUT')
+                    ->orWhere(function ($subquery) use ($exclude_from_records) {
+                        $subquery->whereNotIn('member_id', $exclude_from_records);
+                    });
+            });
+
+        if ($this->filterCausals != null && sizeof($this->filterCausals) > 0) {
+            $causals = array();
+            foreach ($this->filterCausals as $z) {
+                $causals[] = $z;
+                $childs = \App\Models\Causal::where('parent_id', $z)->get();
+                foreach ($childs as $c) {
+                    $causals[] = $c->id;
+                    $childsX = \App\Models\Causal::where('parent_id', $c->id)->get();
+                    foreach ($childsX as $cX) {
+                        $causals[] = $cX->id;
+                    }
+                }
+            }
+            $datas->whereIn('causal_id', $causals);
+        }
+        if ($this->filterMember != null && $this->filterMember > 0) {
+            $datas->where('member_id', $this->filterMember);
+        }
+        $datas = $datas->orderBy('date', 'ASC')
+            ->orderBy('records.created_at', 'ASC')
+            ->orderBy('records_rows.id', 'ASC')
+            ->get();
+
+        foreach ($datas as $data) {
+            $causalCheck = \App\Models\Causal::findOrFail($data->causal_id);
+            $paymentCheck = $data->payment_method->money;
+
+            if (!$paymentCheck && ($causalCheck->no_first == null || !$causalCheck->no_first)) {
+                if (!$data->deleted) {
+                    $amount = $data->amount;
+                    $amount += getVatValue($amount, $data->vat_id);
+                } else {
+                    $amount = $data->amount;
+                }
+
+                if (!isset($exportTotals[$data->payment_method->name])) {
+                    $exportTotals[$data->payment_method->name]["IN"] = 0;
+                    $exportTotals[$data->payment_method->name]["OUT"] = 0;
+                }
+
+                if (!$data->deleted)
+                    $exportTotals[$data->payment_method->name][$data->type] += $amount;
+            }
+        }
+
+        return $exportTotals;
+    }
+    public function resetFilters()
+    {
+        $this->selectedPeriod = 'OGGI';
+        $this->filterCausals = [];
+        $this->filterMember = null;
+
+        $today = date("Y-m-d");
+        $this->fromDate = $today;
+        $this->toDate = $today;
+        $this->appliedFromDate = $today;
+        $this->appliedToDate = $today;
+
+        $this->emit('filters-reset');
+    }
+
+    public function applyFilters()
+    {
+        $this->isFiltering = true;
+
+        $this->setPeriodDates();
+
+        $this->appliedFromDate = $this->fromDate;
+        $this->appliedToDate = $this->toDate;
+
+        $this->render();
+
+        $this->isFiltering = false;
+
+        $this->emit('filters-applied');
+    }
+
+    private function setPeriodDates()
+    {
+        $today = now();
+
+        switch ($this->selectedPeriod) {
+            case 'OGGI':
+                $this->fromDate = $today->format('Y-m-d');
+                $this->toDate = $today->format('Y-m-d');
+                break;
+
+            case 'IERI':
+                $yesterday = $today->copy()->subDay();
+                $this->fromDate = $yesterday->format('Y-m-d');
+                $this->toDate = $yesterday->format('Y-m-d');
+                break;
+
+            case 'MESE CORRENTE':
+                $this->fromDate = $today->copy()->startOfMonth()->format('Y-m-d');
+                $this->toDate = $today->copy()->endOfMonth()->format('Y-m-d');
+                break;
+
+            case 'MESE PRECEDENTE':
+                $lastMonth = $today->copy()->subMonth();
+                $this->fromDate = $lastMonth->startOfMonth()->format('Y-m-d');
+                $this->toDate = $lastMonth->endOfMonth()->format('Y-m-d');
+                break;
+
+            case 'ULTIMO TRIMESTRE':
+                $this->fromDate = $today->copy()->subMonths(3)->format('Y-m-d');
+                $this->toDate = $today->format('Y-m-d');
+                break;
+
+            case 'ULTIMO QUADRIMESTRE':
+                $this->fromDate = $today->copy()->subMonths(4)->format('Y-m-d');
+                $this->toDate = $today->format('Y-m-d');
+                break;
+        }
+    }
+
     public function getCausals($records, $indentation)
     {
         foreach ($records as $record) {
@@ -107,9 +444,9 @@ class Record extends Component
         return $ret;
     }
 
+
     public function render()
     {
-
         $month = 0;
         $year = 0;
 
@@ -117,10 +454,23 @@ class Record extends Component
         $this->totals = array();
 
         $exclude_from_records = \App\Models\Member::where('exclude_from_records', true)->pluck('id')->toArray();
+
         $datas = \App\Models\Record::with('member', 'supplier', 'payment_method')
-            ->select('records.*', 'records_rows.*') // Ensure all columns are selected
+            ->select(
+                'records.*',
+                'records_rows.id as row_id',
+                'records_rows.record_id',
+                'records_rows.causal_id',
+                'records_rows.amount',
+                'records_rows.note',
+                'records_rows.when',
+                'records_rows.vat_id',
+                'records_rows.imponibile',
+                'records_rows.aliquota_iva',
+                'records_rows.imposta'
+            )
             ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
-            ->whereBetween('date', [$this->fromDate, $this->toDate])
+            ->whereBetween('date', [$this->appliedFromDate, $this->appliedToDate])
             ->where(function ($query) {
                 $query->where('type', 'OUT')
                     ->orWhere(function ($query) {
@@ -134,6 +484,7 @@ class Record extends Component
                         $subquery->whereNotIn('member_id', $exclude_from_records);
                     });
             });
+
         if ($this->filterCausals != null && sizeof($this->filterCausals) > 0) {
             $causals = array();
             foreach ($this->filterCausals as $z) {
@@ -154,13 +505,16 @@ class Record extends Component
         }
         $datas = $datas->orderBy('date', 'ASC')
             ->orderBy('records.created_at', 'ASC')
-            ->orderBy('records_rows.id', 'ASC') // Important to maintain row order
+            ->orderBy('records_rows.id', 'ASC')
             ->get();
 
+        $groupedData = [];
+        $causalsCount = [];
+        $nominativi = [];
+
         foreach ($datas as $idx => $data) {
 
             $causalCheck = \App\Models\Causal::findOrFail($data->causal_id);
-
             $paymentCheck = $data->payment_method->money;
 
             if (!$paymentCheck && ($causalCheck->no_first == null || !$causalCheck->no_first)) {
@@ -172,35 +526,75 @@ class Record extends Component
                     $amount = $data->amount;
                 }
 
-                $prefix = '';
-                if (!$data->commercial)
-                    $prefix = $idx . "$";
+                $isCommercial = ($data->commercial == 1 || $data->commercial === '1' || $data->commercial === true);
+                $typeLabel = $isCommercial ? 'Commerciale' : 'Non Commerciale';
 
+                $nominativo = '';
+                if ($data->type == "IN") {
+                    if ($data->member) {
+                        $nominativo = $data->member->last_name . " " . $data->member->first_name;
+                    }
+                } else {
+                    if ($data->supplier) {
+                        $nominativo = $data->supplier->name;
+                    }
+                }
 
-                // aggiungere il nome * * *
-                $causal = $prefix . $data->date . "§" . $causalCheck->getTree() . "§" .
-                    ($data->type == "IN" ? ($data->member ? ($data->member->last_name . " " . $data->member->first_name) : "") :
-                        $data->supplier->name ?? "") . "§" . $data->note . "§" . ($data->deleted ? 'DELETED' : '') .
-                    "§" . $data->numero_linea;
+                $groupKey = $data->date . '|' . $typeLabel . '|' . $data->payment_method->name . '|' . $data->type . '|' . $nominativo;
+
+                if (!isset($groupedData[$groupKey])) {
+                    $groupedData[$groupKey] = [
+                        'date' => $data->date,
+                        'type_label' => $typeLabel,
+                        'payment_method' => $data->payment_method->name,
+                        'transaction_type' => $data->type,
+                        'nominativo' => $nominativo,
+                        'amount' => 0,
+                        'deleted' => false,
+                        'causals' => [],
+                        'notes' => []
+                    ];
+                    $causalsCount[$groupKey] = [];
+                    $nominativi[$groupKey] = $nominativo;
+                }
 
-                if (!isset($this->records[$causal][$data->payment_method->name][$data->type])) {
-                    $this->records[$causal][$data->payment_method->name][$data->type] = 0;
+                $groupedData[$groupKey]['amount'] += $amount;
+                $causalsCount[$groupKey][$causalCheck->getTree()] = true;
+                if (!empty($data->note)) {
+                    $groupedData[$groupKey]['notes'][] = $data->note;
                 }
+                if ($data->deleted) {
+                    $groupedData[$groupKey]['deleted'] = true;
+                }
+            }
+        }
 
-                // Add to the records array
-                $this->records[$causal][$data->payment_method->name][$data->type] += $amount;
+        foreach ($groupedData as $groupKey => $group) {
+            $causalsInGroup = array_keys($causalsCount[$groupKey]);
 
-                // Initialize totals if needed
-                if (!isset($this->totals[$data->payment_method->name])) {
-                    $this->totals[$data->payment_method->name]["IN"] = 0;
-                    $this->totals[$data->payment_method->name]["OUT"] = 0;
-                }
+            $causalDisplay = $group['type_label'];
 
-                // Update totals if not deleted
-                if (!$data->deleted)
-                    $this->totals[$data->payment_method->name][$data->type] += $amount; // $data->amount;//$this->records[$causal][$data->payment_method->name][$data->type];
+            if (count($causalsInGroup) > 1) {
+                $detailDisplay = 'Varie|' . implode('|', $causalsInGroup);
+            } else {
+                $detailDisplay = $causalsInGroup[0];
+            }
+
+            $recordKey = $group['date'] . "§" . $causalDisplay . "§" . $group['nominativo'] . "§" . $detailDisplay . "§" . ($group['deleted'] ? 'DELETED' : '') . "§";
 
+            if (!isset($this->records[$recordKey][$group['payment_method']][$group['transaction_type']])) {
+                $this->records[$recordKey][$group['payment_method']][$group['transaction_type']] = 0;
             }
+
+            $this->records[$recordKey][$group['payment_method']][$group['transaction_type']] += $group['amount'];
+
+            if (!isset($this->totals[$group['payment_method']])) {
+                $this->totals[$group['payment_method']]["IN"] = 0;
+                $this->totals[$group['payment_method']]["OUT"] = 0;
+            }
+
+            if (!$group['deleted'])
+                $this->totals[$group['payment_method']][$group['transaction_type']] += $group['amount'];
         }
 
         return view('livewire.records');
@@ -208,7 +602,6 @@ class Record extends Component
 
     private function getLabels($fromDate, $toDate)
     {
-
         $begin = new DateTime($fromDate);
         $end = new DateTime($toDate);
 
@@ -257,10 +650,189 @@ class Record extends Component
 
         return $data;
     }
-    public function export()
+
+    public function openExportModal()
+    {
+        $this->exportFromDate = $this->appliedFromDate;
+        $this->exportToDate = $this->appliedToDate;
+
+        // Reset email options
+        $this->sendViaEmail = false;
+        $this->exportEmailAddress = $this->getPreferredEmail();
+        $this->updateEmailSubject();
+
+        $this->emit('show-export-modal');
+    }
+
+    public function exportWithDateRange()
     {
-        ini_set('memory_limit', '512M');
 
+        $this->isExporting = true;
+        $this->emit('$refresh'); // This forces Livewire to re-render
+
+        // Add a small delay to allow the view to update
+        usleep(100000);
+        if ($this->sendViaEmail) {
+            $this->validate([
+                'exportEmailAddress' => 'required|email',
+                'exportEmailSubject' => 'required|string|max:255',
+            ]);
+        }
+
+        $this->isExporting = true;
+
+        try {
+            $exportRecords = $this->generateExportData($this->exportFromDate, $this->exportToDate);
+            $exportTotals = $this->generateExportTotals($this->exportFromDate, $this->exportToDate);
+
+            if ($this->sendViaEmail) {
+                // Dispatch job to background queue
+                $this->dispatchExportJob($exportRecords, $exportTotals);
+            } else {
+                // Direct download (synchronous)
+                return $this->exportWithData($exportRecords, $exportTotals);
+            }
+        } catch (\Illuminate\Validation\ValidationException $e) {
+            $this->isExporting = false;
+            throw $e;
+        } catch (\Exception $e) {
+            $this->isExporting = false;
+            Log::error('Export error: ' . $e->getMessage());
+
+            if ($this->sendViaEmail) {
+                $this->emit('export-email-error', 'Errore durante l\'invio dell\'email: ' . $e->getMessage());
+            } else {
+                session()->flash('error', 'Errore durante l\'export: ' . $e->getMessage());
+            }
+        } finally {
+            $this->isExporting = false;
+            $this->emit('export-complete');
+            $this->emit('hide-export-modal');
+        }
+    }
+
+    private function getMemberName($memberId)
+    {
+        $member = \App\Models\Member::find($memberId);
+        return $member ? $member->last_name . ' ' . $member->first_name : 'Sconosciuto';
+    }
+
+    private function getCausalsNames($causalIds)
+    {
+        if (!is_array($causalIds)) {
+            return null;
+        }
+
+        $causals = \App\Models\Causal::whereIn('id', $causalIds)->pluck('name')->toArray();
+        return implode(', ', $causals);
+    }
+
+    public function updatedExportFromDate()
+    {
+        $this->updateEmailSubject();
+    }
+
+    public function updatedExportToDate()
+    {
+        $this->updateEmailSubject();
+    }
+
+    public function updatedSendViaEmail($value)
+    {
+        if ($value && empty($this->exportEmailAddress)) {
+            $this->exportEmailAddress = $this->getPreferredEmail();
+        }
+    }
+
+    public function resetEmailForm()
+    {
+        $this->sendViaEmail = false;
+        $this->exportEmailAddress = $this->getPreferredEmail();
+        $this->updateEmailSubject();
+    }
+
+    private function updateEmailSubject()
+    {
+        if (!empty($this->exportFromDate) && !empty($this->exportToDate)) {
+            $fromFormatted = date('d/m/Y', strtotime($this->exportFromDate));
+            $toFormatted = date('d/m/Y', strtotime($this->exportToDate));
+
+            if ($this->exportFromDate === $this->exportToDate) {
+                $this->exportEmailSubject = "Prima Nota - Export del {$fromFormatted}";
+            } else {
+                $this->exportEmailSubject = "Prima Nota - Export dal {$fromFormatted} al {$toFormatted}";
+            }
+        }
+    }
+
+
+    /**
+     * Dispatch export job to queue
+     */
+    private function dispatchExportJob($exportRecords, $exportTotals)
+    {
+        try {
+            // Prepare filter descriptions for the job
+            $filterDescriptions = [
+                'member' => $this->filterMember ? $this->getMemberName($this->filterMember) : null,
+                'causals' => $this->filterCausals ? $this->getCausalsNames($this->filterCausals) : null,
+            ];
+
+            $paymentsArray = $this->payments->map(function ($payment) {
+                return [
+                    'id' => $payment->id,
+                    'name' => $payment->name,
+                    'type' => $payment->type
+                ];
+            })->toArray();
+
+            // Dispatch job to background queue
+            ExportPrimaNota::dispatch(
+                $exportRecords,
+                $exportTotals,
+                $this->exportEmailAddress,
+                $this->exportEmailSubject,
+                [
+                    'from' => date('d/m/Y', strtotime($this->exportFromDate)),
+                    'to' => date('d/m/Y', strtotime($this->exportToDate))
+                ],
+                auth()->id(),
+                $paymentsArray,
+                $filterDescriptions
+            );
+
+            $this->emit('export-email-queued');
+            session()->flash('success', 'Export in corso! Riceverai l\'email a breve alla casella: ' . $this->exportEmailAddress);
+
+            Log::info('Export job dispatched', [
+                'user_id' => auth()->id(),
+                'email' => $this->exportEmailAddress,
+                'date_range' => [$this->exportFromDate, $this->exportToDate],
+                'total_records' => count($exportRecords)
+            ]);
+        } catch (\Exception $e) {
+            Log::error('Failed to dispatch export job', [
+                'user_id' => auth()->id(),
+                'email' => $this->exportEmailAddress,
+                'error' => $e->getMessage()
+            ]);
+
+            throw new \Exception('Errore nell\'avvio dell\'export: ' . $e->getMessage());
+        }
+    }
+
+
+    function export()
+    {
+        $exportRecords = $this->generateExportData($this->appliedFromDate, $this->appliedToDate);
+        $exportTotals = $this->generateExportTotals($this->appliedFromDate, $this->appliedToDate);
+
+        return $this->exportWithData($exportRecords, $exportTotals);
+    }
+
+    private function exportWithData($exportRecords, $exportTotals)
+    {
+        ini_set('memory_limit', '512M');
         gc_enable();
 
         $letters = array('F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA');
@@ -330,24 +902,25 @@ class Record extends Component
         $batchSize = 1000;
         $recordsProcessed = 0;
 
-        $totalRecords = count($this->records);
-        $recordsArray = array_chunk($this->records, $batchSize, true);
+        $totalRecords = count($exportRecords);
+        $recordsArray = array_chunk($exportRecords, $batchSize, true);
 
         foreach ($recordsArray as $recordsBatch) {
             foreach ($recordsBatch as $causal => $record) {
-                $check = strpos($causal, "$") ? explode("$", $causal)[1] : $causal;
-
+                $check = $causal;
                 $parts = explode("§", $check);
                 $d = $parts[0] ?? '';
                 $c = $parts[1] ?? '';
                 $j = $parts[2] ?? '';
-                $k = $parts[3] ?? '';
+                $det = $parts[3] ?? '';
                 $deleted = $parts[4] ?? '';
-                $numeroLinea = $parts[5] ?? '';
+
+                $detailParts = explode('|', $det);
+                $exportDetail = count($detailParts) > 1 ? implode(', ', array_slice($detailParts, 1)) : $det;
 
                 $activeWorksheet->setCellValue('A' . $count, !empty($d) ? date("d/m/Y", strtotime($d)) : '');
                 $activeWorksheet->setCellValue('B' . $count, $c);
-                $activeWorksheet->setCellValue('C' . $count, $k);
+                $activeWorksheet->setCellValue('C' . $count, $exportDetail);
                 $activeWorksheet->setCellValue('D' . $count, $j);
 
                 $stato = ($deleted === 'DELETED') ? 'ANNULLATA' : '';
@@ -405,21 +978,21 @@ class Record extends Component
                 break;
             }
 
-            if (isset($this->totals[$p->name])) {
+            if (isset($exportTotals[$p->name])) {
                 if ($p->type == 'ALL') {
-                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($this->totals[$p->name]["IN"] ?? 0));
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($exportTotals[$p->name]["IN"] ?? 0));
                     $idx++;
-                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($this->totals[$p->name]["OUT"] ?? 0));
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($exportTotals[$p->name]["OUT"] ?? 0));
                     $idx++;
                 } elseif ($p->type == 'IN') {
-                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($this->totals[$p->name]["IN"] ?? 0));
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($exportTotals[$p->name]["IN"] ?? 0));
                     $idx++;
                     $activeWorksheet->setCellValue($letters[$idx] . $count, "");
                     $idx++;
                 } elseif ($p->type == 'OUT') {
                     $activeWorksheet->setCellValue($letters[$idx] . $count, "");
                     $idx++;
-                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($this->totals[$p->name]["OUT"] ?? 0));
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, formatPrice($exportTotals[$p->name]["OUT"] ?? 0));
                     $idx++;
                 }
             } else {
@@ -453,13 +1026,108 @@ class Record extends Component
             $activeWorksheet->getColumnDimension($l)->setWidth(20);
         }
 
-        $writer = new Xlsx($spreadsheet);
-        $path = storage_path('prima_nota_' . date("YmdHis") . '.xlsx');
-        $writer->save($path);
+        $filename = 'prima_nota_' . date("YmdHis") . '.xlsx';
+
+        try {
+            $currentClient = session('currentClient', 'default');
+
+            $tempPath = sys_get_temp_dir() . '/' . $filename;
+
+            $writer = new Xlsx($spreadsheet);
+            $writer->save($tempPath);
+
+            unset($spreadsheet, $activeWorksheet, $writer);
+            gc_collect_cycles();
+
+            $disk = Storage::disk('s3');
+
+            $s3Path = $currentClient . '/prima_nota/' . $filename;
+
+            $primaNotaFolderPath = $currentClient . '/prima_nota/.gitkeep';
+            if (!$disk->exists($primaNotaFolderPath)) {
+                $disk->put($primaNotaFolderPath, '');
+                Log::info("Created prima_nota folder for client: {$currentClient}");
+            }
+
+            $fileContent = file_get_contents($tempPath);
+            $uploaded = $disk->put($s3Path, $fileContent, 'private');
+
+            if (!$uploaded) {
+                throw new \Exception('Failed to upload file to Wasabi S3');
+            }
+
+            Log::info("Prima Nota exported to Wasabi", [
+                'client' => $currentClient,
+                'path' => $s3Path,
+                'size' => filesize($tempPath),
+                'records_processed' => $recordsProcessed
+            ]);
+
+            if (file_exists($tempPath)) {
+                unlink($tempPath);
+            }
+
+            $downloadUrl = $disk->temporaryUrl($s3Path, now()->addHour());
+            return redirect($downloadUrl);
+        } catch (\Exception $e) {
+            Log::error('Error exporting Prima Nota to Wasabi S3', [
+                'error' => $e->getMessage(),
+                'client' => session('currentClient', 'unknown'),
+                'filename' => $filename,
+                'records_processed' => $recordsProcessed ?? 0
+            ]);
+
+            $currentClient = session('currentClient', 'default');
+            $clientFolder = storage_path('app/prima_nota/' . $currentClient);
+
+            if (!is_dir($clientFolder)) {
+                mkdir($clientFolder, 0755, true);
+                Log::info("Created local client prima_nota folder: {$clientFolder}");
+            }
+
+            $localPath = $clientFolder . '/' . $filename;
+
+            if (isset($tempPath) && file_exists($tempPath)) {
+                rename($tempPath, $localPath);
+            } else {
+                $writer = new Xlsx($spreadsheet);
+                $writer->save($localPath);
+                unset($spreadsheet, $activeWorksheet, $writer);
+            }
+
+            gc_collect_cycles();
+
+            Log::warning("Prima Nota saved locally due to S3 error", [
+                'client' => $currentClient,
+                'local_path' => $localPath,
+                'error' => $e->getMessage()
+            ]);
+
+            session()->flash('warning', 'File salvato localmente a causa di un errore del cloud storage.');
+
+            return response()->download($localPath)->deleteFileAfterSend();
+        }
+    }
+
+    private function getPreferredEmail()
+    {
+        // Try multiple sources in order of preference
+        $email = auth()->user()->email ?? null;
 
-        unset($spreadsheet, $activeWorksheet, $writer);
-        gc_collect_cycles();
+        if (empty($email)) {
+            $email = session('user_email', null);
+        }
+
+        if (empty($email)) {
+            $member = \App\Models\Member::where('user_id', auth()->id())->first();
+            $email = $member ? $member->email : null;
+        }
+
+        if (empty($email)) {
+            // Get from user input or company default
+            $email = config('mail.default_recipient', '');
+        }
 
-        return response()->download($path)->deleteFileAfterSend();
+        return $email;
     }
 }

+ 192 - 156
app/Http/Livewire/RecordINOUT.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Http\Livewire;
+
 use Livewire\Component;
 
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
@@ -8,6 +9,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 use Illuminate\Support\Facades\Log;
 use SimpleXMLElement;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Storage;
 
 class RecordINOUT extends Component
 {
@@ -54,18 +56,17 @@ class RecordINOUT extends Component
     public function mount()
     {
 
-        if(Auth::user()->level != env('LEVEL_ADMIN', 0))
+        if (Auth::user()->level != env('LEVEL_ADMIN', 0))
             return redirect()->to('/dashboard');
 
         $borsellino = \App\Models\Causal::where('money', true)->first();
         if ($borsellino)
             $this->excludeCausals[] = $borsellino->id;
-            //$this->borsellino_id = $borsellino->id;
+        //$this->borsellino_id = $borsellino->id;
 
         // Aggiungo
         $excludes = \App\Models\Causal::where('no_records', true)->get();
-        foreach($excludes as $e)
-        {
+        foreach ($excludes as $e) {
             $this->excludeCausals[] = $e->id;
         }
 
@@ -89,38 +90,34 @@ class RecordINOUT extends Component
 
     public function getCausalsIn($records, $indentation)
     {
-        foreach($records as $record)
-        {
+        foreach ($records as $record) {
             $this->causalsIn[] = array('id' => $record->id, 'name' => $record->getTree(), 'text' => $record->getTree(), 'level' => $indentation);
-            if(count($record->childs))
+            if (count($record->childs))
                 $this->getCausalsIn($record->childs, $indentation + 1);
         }
     }
 
     public function getCausalsOut($records, $indentation)
     {
-        foreach($records as $record)
-        {
+        foreach ($records as $record) {
             $this->causalsOut[] = array('id' => $record->id, 'name' => $record->getTree(), 'text' => $record->getTree(), 'level' => $indentation);
-            if(count($record->childs))
+            if (count($record->childs))
                 $this->getCausalsOut($record->childs, $indentation + 1);
         }
     }
 
     public function getCausale($records, $type, $indentation)
     {
-        foreach($records as $record)
-        {
+        foreach ($records as $record) {
             $first_parent_id = null;
-            if ($record->parent_id != null)
-            {
+            if ($record->parent_id != null) {
                 $first_parent_id = \App\Models\Causal::where('id', $record->parent_id)->first()->parent_id;
             }
             if ($type == 'IN')
                 $this->rows_in[] = array('id' => $record->id, 'name' => $record->name, 'level' => $indentation, 'parent_id' => $record->parent_id, 'parent_name' => $this->getCausalName($record->parent_id), 'first_parent_id' => $first_parent_id, 'first_parent_name' => $this->getCausalName($first_parent_id), 'all_childs' => $this->getAllChild($record->id));
             if ($type == 'OUT')
                 $this->rows_out[] = array('id' => $record->id, 'name' => $record->name, 'level' => $indentation, 'parent_id' => $record->parent_id, 'parent_name' => $this->getCausalName($record->parent_id), 'first_parent_id' => $first_parent_id, 'first_parent_name' => $this->getCausalName($first_parent_id), 'all_childs' => $this->getAllChild($record->id));
-            if(count($record->childs))
+            if (count($record->childs))
                 $this->getCausale($record->childs, $type, $indentation + 1);
         }
     }
@@ -133,30 +130,24 @@ class RecordINOUT extends Component
 
         $record = \App\Models\Causal::findOrFail($id);
         $aChilds[] = $record->parent_id;
-        if ($record->parent_id != null)
-        {
+        if ($record->parent_id != null) {
             $first_parent_id = \App\Models\Causal::where('id', $record->parent_id)->first()->parent_id;
             $aChilds[] = $first_parent_id;
         }
 
         $childs = \App\Models\Causal::where('parent_id', $id)->get();
-        foreach($childs as $child)
-        {
+        foreach ($childs as $child) {
             $aChilds[] = $child->id;
             $childs2 = \App\Models\Causal::where('parent_id', $child->id)->get();
-            foreach($childs2 as $child2)
-            {
+            foreach ($childs2 as $child2) {
                 $aChilds[] = $child2->id;
                 $childs3 = \App\Models\Causal::where('parent_id', $child2->id)->get();
-                foreach($childs3 as $child3)
-                {
+                foreach ($childs3 as $child3) {
                     $aChilds[] = $child3->id;
-
                 }
             }
         }
         return $aChilds;
-
     }
 
 
@@ -189,10 +180,8 @@ class RecordINOUT extends Component
 
         $exclude_from_records = \App\Models\Member::where('exclude_from_records', true)->pluck('id')->toArray();
 
-        if (sizeof($this->datas) > 0)
-        {
-            foreach($this->datas as $filter)
-            {
+        if (sizeof($this->datas) > 0) {
+            foreach ($this->datas as $filter) {
                 $this->columns[] = $filter;
 
                 $f = $filter;
@@ -201,24 +190,22 @@ class RecordINOUT extends Component
                 $records = \App\Models\Record::where('type', 'IN')
                     ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
                     ->whereNotIn('records_rows.causal_id', $this->excludeCausals)
-                    ->where(function ($query)  {
+                    ->where(function ($query) {
                         $query->where('deleted', false)->orWhere('deleted', null);
                     })
-                    ->where(function ($query)  {
+                    ->where(function ($query) {
                         $query->where('financial_movement', false)->orWhere('financial_movement', null);
                     })
                     ->whereNotIn('member_id', $exclude_from_records)
                     ->where('records_rows.when', 'like', '%"' . $f . '"%')
                     ->get();
 
-                    //Log::info('Record In' . $records);
-                foreach($records as $record)
-                {
+                //Log::info('Record In' . $records);
+                foreach ($records as $record) {
                     $amount = $record->amount;
                     $amount += getVatValue($amount, $record->vat_id);
                     $when = sizeof(json_decode($record->when));
-                    if ($when > 1)
-                    {
+                    if ($when > 1) {
                         $amount = $amount / $when;
                         $record->amount = $amount;
                     }
@@ -231,25 +218,23 @@ class RecordINOUT extends Component
                     $this->updateParent("IN", $record->causal_id, $amount, $filter);
                 }
                 $records = \App\Models\Record::where('type', 'OUT')
-                ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
-                ->whereNotIn('records_rows.causal_id', $this->excludeCausals)
-                ->where(function ($query) {
-                    $query->where('deleted', false)->orWhere('deleted', null);
-                })
+                    ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
+                    ->whereNotIn('records_rows.causal_id', $this->excludeCausals)
+                    ->where(function ($query) {
+                        $query->where('deleted', false)->orWhere('deleted', null);
+                    })
 
-                ->where(function ($query) use ($exclude_from_records) {
-                    $query->whereNull('member_id')
-                          ->orWhereNotIn('member_id', $exclude_from_records);
-                })
-                ->where('records_rows.when', 'like', '%"' . $f . '"%')->get();
+                    ->where(function ($query) use ($exclude_from_records) {
+                        $query->whereNull('member_id')
+                            ->orWhereNotIn('member_id', $exclude_from_records);
+                    })
+                    ->where('records_rows.when', 'like', '%"' . $f . '"%')->get();
                 Log::info('Record Out' . $records);
-                foreach($records as $record)
-                {
+                foreach ($records as $record) {
                     $amount = $record->amount;
                     $amount += getVatValue($amount, $record->vat_id);
                     $when = sizeof(json_decode($record->when));
-                    if ($when > 1)
-                    {
+                    if ($when > 1) {
                         $amount = $amount / $when;
                         $record->amount = $amount;
                     }
@@ -260,23 +245,18 @@ class RecordINOUT extends Component
                         $this->records_out[$filter][$record->causal_id] = $amount;
                     $this->updateParent("OUT", $record->causal_id, $amount, $filter);
                 }
-
             }
         }
 
         //$this->showData = true;
         $this->emit('load-table');
-
     }
 
     public function updateParent($type, $causal_id, $amount, $filter)
     {
-        if ($type == "IN")
-        {
-            foreach($this->rows_in as $r)
-            {
-                if ($r["id"] == $causal_id)
-                {
+        if ($type == "IN") {
+            foreach ($this->rows_in as $r) {
+                if ($r["id"] == $causal_id) {
                     if (isset($this->records_in[$filter][$r["parent_id"]]))
                         $this->records_in[$filter][$r["parent_id"]] += $amount;
                     else
@@ -286,12 +266,9 @@ class RecordINOUT extends Component
                 }
             }
         }
-        if ($type == "OUT")
-        {
-            foreach($this->rows_out as $r)
-            {
-                if ($r["id"] == $causal_id)
-                {
+        if ($type == "OUT") {
+            foreach ($this->rows_out as $r) {
+                if ($r["id"] == $causal_id) {
                     if (isset($this->records_out[$filter][$r["parent_id"]]))
                         $this->records_out[$filter][$r["parent_id"]] += $amount;
                     else
@@ -305,12 +282,9 @@ class RecordINOUT extends Component
 
     public function updateParentYear($type, $causal_id, $amount, $filter, &$records_in, &$records_out)
     {
-        if ($type == "IN")
-        {
-            foreach($this->rows_in as $r)
-            {
-                if ($r["id"] == $causal_id)
-                {
+        if ($type == "IN") {
+            foreach ($this->rows_in as $r) {
+                if ($r["id"] == $causal_id) {
                     if (isset($records_in[$filter][$r["parent_id"]]))
                         $records_in[$filter][$r["parent_id"]] += $amount;
                     else
@@ -320,12 +294,9 @@ class RecordINOUT extends Component
                 }
             }
         }
-        if ($type == "OUT")
-        {
-            foreach($this->rows_out as $r)
-            {
-                if ($r["id"] == $causal_id)
-                {
+        if ($type == "OUT") {
+            foreach ($this->rows_out as $r) {
+                if ($r["id"] == $causal_id) {
                     if (isset($records_out[$filter][$r["parent_id"]]))
                         $records_out[$filter][$r["parent_id"]] += $amount;
                     else
@@ -340,8 +311,7 @@ class RecordINOUT extends Component
     public function getCausal($causal)
     {
         $ret = '';
-        if ($causal > 0)
-        {
+        if ($causal > 0) {
             $ret = \App\Models\Causal::findOrFail($causal)->getTree();
         }
         return $ret;
@@ -415,7 +385,7 @@ class RecordINOUT extends Component
 
         if (sizeof($this->datas) > 1)
             array_splice($this->datas, $idx, 1);
-            //unset($this->datas[$idx]);
+        //unset($this->datas[$idx]);
         else
             $this->datas = array();
 
@@ -427,25 +397,34 @@ class RecordINOUT extends Component
 
         $rows_in = array();
 
-        if ($this->filterCausalsIn != null && sizeof($this->filterCausalsIn) > 0)
-        {
-            foreach($this->rows_in as $r)
-            {
-                if (in_array($r["id"], $this->filterCausalsIn) || in_array($r["parent_id"], $this->filterCausalsIn) || in_array($r["first_parent_id"], $this->filterCausalsIn))
-                {
+        if ($this->filterCausalsIn != null && sizeof($this->filterCausalsIn) > 0) {
+            foreach ($this->rows_in as $r) {
+                if (in_array($r["id"], $this->filterCausalsIn) || in_array($r["parent_id"], $this->filterCausalsIn) || in_array($r["first_parent_id"], $this->filterCausalsIn)) {
                     $rows_in[] = $r;
                 }
             }
-        }
-        else
-        {
+        } else {
             $rows_in = $this->rows_in;
         }
-
-        $path = $this->generateExcel($this->columns, $rows_in, $this->records_in, $this->rows_out, $this->records_out,false);
-
-        return response()->download($path)->deleteFileAfterSend();
-
+        $result = $this->generateExcel($this->columns, $rows_in, $this->records_in, $this->rows_out, $this->records_out, false);
+
+        if ($result['storage_type'] === 's3') {
+            try {
+                $disk = Storage::disk('s3');
+                $downloadUrl = $disk->temporaryUrl($result['path'], now()->addHour());
+
+                return redirect($downloadUrl);
+            } catch (\Exception $e) {
+                Log::error('Error generating S3 download URL for export', [
+                    'error' => $e->getMessage(),
+                    'path' => $result['path']
+                ]);
+                session()->flash('error', 'Errore durante la generazione del link di download. Riprova.');
+                return back();
+            }
+        } else {
+            return response()->download($result['path'])->deleteFileAfterSend();
+        }
     }
 
     public function exportYear($year)
@@ -461,46 +440,39 @@ class RecordINOUT extends Component
             {
                 $datas[] = $m . "-" . $year;
             }
-            for($m=1;$m<=env('FISCAL_YEAR_MONTH_TO', 12);$m++)
-            {
+            for ($m = 1; $m <= env('FISCAL_YEAR_MONTH_TO', 12); $m++) {
                 $datas[] = $m . "-" . ($year + 1);
             }
-        }
-        else
-        {
-            for($m=1;$m<=12;$m++)
-            {
+        } else {
+            for ($m = 1; $m <= 12; $m++) {
                 $datas[] = $m . "-" . $year;
             }
         }
 
         $exclude_from_records = \App\Models\Member::where('exclude_from_records', true)->pluck('id')->toArray();
 
-        foreach($datas as $filter)
-        {
+        foreach ($datas as $filter) {
 
             $columns[] = $filter;
 
             $records = \App\Models\Record::where('type', 'IN')
                 ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
                 ->whereNotIn('records_rows.causal_id', $this->excludeCausals)
-                ->where(function ($query)  {
+                ->where(function ($query) {
                     $query->where('deleted', false)->orWhere('deleted', null);
                 })
-                ->where(function ($query)  {
+                ->where(function ($query) {
                     $query->where('financial_movement', false)->orWhere('financial_movement', null);
                 })
                 ->whereNotIn('member_id', $exclude_from_records)
                 ->where('records_rows.when', 'like', '%"' . $filter . '"%')->get();
             //$records = $records->orderBy('date', 'DESC')->get();
 
-            foreach($records as $record)
-            {
+            foreach ($records as $record) {
                 $amount = $record->amount;
                 $amount += getVatValue($amount, $record->vat_id);
                 $when = sizeof(json_decode($record->when));
-                if ($when > 1)
-                {
+                if ($when > 1) {
                     $amount = $amount / $when;
                     $record->amount = $amount;
                 }
@@ -516,21 +488,19 @@ class RecordINOUT extends Component
             $records = \App\Models\Record::where('type', 'OUT')
                 ->join('records_rows', 'records.id', '=', 'records_rows.record_id')
                 ->whereNotIn('records_rows.causal_id', $this->excludeCausals)
-                ->where(function ($query)  {
+                ->where(function ($query) {
                     $query->where('deleted', false)->orWhere('deleted', null);
                 })
                 ->where(function ($query) use ($exclude_from_records) {
                     $query->whereNull('member_id')
-                          ->orWhereNotIn('member_id', $exclude_from_records);
+                        ->orWhereNotIn('member_id', $exclude_from_records);
                 })
                 ->where('records_rows.when', 'like', '%"' . $filter . '"%')->get();
             //$records = $records->orderBy('date', 'DESC')->get();
-            foreach($records as $record)
-            {
+            foreach ($records as $record) {
                 $amount = $record->amount;
                 $when = sizeof(json_decode($record->when));
-                if ($when > 1)
-                {
+                if ($when > 1) {
                     $amount = $amount / $when;
                     $record->amount = $amount;
                 }
@@ -541,12 +511,17 @@ class RecordINOUT extends Component
                     $records_out[$filter][$record->causal_id] = $amount;
                 $this->updateParentYear("OUT", $record->causal_id, $amount, $filter, $records_in, $records_out);
             }
-
         }
 
-        $path = $this->generateExcel($columns, $this->rows_in, $records_in, $this->rows_out, $records_out, true);
-        return response()->download($path)->deleteFileAfterSend();
+        $result = $this->generateExcel($columns, $this->rows_in, $records_in, $this->rows_out, $records_out, true);
 
+        if ($result['storage_type'] === 's3') {
+            $disk = Storage::disk('s3');
+            $downloadUrl = $disk->temporaryUrl($result['path'], now()->addHour());
+            return redirect($downloadUrl);
+        } else {
+            return response()->download($result['path'])->deleteFileAfterSend();
+        }
     }
 
     public function generateExcel($columns, $rows_in, $records_in, $rows_out, $records_out, $isYearExport)
@@ -557,8 +532,7 @@ class RecordINOUT extends Component
         $activeWorksheet = $spreadsheet->getActiveSheet();
 
         $activeWorksheet->setCellValue('A1', 'Entrate');
-        foreach($columns as $idx => $column)
-        {
+        foreach ($columns as $idx => $column) {
             $activeWorksheet->setCellValue($letters[$idx + 1] . '1', $this->getMonth($column));
         }
 
@@ -569,17 +543,13 @@ class RecordINOUT extends Component
 
         $totals = [];
 
-        foreach($rows_in as $in)
-        {
+        foreach ($rows_in as $in) {
             $activeWorksheet->setCellValue('A' . $count, str_repeat("  ", $in["level"]) . $in["name"]);
 
-            foreach($columns as $idx => $column)
-            {
-                if(isset($records_in[$column][$in["id"]]))
-                {
+            foreach ($columns as $idx => $column) {
+                if (isset($records_in[$column][$in["id"]])) {
                     $activeWorksheet->setCellValue($letters[$idx + 1] . $count, formatPrice($records_in[$column][$in["id"]]));
-                    if ($in["level"] == 0)
-                    {
+                    if ($in["level"] == 0) {
                         if (isset($totals[$idx]))
                             $totals[$idx] += $records_in[$column][$in["id"]];
                         else
@@ -588,30 +558,23 @@ class RecordINOUT extends Component
                 }
             }
 
-            if ($in["level"] == 0)
-            {
+            if ($in["level"] == 0) {
                 $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFont()->setBold(true);
-                //$activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB('b1ed5c');
             }
 
             $count += 1;
-
         }
 
         $activeWorksheet->setCellValue('A' . $count, 'Totale');
-        foreach($totals as $idx => $total)
-        {
+        foreach ($totals as $idx => $total) {
             $activeWorksheet->setCellValue($letters[$idx + 1] . $count, formatPrice($total));
             $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFont()->setBold(true);
             $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB('C6E0B4'); // Lighter green
         }
 
-
-
         $count += 2;
         $activeWorksheet->setCellValue('A' . $count, "Uscite");
-        foreach($columns as $idx => $column)
-        {
+        foreach ($columns as $idx => $column) {
             $activeWorksheet->setCellValue($letters[$idx + 1] . $count, $this->getMonth($column));
         }
 
@@ -622,17 +585,13 @@ class RecordINOUT extends Component
 
         $totals = [];
 
-        foreach($rows_out as $out)
-        {
+        foreach ($rows_out as $out) {
             $activeWorksheet->setCellValue('A' . $count, str_repeat("  ", $out["level"]) . $out["name"]);
 
-            foreach($columns as $idx => $column)
-            {
-                if(isset($records_out[$column][$out["id"]]))
-                {
+            foreach ($columns as $idx => $column) {
+                if (isset($records_out[$column][$out["id"]])) {
                     $activeWorksheet->setCellValue($letters[$idx + 1] . $count, formatPrice($records_out[$column][$out["id"]]));
-                    if ($out["level"] == 0)
-                    {
+                    if ($out["level"] == 0) {
                         if (isset($totals[$idx]))
                             $totals[$idx] += $records_out[$column][$out["id"]];
                         else
@@ -641,19 +600,15 @@ class RecordINOUT extends Component
                 }
             }
 
-            if ($out["level"] == 0)
-            {
+            if ($out["level"] == 0) {
                 $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFont()->setBold(true);
-                //$activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB('ed6d61');
             }
 
             $count += 1;
-
         }
 
         $activeWorksheet->setCellValue('A' . $count, 'Totale');
-        foreach($totals as $idx => $total)
-        {
+        foreach ($totals as $idx => $total) {
             $activeWorksheet->setCellValue($letters[$idx + 1] . $count, formatPrice($total));
             $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFont()->setBold(true);
             $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB('F8CBAD'); // Lighter red
@@ -661,17 +616,98 @@ class RecordINOUT extends Component
 
         $activeWorksheet->getColumnDimension('A')->setWidth(35);
 
-        for($i = 1; $i < count($letters); $i++) {
+        for ($i = 1; $i < count($letters); $i++) {
             $activeWorksheet->getColumnDimension($letters[$i])->setWidth(20);
         }
 
-
         $fileSuffix = $isYearExport ? 'AnnoFiscale' : 'Selezione';
+        $filename = date("Ymd") . '_Gestionale_' . $fileSuffix . '.xlsx';
+
+        try {
+            // Get current client for folder organization
+            $currentClient = session('currentClient', 'default');
+
+            // Create temporary file to write Excel data
+            $tempPath = sys_get_temp_dir() . '/' . $filename;
+
+            // Write to temporary file
+            $writer = new Xlsx($spreadsheet);
+            $writer->save($tempPath);
 
-        $writer = new Xlsx($spreadsheet);
-        $writer->save($path = storage_path(date("Ymd") .'_Gestionale_' . $fileSuffix .  '.xlsx'));
+            // Get Wasabi disk
+            $disk = Storage::disk('s3');
 
-        return $path;
+            // Create the S3 path (client folder + reports subfolder)
+            $s3Path = $currentClient . '/reports/' . $filename;
 
+            // Ensure the client reports folder exists
+            $reportsFolderPath = $currentClient . '/reports/.gitkeep';
+            if (!$disk->exists($reportsFolderPath)) {
+                $disk->put($reportsFolderPath, '');
+                Log::info("Created reports folder for client: {$currentClient}");
+            }
+
+            // Upload file to Wasabi S3
+            $fileContent = file_get_contents($tempPath);
+            $uploaded = $disk->put($s3Path, $fileContent, 'private');
+
+            if (!$uploaded) {
+                throw new \Exception('Failed to upload file to Wasabi S3');
+            }
+
+            Log::info("Management report uploaded to Wasabi", [
+                'client' => $currentClient,
+                'path' => $s3Path,
+                'size' => filesize($tempPath),
+                'type' => $fileSuffix
+            ]);
+
+            // Clean up temporary file
+            if (file_exists($tempPath)) {
+                unlink($tempPath);
+            }
+
+            // Return S3 path for further processing or download URL generation
+            return [
+                'success' => true,
+                'path' => $s3Path,
+                'local_path' => null,
+                'storage_type' => 's3'
+            ];
+        } catch (\Exception $e) {
+            Log::error('Error uploading management report to Wasabi S3', [
+                'error' => $e->getMessage(),
+                'client' => session('currentClient', 'unknown'),
+                'filename' => $filename
+            ]);
+
+            // Fallback to local storage if S3 fails
+            $currentClient = session('currentClient', 'default');
+            $clientFolder = storage_path('app/reports/' . $currentClient);
+
+            // Ensure client folder exists
+            if (!is_dir($clientFolder)) {
+                mkdir($clientFolder, 0755, true);
+                Log::info("Created local client reports folder: {$clientFolder}");
+            }
+
+            $localPath = $clientFolder . '/' . $filename;
+            $writer = new Xlsx($spreadsheet);
+            $writer->save($localPath);
+
+            Log::warning("Management report saved locally due to S3 error", [
+                'client' => $currentClient,
+                'local_path' => $localPath,
+                'error' => $e->getMessage()
+            ]);
+
+            // Return local path for backward compatibility
+            return [
+                'success' => true,
+                'path' => $localPath,
+                'local_path' => $localPath,
+                'storage_type' => 'local'
+            ];
+        }
     }
 }

+ 448 - 112
app/Http/Livewire/RecordOUT.php

@@ -9,7 +9,8 @@ use SimpleXMLElement;
 use Livewire\WithFileUploads;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
-
+use App\Services\RecordFileService;
+use App\Jobs\ProcessRecordAttachment;
 
 class RecordOUT extends Component
 {
@@ -63,6 +64,7 @@ class RecordOUT extends Component
         $add = false,
         $is_paid = false;
     public $attachment;
+    public $attachment_old;
 
     public $filterSupplier = 0, $filterPaymentMethod = 0, $filterCausals = [], $filterFrom = '', $filterTo = '', $filterCommercial = 0;
 
@@ -89,6 +91,9 @@ class RecordOUT extends Component
     public $vats = array();
 
     public $numero_fattura;
+    public $attachmentUploadStatus = 'none';
+    public $uploadProgress = 0;
+    public $uploadStartTime = null;
 
 
     protected $rules = [
@@ -105,6 +110,13 @@ class RecordOUT extends Component
         'rows.*.causal_id.required' => 'La causale è obbligatoria'
     ];
 
+    protected $recordFileService;
+
+    public function boot()
+    {
+        $this->recordFileService = app(RecordFileService::class);
+    }
+
     public function getSupplierProperty()
     {
         $ret = null;
@@ -130,10 +142,12 @@ class RecordOUT extends Component
         $this->payment_method_id = null;
         $this->date = date("Y-m-d");
         $this->data_pagamento = date("Y-m-d");
-        //$this->attachment = null;
+        $this->attachment = null;
+        $this->attachment_old = null;
         $this->numero_fattura = null;
         $this->type = 'OUT';
         $this->commercial = 1;
+        $this->dataId = null;
         $this->rows = array();
         $this->rows[] = array(
             'causal_id' => null,
@@ -146,7 +160,6 @@ class RecordOUT extends Component
         );
         $this->emit('load-data-table');
     }
-
     public function getCausale($records, $indentation)
     {
         foreach ($records as $record) {
@@ -180,7 +193,6 @@ class RecordOUT extends Component
         }
 
         $this->vats = \App\Models\Vat::select('id', 'name', 'value')->orderBy('value')->get();
-
     }
 
     public function mount()
@@ -286,7 +298,7 @@ class RecordOUT extends Component
         if (false) {
             if ($this->hasFilter) {
 
-                $datas = \App\Models\Record::where('type', 'OUT')->with('supplier', 'payment_method','is_paid');
+                $datas = \App\Models\Record::where('type', 'OUT')->with('supplier', 'payment_method', 'is_paid');
                 if ($this->filterSupplier > 0) {
                     $datas = $datas->where('supplier_id', $this->filterSupplier);
                 }
@@ -363,78 +375,178 @@ class RecordOUT extends Component
         $this->emit('setEdit', true);
     }
 
+
+
     public function store()
     {
+        $this->emit('start-loading', 'Validazione dati...');
         $this->emit('refresh');
-        /*
-        if ($this->attachment) {
-            Log::info("Attachment: " . json_encode($this->attachment));
-            $name = md5($this->attachment . microtime()) . '.' . $this->attachment->extension();
-            $this->attachment->storeAs('public', $name);
-        } */
 
-        if($this->numero_fattura == null || $this->numero_fattura == '') {
+        if ($this->numero_fattura == null || $this->numero_fattura == '') {
             $this->numero_fattura = 'USC-' . date('Ymd');
         }
 
-
         if (empty($this->data_pagamento) || $this->data_pagamento == '1970-01-01') {
             $this->data_pagamento = null;
-            Log::info("Setting data_pagamento to NULL in store");
             $is_paid = false;
         } else {
-            Log::info("Using data_pagamento: " . $this->data_pagamento);
             $is_paid = true;
         }
 
+        if ($this->attachment) {
+            $fileSize = $this->attachment->getSize();
+            $fileName = $this->attachment->getClientOriginalName();
+            $mimeType = $this->attachment->getMimeType();
+            $maxSize = 50 * 1024 * 1024;
+
+            Log::info("=== FILE VALIDATION ===");
+            Log::info("File: {$fileName}");
+            Log::info("Size: " . round($fileSize / 1024 / 1024, 2) . " MB");
+            Log::info("MIME: {$mimeType}");
+
+            if ($fileSize > $maxSize) {
+                $this->emit('stop-loading');
+                $this->emit('flash-error', 'File troppo grande. Dimensione massima: 50MB');
+                return;
+            }
+
+            $allowedMimes = [
+                'image/jpeg',
+                'image/jpg',
+                'image/png',
+                'image/gif',
+                'image/webp',
+                'application/pdf',
+                'application/msword',
+                'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+                'text/xml',
+                'application/xml',
+                'text/plain',
+                'text/csv',
+                'application/vnd.ms-excel',
+                'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+            ];
+
+            if (!in_array($mimeType, $allowedMimes)) {
+                $this->emit('stop-loading');
+                $this->emit('flash-error', 'Tipo di file non supportato');
+                return;
+            }
+
+            Log::info("File validation passed");
+        }
+
         $this->validate();
+
         try {
+            $this->emit('update-loading', 'Salvataggio record...');
+
+            DB::beginTransaction();
+
             $record = \App\Models\Record::create([
                 'member_id' => $this->member_id,
                 'supplier_id' => $this->supplier_id,
                 'payment_method_id' => $this->payment_method_id,
                 'date' => $this->date,
-                'data_pagamento' =>$this->data_pagamento,
-                //'attachment' => $this->attachment,
+                'data_pagamento' => $this->data_pagamento,
+                'attachment' => '',
                 'type' => $this->type,
-                'amount' => $this->currencyToDouble($this->amount),
+                'amount' => 0,
                 'commercial' => $this->commercial,
                 'numero_fattura' => $this->numero_fattura,
                 'is_paid' => $is_paid,
+                'attachment_status' => $this->attachment ? 'pending' : 'none',
             ]);
-            Log::info("Record data being inserted: " . json_encode($record));
 
             $this->dataId = $record->id;
+            Log::info("Record created with ID: {$this->dataId}");
+
             $tot = 0;
+            $rowsData = [];
+
             foreach ($this->rows as $row) {
                 foreach ($row["when"] as $x => $y) {
                     $row["when"][$x]['period'] = $row["when"][$x]['month'] . "-" . $row["when"][$x]['year'];
                 }
+
                 $imponibile = isset($row["imponibile"]) ? $this->currencyToDouble($row["imponibile"]) : null;
-                Log::info("Imponibile store: " . $imponibile);
                 $aliquota_iva = isset($row["aliquota_iva"]) ? floatval(str_replace('%', '', $row["aliquota_iva"])) : null;
-                Log::info("Aliquota IVA store: " . $aliquota_iva);
-                \App\Models\RecordRow::create([
+                $amount = $this->currencyToDouble($row["amount"]);
+
+                $rowsData[] = [
                     'record_id' => $this->dataId,
                     'causal_id' => $row["causal_id"],
                     'note' => $row["note"],
-                    'amount' => $this->currencyToDouble($row["amount"]),
+                    'amount' => $amount,
                     'imponibile' => $imponibile,
                     'aliquota_iva' => $aliquota_iva,
                     'commercial' => $row["commercial"],
-                    'when' => json_encode($row["when"])
-                ]);
-                $tot += $this->currencyToDouble($row["amount"]);
+                    'when' => json_encode($row["when"]),
+                    'created_at' => now(),
+                    'updated_at' => now()
+                ];
+                $tot += $amount;
             }
+
+            \App\Models\RecordRow::insert($rowsData);
             $record->amount = $tot;
             $record->save();
 
-            session()->flash('success', 'Movimento creato');
+            DB::commit();
+            Log::info("✅ Database transaction committed");
+
+            if ($this->attachment) {
+                $this->emit('update-loading', 'Preparazione file per elaborazione...');
+
+                try {
+                    Log::info("=== STARTING FILE PROCESSING ===");
+
+                    $tempPath = $this->recordFileService->storeTemporarily($this->attachment);
+                    $originalFileName = $this->attachment->getClientOriginalName();
+
+                    Log::info(" File stored temporarily at: {$tempPath}");
+
+                    $clientName = session('clientName', 'default');
+
+                    ProcessRecordAttachment::dispatch(
+                        $record->id,
+                        $tempPath,
+                        $originalFileName,
+                        'out',
+                        $clientName
+                    )->onQueue('attachments');
+
+                    Log::info("File processing job dispatched for record {$record->id}");
+                } catch (\Exception $ex) {
+                    Log::error("Failed to process file: " . $ex->getMessage());
+                    Log::error("Stack trace: " . $ex->getTraceAsString());
+
+                    DB::table('records')
+                        ->where('id', $record->id)
+                        ->update(['attachment_status' => 'failed']);
+
+                    session()->flash('warning', 'Record creato ma elaborazione allegato fallita. Prova a modificare il record e caricare nuovamente il file.');
+                }
+            }
+
+            $this->emit('stop-loading');
+
+            if ($this->attachment) {
+                session()->flash('success', 'Movimento creato con successo. L\'allegato verrà elaborato in background. Aggiorna la pagina tra qualche secondo per vedere lo stato.');
+            } else {
+                session()->flash('success', 'Movimento creato con successo.');
+            }
+
             $this->resetFields();
             $this->add = false;
             $this->emit('setEdit', false);
         } catch (\Exception $ex) {
-            $this->emit('flash-error', 'Errore (' . $ex->getMessage() . ')');
+            DB::rollback();
+            $this->emit('stop-loading');
+
+            Log::error("Store error: " . $ex->getMessage());
+            Log::error("Stack trace: " . $ex->getTraceAsString());
+            $this->emit('flash-error', 'Errore durante il salvataggio: ' . $ex->getMessage());
         }
     }
 
@@ -455,6 +567,31 @@ class RecordOUT extends Component
         return $value;
     }
 
+    public function removeAttachment()
+    {
+        if ($this->attachment_old) {
+            try {
+                $this->recordFileService->deleteAttachment($this->attachment_old);
+
+                if ($this->dataId) {
+                    DB::table('records')
+                        ->where('id', $this->dataId)
+                        ->update([
+                            'attachment' => '',
+                        ]);
+                }
+
+                $this->attachment_old = '';
+                $this->attachmentUploadStatus = 'none';
+
+                session()->flash('success', 'Allegato rimosso con successo');
+            } catch (\Exception $e) {
+                Log::error("Error removing attachment: " . $e->getMessage());
+                $this->emit('flash-error', 'Errore durante la rimozione dell\'allegato');
+            }
+        }
+    }
+
     public function edit($id)
     {
         if (!isset($_GET["from"]) && $this->fromPage == '')
@@ -462,25 +599,36 @@ class RecordOUT extends Component
         $this->emit('setEdit', true);
         $this->emit('load-select');
         $this->emit('hide-search');
+
         try {
             $record = \App\Models\Record::findOrFail($id);
             if (!$record) {
                 $this->emit('flash-error', 'Movimento non trovato');
             } else {
+
                 $this->member_id = $record->member_id;
                 $this->supplier_id = $record->supplier_id;
                 $this->payment_method_id = $record->payment_method_id;
                 $this->date = date("Y-m-d", strtotime($record->date));
-                $this->data_pagamento =  $record->data_pagamento;
+                $this->data_pagamento = $record->data_pagamento;
                 $this->type = $record->type;
                 $this->numero_fattura = $record->numero_fattura;
-                //$attachment = $record->attachment;
+
+                if (!empty($record->attachment) && $record->attachment !== null && $record->attachment !== '') {
+                    $this->attachment_old = $record->attachment;
+                } else {
+                    $this->attachment_old = '';
+                }
+
+                $this->attachment = null;
+
                 $this->commercial = $record->commercial;
                 $this->dataId = $record->id;
                 $this->update = true;
                 $this->add = false;
 
                 $this->rows = [];
+                $this->recordFileService->createRecordFolders($record->id, 'OUT');
 
                 $recordRows = \App\Models\RecordRow::where('record_id', $this->dataId)->get();
 
@@ -526,30 +674,88 @@ class RecordOUT extends Component
                         }
                     }
 
-
                     $this->rows[] = $rowData;
                 }
+
+                Log::info("EDIT DEBUG - Final attachment_old value: '" . $this->attachment_old . "'");
+
+                $this->emit('refresh');
             }
         } catch (\Exception $ex) {
             $this->emit('flash-error', 'Errore (' . $ex->getMessage() . ')');
+            Log::error("EDIT ERROR: " . $ex->getMessage());
         }
     }
 
     public function update()
     {
+        $this->emit('start-loading', 'Validazione dati...');
         $this->emit('refresh');
-        $this->validate();
-        if (empty($this->data_pagamento) || $this->data_pagamento == '') {
+
+        if ($this->numero_fattura == null || $this->numero_fattura == '') {
+            $this->numero_fattura = 'USC-' . date('Ymd');
+        }
+
+        if (empty($this->data_pagamento) || $this->data_pagamento == '1970-01-01') {
             $this->data_pagamento = null;
-            Log::info("Data pagamento vuota, imposto a NULL in update");
             $is_paid = false;
         } else {
-            Log::info("Data pagamento in update: " . $this->data_pagamento);
             $is_paid = true;
         }
 
+        if ($this->attachment) {
+            $fileSize = $this->attachment->getSize();
+            $fileName = $this->attachment->getClientOriginalName();
+            $mimeType = $this->attachment->getMimeType();
+            $maxSize = 50 * 1024 * 1024;
+
+            Log::info("=== FILE VALIDATION (UPDATE) ===");
+            Log::info("File: {$fileName}");
+            Log::info("Size: " . round($fileSize / 1024 / 1024, 2) . " MB");
+            Log::info("MIME: {$mimeType}");
+
+            if ($fileSize > $maxSize) {
+                $this->emit('stop-loading');
+                $this->emit('flash-error', 'File troppo grande. Dimensione massima: 50MB');
+                return;
+            }
+
+            $allowedMimes = [
+                'image/jpeg',
+                'image/jpg',
+                'image/png',
+                'image/gif',
+                'image/webp',
+                'application/pdf',
+                'application/msword',
+                'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+                'text/xml',
+                'application/xml',
+                'text/plain',
+                'text/csv',
+                'application/vnd.ms-excel',
+                'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+            ];
+
+            if (!in_array($mimeType, $allowedMimes)) {
+                $this->emit('stop-loading');
+                $this->emit('flash-error', 'Tipo di file non supportato');
+                return;
+            }
+
+            Log::info("File validation passed");
+        }
+
+        $this->validate();
+
         try {
-            \App\Models\Record::whereId($this->dataId)->update([
+            $this->emit('update-loading', 'Aggiornamento record...');
+
+            DB::beginTransaction();
+
+            $record = \App\Models\Record::findOrFail($this->dataId);
+
+            $record->update([
                 'member_id' => $this->member_id,
                 'supplier_id' => $this->supplier_id,
                 'payment_method_id' => $this->payment_method_id,
@@ -557,12 +763,12 @@ class RecordOUT extends Component
                 'data_pagamento' => $this->data_pagamento,
                 'type' => $this->type,
                 'commercial' => $this->commercial,
-                'is_paid' => $is_paid,
                 'numero_fattura' => $this->numero_fattura,
-                //'attachment' => $this->attachment,
+                'is_paid' => $is_paid,
+                'attachment_status' => $this->attachment ? 'pending' : ($this->attachment_old ? 'completed' : 'none'),
             ]);
 
-            $tot = 0;
+            Log::info("Record updated with ID: {$this->dataId}");
 
             $existingRows = \App\Models\RecordRow::where('record_id', $this->dataId)
                 ->select('id', 'quantita', 'numero_linea')
@@ -572,42 +778,32 @@ class RecordOUT extends Component
 
             \App\Models\RecordRow::where('record_id', $this->dataId)->delete();
 
+            $tot = 0;
+            $newRowsData = [];
+
             foreach ($this->rows as $row) {
                 foreach ($row["when"] as $x => $y) {
                     $row["when"][$x]['period'] = $row["when"][$x]['month'] . "-" . $row["when"][$x]['year'];
                 }
 
-                $imponibile = null;
-                if (isset($row["imponibile"]) && $row["imponibile"] !== null && $row["imponibile"] !== '') {
-                    $imponibile = $this->currencyToDouble($row["imponibile"]);
-                    Log::info("Imponibile: " . $imponibile);
-                }
-
-                $aliquota_iva = null;
-                if (isset($row["aliquota_iva"]) && $row["aliquota_iva"] !== null && $row["aliquota_iva"] !== '') {
-                    $aliquota_iva = floatval(str_replace('%', '', $row["aliquota_iva"]));
-                    Log::info("Aliquota IVA: " . $aliquota_iva);
-                }
-
+                $imponibile = isset($row["imponibile"]) ? $this->currencyToDouble($row["imponibile"]) : null;
+                $aliquota_iva = isset($row["aliquota_iva"]) ? floatval(str_replace('%', '', $row["aliquota_iva"])) : null;
                 $amount = $this->currencyToDouble($row["amount"]);
-
-                $imposta = null;
-                if ($imponibile !== null) {
-                    $imposta = $amount - $imponibile;
-                    Log::info("Imposta calculated: " . $imposta);
-                }
+                $imposta = $imponibile !== null ? $amount - $imponibile : null;
 
                 $recordRowData = [
                     'record_id' => $this->dataId,
                     'causal_id' => $row["causal_id"],
                     'note' => $row["note"],
-                    'amount' => $this->currencyToDouble($row["amount"]),
-                    'commercial' => $row["commercial"],
-                    'when' => json_encode($row["when"]),
+                    'amount' => $amount,
                     'imponibile' => $imponibile,
                     'aliquota_iva' => $aliquota_iva,
                     'imposta' => $imposta,
+                    'commercial' => $row["commercial"],
+                    'when' => json_encode($row["when"]),
                     'divisa' => 'EUR',
+                    'created_at' => now(),
+                    'updated_at' => now()
                 ];
 
                 if (isset($row["id"]) && isset($existingRows[$row["id"]])) {
@@ -616,21 +812,74 @@ class RecordOUT extends Component
                     $recordRowData['numero_linea'] = $existingRow['numero_linea'];
                 }
 
-                Log::info("RecordRowData: " . json_encode($recordRowData));
-                \App\Models\RecordRow::create($recordRowData);
-                $tot += $this->currencyToDouble($row["amount"]);
+                $newRowsData[] = $recordRowData;
+                $tot += $amount;
+            }
+
+            \App\Models\RecordRow::insert($newRowsData);
+            $record->amount = $tot;
+            $record->save();
+
+            DB::commit();
+            Log::info("Database transaction committed");
+
+            if ($this->attachment) {
+                $this->emit('update-loading', 'Preparazione file per elaborazione...');
+
+                try {
+                    Log::info("=== STARTING FILE PROCESSING (UPDATE) ===");
+
+                    if ($this->attachment_old) {
+                        $this->recordFileService->deleteAttachment($this->attachment_old);
+                        Log::info("Old attachment deleted: {$this->attachment_old}");
+                    }
+
+                    $tempPath = $this->recordFileService->storeTemporarily($this->attachment);
+                    $originalFileName = $this->attachment->getClientOriginalName();
+
+                    Log::info("File stored temporarily at: {$tempPath}");
+
+                    $clientName = session('clientName', 'default');
+
+                    ProcessRecordAttachment::dispatch(
+                        $record->id,
+                        $tempPath,
+                        $originalFileName,
+                        'out',
+                        $clientName
+                    )->onQueue('attachments');
+
+                    Log::info("File processing job dispatched for record {$record->id}");
+                } catch (\Exception $ex) {
+                    Log::error("Failed to process file: " . $ex->getMessage());
+                    Log::error("Stack trace: " . $ex->getTraceAsString());
+
+                    DB::table('records')
+                        ->where('id', $record->id)
+                        ->update(['attachment_status' => 'failed']);
+
+                    session()->flash('warning', 'Record aggiornato ma elaborazione allegato fallita. Prova a modificare il record e caricare nuovamente il file.');
+                }
             }
 
-            $rec = \App\Models\Record::findOrFail($this->dataId);
-            $rec->amount = $tot;
-            $rec->save();
+            $this->emit('stop-loading');
+
+            if ($this->attachment) {
+                session()->flash('success', 'Movimento aggiornato con successo. L\'allegato verrà elaborato in background. Aggiorna la pagina tra qualche secondo per vedere lo stato.');
+            } else {
+                session()->flash('success', 'Movimento aggiornato con successo.');
+            }
 
-            session()->flash('success', 'Movimento aggiornato');
             $this->resetFields();
             $this->update = false;
             $this->emit('setEdit', false);
         } catch (\Exception $ex) {
-            $this->emit('flash-error', 'Errore (' . $ex->getMessage() . ')');
+            DB::rollback();
+            $this->emit('stop-loading');
+
+            Log::error("Update error: " . $ex->getMessage());
+            Log::error("Stack trace: " . $ex->getTraceAsString());
+            $this->emit('flash-error', 'Errore durante l\'aggiornamento: ' . $ex->getMessage());
         }
     }
 
@@ -645,18 +894,28 @@ class RecordOUT extends Component
     public function delete($id)
     {
         try {
-            \App\Models\Record::find($id)->delete();
+            $record = \App\Models\Record::find($id);
+            if ($record->attachment) {
+                $this->recordFileService->deleteAttachment($record->attachment);
+            }
+
+            $record->delete();
             session()->flash('success', "Movimento eliminato");
         } catch (\Exception $e) {
             $this->emit('flash-error', 'Errore (' . $e->getMessage() . ')');
         }
     }
-
     public function multipleDelete()
     {
         try {
             foreach ($this->multipleIds as $id) {
-                \App\Models\Record::find($id)->delete();
+                $record = \App\Models\Record::find($id);
+
+                if ($record->attachment) {
+                    $this->recordFileService->deleteAttachment($record->attachment);
+                }
+
+                $record->delete();
             }
         } catch (\Exception $e) {
             $this->emit('flash-error', 'Errore (' . $e->getMessage() . ')');
@@ -664,6 +923,15 @@ class RecordOUT extends Component
         $this->multipleAction = '';
     }
 
+    public function getAttachmentUrl($filePath)
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        return $this->recordFileService->getAttachmentUrl($filePath);
+    }
+
     function currencyToDouble($val)
     {
         $x = str_replace("€", "", $val);
@@ -749,10 +1017,10 @@ class RecordOUT extends Component
     public function importReceipts()
     {
         $this->validate([
-            //'receiptFiles.*' => 'required|mimes:xml|max:2048',
             'selectedCausal' => 'required|exists:causals,id',
         ]);
         Log::info("Importazione ricevute: " . json_encode($this->receiptFiles));
+
         try {
             $importCount = 0;
             $updateCount = 0;
@@ -771,6 +1039,7 @@ class RecordOUT extends Component
                 try {
                     $fileName = $receiptFile->getClientOriginalName();
                     Log::info("Elaborazione file: " . $fileName);
+
                     // Carica e analizza il file XML
                     $xmlString = file_get_contents($receiptFile->getRealPath());
                     $xml = simplexml_load_string($xmlString);
@@ -792,7 +1061,7 @@ class RecordOUT extends Component
                     // Estrai i dati dalla fattura elettronica
                     $fatturaData = $this->extractFatturaData($xml);
 
-                    // Trova o crea il fornitorez
+                    // Trova o crea il fornitore
                     $supplier = $this->findOrCreateSupplier($fatturaData);
 
                     // Trova il metodo di pagamento
@@ -808,16 +1077,27 @@ class RecordOUT extends Component
                         $record = $this->updateRecord($existingRecord, $paymentMethodId, $fatturaData);
                         $isUpdate = true;
                         $updateCount++;
-                        $updatedFiles[] = $fileName; // Aggiungiamo il nome del file alla lista degli aggiornati
+                        $updatedFiles[] = $fileName;
                         Log::info("Fattura aggiornata con successo: {$fatturaData['numeroFattura']}, Fornitore: {$supplier->name}");
                     } else {
                         // Crea un nuovo record
                         $record = $this->createRecord($supplier->id, $paymentMethodId, $fatturaData);
                         $importCount++;
-                        $importedFiles[] = $fileName; // Aggiungiamo il nome del file alla lista degli importati
+                        $importedFiles[] = $fileName;
                         Log::info("Fattura importata con successo: {$fatturaData['numeroFattura']}, Fornitore: {$supplier->name}");
                     }
 
+                    $this->recordFileService->createRecordFolders($record->id, 'OUT');
+
+                    try {
+                        $xmlAttachmentPath = $this->recordFileService->uploadXmlReceipt($receiptFile, $record->id, 'OUT');
+                        $record->update(['attachment' => $xmlAttachmentPath]);
+
+                        Log::info("XML receipt stored as attachment: " . $xmlAttachmentPath);
+                    } catch (\Exception $ex) {
+                        Log::warning("Could not store XML as attachment: " . $ex->getMessage());
+                    }
+
                     // Crea il record row
                     $this->createRecordRow($record->id, $fatturaData);
 
@@ -1104,7 +1384,7 @@ class RecordOUT extends Component
 
         if ($record->data_pagamento != null) {
             $record->is_paid = true;
-        }else {
+        } else {
             $record->is_paid = false;
         }
 
@@ -1439,7 +1719,8 @@ class RecordOUT extends Component
         $this->reset(['receiptFiles']);
     }
 
-    private function getFriendlyErrorMessage($errorMessage) {
+    private function getFriendlyErrorMessage($errorMessage)
+    {
         // Errore di parsing XML iniziale
         if (strpos($errorMessage, "simplexml_load_string(): Entity: line 1: parser error : Start tag expected, '<' not found") !== false) {
             return "Il file non è in formato valido. Potrebbe essere danneggiato o in un formato diverso.";
@@ -1456,8 +1737,10 @@ class RecordOUT extends Component
         }
 
         // Errori di struttura interna
-        if (strpos($errorMessage, "Undefined index") !== false ||
-            strpos($errorMessage, "Trying to get property") !== false) {
+        if (
+            strpos($errorMessage, "Undefined index") !== false ||
+            strpos($errorMessage, "Trying to get property") !== false
+        ) {
             return "La fattura è incompleta o non contiene tutti i dati necessari.";
         }
 
@@ -1468,14 +1751,11 @@ class RecordOUT extends Component
     public function getVats()
     {
         $vats = array();
-        foreach($this->rows as $r)
-        {
-            if ($r["amount"] != null && $r["amount"] != "" && $r["vat_id"] > 0)
-            {
+        foreach ($this->rows as $r) {
+            if ($r["amount"] != null && $r["amount"] != "" && $r["vat_id"] > 0) {
                 $vat = getVatValue($this->currencyToDouble($r["amount"]), $r["vat_id"]);
                 $vatName = "";
-                foreach($this->vats as $v)
-                {
+                foreach ($this->vats as $v) {
                     if ($v->id == $r["vat_id"])
                         $vatName = $v->name;
                 }
@@ -1519,39 +1799,95 @@ class RecordOUT extends Component
         return $newVat->id;
     }
 
-public function viewData($id)
-{
-    Log::info("Visualizzazione dati per ID: " . $id);
-    try {
-        $record = \App\Models\Record::with(['supplier', 'payment_method', 'rows.causal'])->findOrFail($id);
+    public function viewData($id)
+    {
+        Log::info("Visualizzazione dati per ID: " . $id);
+        try {
+            $record = \App\Models\Record::with(['supplier', 'payment_method', 'rows.causal'])->findOrFail($id);
 
-        if (!$record) {
-            $this->emit('flash-error', 'Movimento non trovato');
-            return;
+            if (!$record) {
+                $this->emit('flash-error', 'Movimento non trovato');
+                return;
+            }
+
+            $record->formatted_date = date("d/m/Y", strtotime($record->date));
+            $record->formatted_data_pagamento = $record->data_pagamento ? date("d/m/Y", strtotime($record->data_pagamento)) : 'Non impostata';
+            $record->supplier_name = $record->supplier ? $record->supplier->name : 'N/A';
+            $record->payment_method_name = $record->payment_method ? $record->payment_method->name : 'N/A';
+            $record->formatted_amount = formatPrice($record->amount);
+            $record->payment_status = $record->is_paid ? 'Pagato' : 'Da Pagare';
+
+            foreach ($record->rows as $row) {
+                $row->causal_name = $row->causal ? $row->causal->getTree() : 'N/A';
+                $row->formatted_imponibile = $row->imponibile ? formatPrice($row->imponibile) : 'N/A';
+                $row->iva = $row->aliquota_iva ? formatPrice($row->aliquota_iva) : 'N/A';
+                $row->formatted_imposta = $row->imposta ? formatPrice($row->imposta) : 'N/A';
+                $row->formatted_amount = formatPrice($row->amount);
+            }
+
+            if ($record->attachment) {
+                $record->attachment_url = $this->getAttachmentUrl($record->attachment);
+            }
+
+            Log::info("Emitting show-record-details event");
+            $this->dispatchBrowserEvent('show-record-details', ['record' => $record]);
+        } catch (\Exception $e) {
+            Log::error("Errore nel caricamento dei dettagli: " . $e->getMessage());
+            $this->emit('flash-error', 'Errore nel caricamento dei dettagli: ' . $e->getMessage());
         }
+    }
+
+    public function getUploadProgressProperty()
+    {
+        return $this->uploadProgress;
+    }
 
-        $record->formatted_date = date("d/m/Y", strtotime($record->date));
-        $record->formatted_data_pagamento = $record->data_pagamento ? date("d/m/Y", strtotime($record->data_pagamento)) : 'Non impostata';
-        $record->supplier_name = $record->supplier ? $record->supplier->name : 'N/A';
-        $record->payment_method_name = $record->payment_method ? $record->payment_method->name : 'N/A';
-        $record->formatted_amount = formatPrice($record->amount);
-        $record->payment_status = $record->is_paid ? 'Pagato' : 'Da Pagare';
-
-        foreach ($record->rows as $row) {
-            $row->causal_name = $row->causal ? $row->causal->getTree() : 'N/A';
-            $row->formatted_imponibile = $row->imponibile ? formatPrice($row->imponibile) : 'N/A';
-            $row->iva = $row->aliquota_iva ? formatPrice($row->aliquota_iva) : 'N/A';
-            $row->formatted_imposta = $row->imposta ? formatPrice($row->imposta) : 'N/A';
-            $row->formatted_amount = formatPrice($row->amount);
+    /**
+     * Get upload status
+     */
+    public function getUploadStatusProperty()
+    {
+        return $this->attachmentUploadStatus;
+    }
+
+    /**
+     * Check if file upload is in progress
+     */
+    public function getIsUploadingProperty()
+    {
+        return $this->attachmentUploadStatus === 'pending';
+    }
+
+    /**
+     * Get estimated upload time remaining (in seconds)
+     */
+    public function getEstimatedTimeRemainingProperty()
+    {
+        if (!$this->uploadStartTime || $this->uploadProgress <= 0) {
+            return null;
         }
 
-        Log::info("Emitting show-record-details event");
-        $this->dispatchBrowserEvent('show-record-details', ['record' => $record]);
+        $elapsed = microtime(true) - $this->uploadStartTime;
+        $rate = $this->uploadProgress / $elapsed;
+        $remaining = (100 - $this->uploadProgress) / $rate;
 
-    } catch (\Exception $e) {
-        Log::error("Errore nel caricamento dei dettagli: " . $e->getMessage());
-        $this->emit('flash-error', 'Errore nel caricamento dei dettagli: ' . $e->getMessage());
+        return max(0, round($remaining));
     }
-}
 
+    /**
+     * Get file upload speed (KB/s)
+     */
+    public function getUploadSpeedProperty()
+    {
+        if (!$this->uploadStartTime || !$this->attachment) {
+            return null;
+        }
+
+        $elapsed = microtime(true) - $this->uploadStartTime;
+        $fileSize = $this->attachment->getSize();
+        $uploadedBytes = ($this->uploadProgress / 100) * $fileSize;
+        $speed = $uploadedBytes / $elapsed; // bytes per second
+
+        return round($speed / 1024, 1); // KB per second
+    }
 }

+ 104 - 41
app/Http/Livewire/Reports.php

@@ -253,8 +253,8 @@ class Reports extends Component
 
             $treeName = $tempCausal->getTree();
 
-            $displayName = strlen($treeName) > 30 ? substr($treeName, 0, 27) . '...' : $treeName;
-
+            //$displayName = strlen($treeName) > 30 ? substr($treeName, 0, 27) . '...' : $treeName;
+            $displayName = $treeName;
             $inData[] = [
                 'label' => $displayName,
                 'value' => $causal->total_amount,
@@ -287,7 +287,7 @@ class Reports extends Component
         Log::info('Getting courses for season: ' . $this->seasonFilter);
         Log::info('Season years: ' . json_encode($seasonYears));
 
-        $courses = Course::with(['level', 'type', 'frequency'])
+        $courses = Course::with(['level', 'frequency'])
             ->where('active', true)
             ->where(function ($query) use ($seasonYears) {
                 $query->where('year', $this->seasonFilter)
@@ -318,13 +318,10 @@ class Reports extends Component
                 return false;
             })
             ->map(function ($course) {
-                $type = null;
-                if (!empty($course->course_type_id)) {
-                    $type = \App\Models\CourseType::find($course->course_type_id);
-                }
+                Log::info('Processing course: ' . $course->name . ' (ID: ' . $course->id . ')' . $course);
 
                 $levelName = is_object($course->level) ? $course->level->name : 'No Level';
-                $typeName = is_object($type) ? $type->name : 'No Type';
+                $typeName = $course->getFormattedTypeField();
                 $frequencyName = is_object($course->frequency) ? $course->frequency->name : 'No Frequency';
                 $year = $course->year ?? '';
 
@@ -431,11 +428,11 @@ class Reports extends Component
             ];
         }
 
-        $memberCourses = \App\Models\MemberCourse::where('course_id', $courseId)
-            ->with('member')
-            ->get();
+        $rates = \App\Models\Rate::whereHas('member_course', function ($query) use ($courseId) {
+            $query->where('course_id', $courseId);
+        })->with('member_course')->get();
 
-        if ($memberCourses->isEmpty()) {
+        if ($rates->isEmpty()) {
             return [
                 'labels' => [],
                 'datasets' => [],
@@ -447,24 +444,25 @@ class Reports extends Component
 
         $hasData = false;
 
-        foreach ($memberCourses as $memberCourse) {
-            $price = (float)($memberCourse->price ?? 0);
+        foreach ($rates as $rate) {
+            $totalPrice = (float)($rate->price ?? 0);
+
+            if ($rate->months) {
+                $monthsData = json_decode($rate->months, true);
 
-            if ($memberCourse->months) {
-                $monthsData = json_decode($memberCourse->months, true);
+                if (is_array($monthsData) && count($monthsData) > 0) {
+                    $pricePerMonth = $totalPrice / count($monthsData);
 
-                if (is_array($monthsData)) {
-                    foreach ($monthsData as $monthData) {
-                        $month = $monthData['m'] ?? null;
-                        $status = $monthData['status'] ?? '';
+                    foreach ($monthsData as $month) {
+                        $monthNumber = (int)$month;
 
-                        if ($month !== null && isset($monthlyData[$month])) {
-                            $monthlyData[$month]['total'] += $price;
-                            $monthlyData[$month]['participants']++;
+                        if (isset($monthlyData[$monthNumber])) {
+                            $monthlyData[$monthNumber]['total'] += $pricePerMonth;
+                            $monthlyData[$monthNumber]['participants']++;
                             $hasData = true;
 
-                            if ($status === 1) {
-                                $monthlyData[$month]['earned'] += $price;
+                            if (!is_null($rate->record_id) && $rate->record_id !== '') {
+                                $monthlyData[$monthNumber]['earned'] += $pricePerMonth;
                             }
                         }
                     }
@@ -491,7 +489,7 @@ class Reports extends Component
         foreach ($monthOrder as $month) {
             $earned = round($monthlyData[$month]['earned'], 2);
             $total = round($monthlyData[$month]['total'], 2);
-            $delta = max(0, $total - $earned); // Only positive deltas (missing amounts)
+            $delta = max(0, $total - $earned);
             $participants = $monthlyData[$month]['participants'];
 
             $labels[] = $monthNames[$month];
@@ -499,7 +497,6 @@ class Reports extends Component
             $totalData[] = $total;
             $participantData[] = $participants;
 
-            // Fix percentage calculation: earned/total * 100 (not delta-based)
             $percentage = $total > 0 ? round(($earned / $total) * 100, 1) : 0;
 
             $tableData[] = [
@@ -556,18 +553,29 @@ class Reports extends Component
 
         $startYear = $endYear - $span + 1;
 
-        $memberCards = MemberCard::select('member_id', 'expire_date')
+        $memberCards = MemberCard::select('member_id', 'expire_date', 'card_id')
+            ->with('card:id,name')
             ->whereNotNull('expire_date')
             ->whereNotNull('member_id')
+            ->whereNotNull('card_id')
             ->where('status', '!=', 'cancelled')
             ->whereRaw('YEAR(expire_date) >= ?', [$startYear])
             ->whereRaw('YEAR(expire_date) <= ?', [$endYear])
             ->get();
 
+        $cardTypes = $memberCards->pluck('card.name')->unique()->filter()->sort()->values();
+
         $seasonCounts = [];
+        $seasonCardCounts = [];
+
         for ($year = $startYear; $year <= $endYear; $year++) {
             $seasonPeriod = ($year - 1) . '-' . $year;
             $seasonCounts[$seasonPeriod] = [];
+            $seasonCardCounts[$seasonPeriod] = [];
+
+            foreach ($cardTypes as $cardType) {
+                $seasonCardCounts[$seasonPeriod][$cardType] = [];
+            }
         }
 
         foreach ($memberCards as $card) {
@@ -582,32 +590,87 @@ class Reports extends Component
 
             if (isset($seasonCounts[$seasonPeriod])) {
                 $seasonCounts[$seasonPeriod][$card->member_id] = true;
+
+                $cardTypeName = $card->card->name ?? 'Unknown';
+                if (isset($seasonCardCounts[$seasonPeriod][$cardTypeName])) {
+                    $seasonCardCounts[$seasonPeriod][$cardTypeName][$card->member_id] = true;
+                }
             }
         }
 
         $seasonLabels = [];
         $memberCountData = [];
+        $cardTypeDatasets = [];
+
+        $colors = [
+            'rgba(255, 99, 132, 0.2)',
+            'rgba(54, 162, 235, 0.2)',
+            'rgba(255, 205, 86, 0.2)',
+            'rgba(75, 192, 192, 0.2)',
+            'rgba(153, 102, 255, 0.2)',
+            'rgba(255, 159, 64, 0.2)',
+            'rgba(199, 199, 199, 0.2)',
+            'rgba(83, 102, 255, 0.2)',
+        ];
+
+        $borderColors = [
+            'rgba(255, 99, 132, 1)',
+            'rgba(54, 162, 235, 1)',
+            'rgba(255, 205, 86, 1)',
+            'rgba(75, 192, 192, 1)',
+            'rgba(153, 102, 255, 1)',
+            'rgba(255, 159, 64, 1)',
+            'rgba(199, 199, 199, 1)',
+            'rgba(83, 102, 255, 1)',
+        ];
+
+        foreach ($cardTypes as $index => $cardType) {
+            $cardTypeDatasets[$cardType] = [
+                'label' => $cardType,
+                'data' => [],
+                'backgroundColor' => $colors[$index % count($colors)],
+                'borderColor' => $borderColors[$index % count($borderColors)],
+                'borderWidth' => 2,
+                'pointBackgroundColor' => $borderColors[$index % count($borderColors)],
+                'pointRadius' => 4,
+                'tension' => 0.3,
+                'fill' => true
+            ];
+        }
 
         foreach ($seasonCounts as $seasonPeriod => $members) {
             $seasonLabels[] = $seasonPeriod;
             $memberCountData[] = count($members);
+
+            foreach ($cardTypes as $cardType) {
+                $cardTypeCount = isset($seasonCardCounts[$seasonPeriod][$cardType])
+                    ? count($seasonCardCounts[$seasonPeriod][$cardType])
+                    : 0;
+                $cardTypeDatasets[$cardType]['data'][] = $cardTypeCount;
+            }
+        }
+
+        $datasets = [
+            [
+                'label' => 'Totale Membri Tesserati',
+                'data' => $memberCountData,
+                'backgroundColor' => 'rgba(54, 162, 235, 0.2)',
+                'borderColor' => 'rgba(54, 162, 235, 1)',
+                'borderWidth' => 3,
+                'pointBackgroundColor' => 'rgba(54, 162, 235, 1)',
+                'pointRadius' => 6,
+                'tension' => 0.3,
+                'fill' => true,
+                'type' => 'line'
+            ]
+        ];
+        foreach ($cardTypeDatasets as $dataset) {
+            $datasets[] = $dataset;
         }
 
         return [
             'labels' => $seasonLabels,
-            'datasets' => [
-                [
-                    'label' => 'Membri Tesserati',
-                    'data' => $memberCountData,
-                    'backgroundColor' => 'rgba(54, 162, 235, 0.2)',
-                    'borderColor' => 'rgba(54, 162, 235, 1)',
-                    'borderWidth' => 2,
-                    'pointBackgroundColor' => 'rgba(54, 162, 235, 1)',
-                    'pointRadius' => 4,
-                    'tension' => 0.3,
-                    'fill' => true
-                ]
-            ]
+            'datasets' => $datasets
         ];
     }
 }

+ 378 - 0
app/Jobs/ExportPrimaNota.php

@@ -0,0 +1,378 @@
+<?php
+
+namespace App\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Log;
+use App\Mail\ExportNotification;
+use App\Events\ExportCompleted;
+use App\Events\ExportFailed;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class ExportPrimaNota implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public $timeout = 600;
+    public $tries = 3;
+    public $maxExceptions = 3;
+
+    protected $exportData;
+    protected $exportTotals;
+    protected $emailAddress;
+    protected $emailSubject;
+    protected $dateRange;
+    protected $userId;
+    protected $payments;
+    protected $filters;
+
+    /**
+     * Create a new job instance.
+     */
+    public function __construct($exportData, $exportTotals, $emailAddress, $emailSubject, $dateRange, $userId, $payments, $filters = [])
+    {
+        $this->exportData = $exportData;
+        $this->exportTotals = $exportTotals;
+        $this->emailAddress = $emailAddress;
+        $this->emailSubject = $emailSubject;
+        $this->dateRange = $dateRange;
+        $this->userId = $userId;
+        $this->payments = $payments;
+        $this->filters = $filters;
+
+        $this->onQueue('exports');
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle()
+    {
+        try {
+            Log::info('Starting background export', [
+                'user_id' => $this->userId,
+                'email' => $this->emailAddress,
+                'date_range' => $this->dateRange,
+                'total_records' => count($this->exportData)
+            ]);
+
+            ini_set('memory_limit', '1024M');
+
+            $filename = 'prima_nota_' . date("Ymd_His") . '_' . $this->userId . '.xlsx';
+            $tempPath = sys_get_temp_dir() . '/' . $filename;
+
+            $this->createExcelFile($tempPath);
+
+            if (!file_exists($tempPath) || filesize($tempPath) === 0) {
+                throw new \Exception('Excel file creation failed');
+            }
+
+            $fileSize = filesize($tempPath);
+            $maxSize = 25 * 1024 * 1024;
+
+            if ($fileSize > $maxSize) {
+                throw new \Exception('File too large for email attachment (' . round($fileSize / 1024 / 1024, 2) . 'MB > 25MB)');
+            }
+
+            $user = \App\Models\User::find($this->userId);
+
+            $emailData = [
+                'subject' => $this->emailSubject,
+                'from_date' => $this->dateRange['from'],
+                'to_date' => $this->dateRange['to'],
+                'total_records' => count($this->exportData),
+                'user_name' => $user ? $user->name : 'Utente',
+                'generated_at' => now()->format('d/m/Y H:i:s'),
+                'filters_applied' => $this->getFiltersDescription(),
+                'file_size' => round($fileSize / 1024 / 1024, 2) . ' MB'
+            ];
+
+            Mail::to($this->emailAddress)->send(new ExportNotification($emailData, $tempPath, $filename));
+
+            if (class_exists(ExportCompleted::class)) {
+                broadcast(new ExportCompleted($this->userId, $filename, $this->emailAddress));
+            }
+
+            Log::info('Background export completed successfully', [
+                'user_id' => $this->userId,
+                'email' => $this->emailAddress,
+                'filename' => $filename,
+                'file_size' => $fileSize,
+                'processing_time' => microtime(true) - LARAVEL_START ?? 0
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('Background export failed', [
+                'user_id' => $this->userId,
+                'email' => $this->emailAddress,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            if (class_exists(ExportFailed::class)) {
+                broadcast(new ExportFailed($this->userId, $e->getMessage()));
+            }
+
+            throw $e;
+        } finally {
+            if (isset($tempPath) && file_exists($tempPath)) {
+                unlink($tempPath);
+            }
+
+            gc_collect_cycles();
+        }
+    }
+
+    /**
+     * Handle a job failure.
+     */
+    public function failed(\Throwable $exception)
+    {
+        Log::error('Export job failed permanently', [
+            'user_id' => $this->userId,
+            'email' => $this->emailAddress,
+            'attempts' => $this->attempts(),
+            'error' => $exception->getMessage()
+        ]);
+
+        try {
+            Mail::raw(
+                "Il tuo export della Prima Nota non è riuscito dopo {$this->tries} tentativi.\n\n" .
+                "Errore: {$exception->getMessage()}\n\n" .
+                "Contatta il supporto tecnico se il problema persiste.",
+                function ($message) {
+                    $message->to($this->emailAddress)
+                           ->subject('Export Prima Nota - Errore');
+                }
+            );
+        } catch (\Exception $e) {
+            Log::error('Failed to send failure notification email', [
+                'user_id' => $this->userId,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * Create Excel file with export data
+     */
+    private function createExcelFile($filePath)
+    {
+        $letters = array('F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA');
+
+        $spreadsheet = new Spreadsheet();
+        $activeWorksheet = $spreadsheet->getActiveSheet();
+
+        $activeWorksheet->setTitle('Prima Nota');
+
+        $spreadsheet->getProperties()
+            ->setCreator('Prima Nota System')
+            ->setLastModifiedBy('Sistema')
+            ->setTitle('Prima Nota Export')
+            ->setSubject('Export Prima Nota')
+            ->setDescription('Export dei dati Prima Nota dal ' . $this->dateRange['from'] . ' al ' . $this->dateRange['to']);
+
+        $activeWorksheet->setCellValue('A1', "Data");
+        $activeWorksheet->setCellValue('B1', "Causale");
+        $activeWorksheet->setCellValue('C1', "Dettaglio Causale");
+        $activeWorksheet->setCellValue('D1', "Nominativo");
+        $activeWorksheet->setCellValue('E1', "Stato");
+
+        $idx = 0;
+        foreach ($this->payments as $p) {
+            if ($idx >= count($letters)) break;
+
+            $activeWorksheet->setCellValue($letters[$idx] . '1', $p['name']);
+            $activeWorksheet->mergeCells($letters[$idx] . '1:' . $letters[$idx + 1] . '1');
+            $idx += 2;
+        }
+
+        $activeWorksheet->setCellValue('A2', "");
+        $activeWorksheet->setCellValue('B2', "");
+        $activeWorksheet->setCellValue('C2', "");
+        $activeWorksheet->setCellValue('D2', "");
+        $activeWorksheet->setCellValue('E2', "");
+
+        $idx = 0;
+        foreach ($this->payments as $p) {
+            if ($idx >= count($letters) - 1) break;
+
+            if ($p['type'] == 'ALL') {
+                $activeWorksheet->setCellValue($letters[$idx] . '2', "Entrate");
+                $idx++;
+                $activeWorksheet->setCellValue($letters[$idx] . '2', "Uscite");
+                $idx++;
+            } elseif ($p['type'] == 'IN') {
+                $activeWorksheet->setCellValue($letters[$idx] . '2', "Entrate");
+                $idx++;
+                $activeWorksheet->setCellValue($letters[$idx] . '2', "");
+                $idx++;
+            } elseif ($p['type'] == 'OUT') {
+                $activeWorksheet->setCellValue($letters[$idx] . '2', "");
+                $idx++;
+                $activeWorksheet->setCellValue($letters[$idx] . '2', "Uscite");
+                $idx++;
+            }
+        }
+
+        $activeWorksheet->getStyle('A1:' . $letters[min(count($letters) - 1, count($this->payments) * 2 + 4)] . '2')
+                       ->getFont()->setBold(true);
+
+        $activeWorksheet->getStyle('A1:' . $letters[min(count($letters) - 1, count($this->payments) * 2 + 4)] . '1')
+                       ->getFill()
+                       ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+                       ->getStartColor()->setARGB('FF0C6197');
+
+        $activeWorksheet->getStyle('A1:' . $letters[min(count($letters) - 1, count($this->payments) * 2 + 4)] . '1')
+                       ->getFont()->getColor()->setARGB('FFFFFFFF');
+
+        $count = 3;
+        $batchSize = 500;
+        $processed = 0;
+
+        foreach ($this->exportData as $causal => $record) {
+            $parts = explode("§", $causal);
+            $d = $parts[0] ?? '';
+            $c = $parts[1] ?? '';
+            $j = $parts[2] ?? '';
+            $det = $parts[3] ?? '';
+            $deleted = $parts[4] ?? '';
+
+            $detailParts = explode('|', $det);
+            $exportDetail = count($detailParts) > 1 ? implode(', ', array_slice($detailParts, 1)) : $det;
+
+            $activeWorksheet->setCellValue('A' . $count, !empty($d) ? date("d/m/Y", strtotime($d)) : '');
+            $activeWorksheet->setCellValue('B' . $count, $c);
+            $activeWorksheet->setCellValue('C' . $count, $exportDetail);
+            $activeWorksheet->setCellValue('D' . $count, $j);
+
+            $stato = ($deleted === 'DELETED') ? 'ANNULLATA' : '';
+            $activeWorksheet->setCellValue('E' . $count, $stato);
+
+            if ($stato === 'ANNULLATA') {
+                $activeWorksheet->getStyle('E' . $count)->getFont()->getColor()->setARGB('FFFF0000');
+            }
+
+            $idx = 0;
+            foreach ($this->payments as $p) {
+                if ($idx >= count($letters) - 1) break;
+
+                if (isset($record[$p['name']])) {
+                    $inValue = isset($record[$p['name']]["IN"]) ? $this->formatPrice($record[$p['name']]["IN"]) : "";
+                    $outValue = isset($record[$p['name']]["OUT"]) ? $this->formatPrice($record[$p['name']]["OUT"]) : "";
+
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, $inValue);
+                    $idx++;
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, $outValue);
+                    $idx++;
+                } else {
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, "");
+                    $idx++;
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, "");
+                    $idx++;
+                }
+            }
+
+            $count++;
+            $processed++;
+
+            if ($processed % $batchSize === 0) {
+                gc_collect_cycles();
+            }
+        }
+
+        $count++;
+        $activeWorksheet->setCellValue('A' . $count, 'TOTALE');
+        $activeWorksheet->setCellValue('B' . $count, '');
+        $activeWorksheet->setCellValue('C' . $count, '');
+        $activeWorksheet->setCellValue('D' . $count, '');
+        $activeWorksheet->setCellValue('E' . $count, '');
+
+        $idx = 0;
+        foreach ($this->payments as $p) {
+            if ($idx >= count($letters) - 1) break;
+
+            if (isset($this->exportTotals[$p['name']])) {
+                if ($p['type'] == 'ALL') {
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, $this->formatPrice($this->exportTotals[$p['name']]["IN"] ?? 0));
+                    $idx++;
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, $this->formatPrice($this->exportTotals[$p['name']]["OUT"] ?? 0));
+                    $idx++;
+                } elseif ($p['type'] == 'IN') {
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, $this->formatPrice($this->exportTotals[$p['name']]["IN"] ?? 0));
+                    $idx++;
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, "");
+                    $idx++;
+                } elseif ($p['type'] == 'OUT') {
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, "");
+                    $idx++;
+                    $activeWorksheet->setCellValue($letters[$idx] . $count, $this->formatPrice($this->exportTotals[$p['name']]["OUT"] ?? 0));
+                    $idx++;
+                }
+            } else {
+                $activeWorksheet->setCellValue($letters[$idx] . $count, "0,00");
+                $idx++;
+                $activeWorksheet->setCellValue($letters[$idx] . $count, "0,00");
+                $idx++;
+            }
+        }
+
+        $activeWorksheet->getStyle('A' . $count . ':' . $letters[min(count($letters) - 1, count($this->payments) * 2 + 4)] . $count)
+                       ->getFont()->setBold(true);
+
+        $activeWorksheet->getStyle('A' . $count . ':' . $letters[min(count($letters) - 1, count($this->payments) * 2 + 4)] . $count)
+                       ->getFill()
+                       ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+                       ->getStartColor()->setARGB('FFF0F0F0');
+
+        $activeWorksheet->getColumnDimension('A')->setWidth(15);
+        $activeWorksheet->getColumnDimension('B')->setWidth(25);
+        $activeWorksheet->getColumnDimension('C')->setWidth(30);
+        $activeWorksheet->getColumnDimension('D')->setWidth(25);
+        $activeWorksheet->getColumnDimension('E')->setWidth(15);
+
+        foreach ($letters as $l) {
+            $activeWorksheet->getColumnDimension($l)->setWidth(15);
+        }
+
+        $activeWorksheet->freezePane('A3');
+
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($filePath);
+
+        unset($spreadsheet, $activeWorksheet, $writer);
+        gc_collect_cycles();
+    }
+
+    /**
+     * Format price for display
+     */
+    private function formatPrice($amount)
+    {
+        return number_format($amount, 2, ',', '.');
+    }
+
+    /**
+     * Get description of applied filters
+     */
+    private function getFiltersDescription()
+    {
+        $descriptions = [];
+
+        if (!empty($this->filters['member'])) {
+            $descriptions[] = "Utente: {$this->filters['member']}";
+        }
+
+        if (!empty($this->filters['causals'])) {
+            $descriptions[] = "Causali: " . (is_array($this->filters['causals']) ? implode(', ', $this->filters['causals']) : $this->filters['causals']);
+        }
+
+        return empty($descriptions) ? 'Nessun filtro applicato' : implode(' | ', $descriptions);
+    }
+}

+ 249 - 0
app/Jobs/ProcessRecordAttachment.php

@@ -0,0 +1,249 @@
+<?php
+
+namespace App\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use App\Services\RecordFileService;
+
+class ProcessRecordAttachment implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $recordId;
+    protected $tempFilePath;
+    protected $originalFileName;
+    protected $type;
+    protected $clientName;
+
+    public $timeout = 300;
+    public $tries = 3;
+    public $backoff = [10, 30, 60];
+
+    public function __construct($recordId, $tempFilePath, $originalFileName, $type = 'OUT', $clientName = null)
+    {
+        $this->recordId = $recordId;
+        $this->tempFilePath = $tempFilePath;
+        $this->originalFileName = $originalFileName;
+        $this->type = strtolower($type);
+        $this->clientName = $clientName ?: session('clientName', 'default');
+
+        $this->clientName = Str::slug($this->clientName, '_');
+    }
+
+    public function handle(RecordFileService $recordFileService)
+    {
+        try {
+            Log::info("=== PROCESSING ATTACHMENT JOB START ===");
+            Log::info("Client: {$this->clientName}");
+            Log::info("Record ID: {$this->recordId}");
+            Log::info("Temp file: {$this->tempFilePath}");
+            Log::info("Original name: {$this->originalFileName}");
+            Log::info("Type: {$this->type}");
+
+            DB::table('records')
+                ->where('id', $this->recordId)
+                ->update([
+                    'attachment_status' => 'processing',
+                    'updated_at' => now()
+                ]);
+
+            if (!Storage::disk('s3')->exists($this->tempFilePath)) {
+                Log::error("Temp file not found on S3: {$this->tempFilePath}");
+
+                try {
+                    $tempFiles = Storage::disk('s3')->files("{$this->clientName}/temp/uploads");
+                    Log::info("Available temp files for client '{$this->clientName}' on S3: " . json_encode($tempFiles));
+                } catch (\Exception $e) {
+                    Log::error("Could not list temp files for client '{$this->clientName}': " . $e->getMessage());
+                }
+
+                throw new \Exception("Temp file not found on S3: {$this->tempFilePath}");
+            }
+
+            $tempFileSize = Storage::disk('s3')->size($this->tempFilePath);
+            Log::info("Temp file size: {$tempFileSize} bytes");
+
+            $extension = pathinfo($this->originalFileName, PATHINFO_EXTENSION);
+            $fileName = time() . '_' . Str::random(10) . '.' . $extension;
+            $finalPath = "{$this->clientName}/records/{$this->type}/{$this->recordId}/attachments/{$fileName}";
+
+            Log::info("Final path: {$finalPath}");
+
+            $copySuccess = $this->copyFileOnS3($this->tempFilePath, $finalPath);
+
+            if (!$copySuccess) {
+                throw new \Exception("Failed to copy file from {$this->tempFilePath} to {$finalPath}");
+            }
+
+            if (!Storage::disk('s3')->exists($finalPath)) {
+                throw new \Exception("Final file not found after copy: {$finalPath}");
+            }
+
+            $finalFileSize = Storage::disk('s3')->size($finalPath);
+            Log::info("Final file size: {$finalFileSize} bytes");
+
+            if ($finalFileSize !== $tempFileSize) {
+                Log::warning("File size mismatch! Temp: {$tempFileSize}, Final: {$finalFileSize}");
+            } else {
+                Log::info("File sizes match - copy successful");
+            }
+
+            DB::table('records')
+                ->where('id', $this->recordId)
+                ->update([
+                    'attachment' => $finalPath,
+                    'attachment_status' => 'completed',
+                    'updated_at' => now()
+                ]);
+
+            $this->cleanupTempFile($this->tempFilePath);
+
+            Log::info("Attachment processing completed successfully for record {$this->recordId}: {$finalPath}");
+            Log::info("=== PROCESSING ATTACHMENT JOB END ===");
+
+        } catch (\Exception $e) {
+            Log::error("Failed to process attachment for record {$this->recordId}: " . $e->getMessage());
+            Log::error("Stack trace: " . $e->getTraceAsString());
+
+            DB::table('records')
+                ->where('id', $this->recordId)
+                ->update([
+                    'attachment_status' => 'failed',
+                    'updated_at' => now()
+                ]);
+
+            $this->cleanupTempFile($this->tempFilePath);
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Enhanced S3 copy with multiple fallback approaches
+     */
+    private function copyFileOnS3($sourcePath, $destinationPath)
+    {
+        Log::info("Attempting S3 copy from {$sourcePath} to {$destinationPath}");
+
+        try {
+            Log::info("Trying Method 1: Standard S3 copy");
+            $copyResult = Storage::disk('s3')->copy($sourcePath, $destinationPath);
+
+            if ($copyResult && Storage::disk('s3')->exists($destinationPath)) {
+                Log::info("Method 1 successful: Standard S3 copy");
+                return true;
+            } else {
+                Log::warning("Method 1 failed: Standard S3 copy returned " . ($copyResult ? 'true' : 'false'));
+            }
+        } catch (\Exception $e) {
+            Log::warning("Method 1 exception: " . $e->getMessage());
+        }
+
+        try {
+            Log::info("Trying Method 2: Read and write");
+
+            $fileContent = Storage::disk('s3')->get($sourcePath);
+            if (!$fileContent) {
+                throw new \Exception("Could not read source file content");
+            }
+
+            $writeResult = Storage::disk('s3')->put($destinationPath, $fileContent);
+
+            if ($writeResult && Storage::disk('s3')->exists($destinationPath)) {
+                Log::info("Method 2 successful: Read and write");
+                return true;
+            } else {
+                Log::warning("Method 2 failed: Write returned " . ($writeResult ? 'true' : 'false'));
+            }
+        } catch (\Exception $e) {
+            Log::warning("Method 2 exception: " . $e->getMessage());
+        }
+
+        try {
+            Log::info("Trying Method 3: Stream copy");
+
+            $sourceStream = Storage::disk('s3')->readStream($sourcePath);
+            if (!$sourceStream) {
+                throw new \Exception("Could not open source stream");
+            }
+
+            $writeResult = Storage::disk('s3')->writeStream($destinationPath, $sourceStream);
+
+            if (is_resource($sourceStream)) {
+                fclose($sourceStream);
+            }
+
+            if ($writeResult && Storage::disk('s3')->exists($destinationPath)) {
+                Log::info(" Method 3 successful: Stream copy");
+                return true;
+            } else {
+                Log::warning("Method 3 failed: Stream write returned " . ($writeResult ? 'true' : 'false'));
+            }
+        } catch (\Exception $e) {
+            Log::warning("Method 3 exception: " . $e->getMessage());
+        }
+
+        Log::error("All S3 copy methods failed");
+        return false;
+    }
+
+    /**
+     * Clean up temp file with error handling
+     */
+    private function cleanupTempFile($tempPath)
+    {
+        try {
+            if (Storage::disk('s3')->exists($tempPath)) {
+                $deleted = Storage::disk('s3')->delete($tempPath);
+                if ($deleted) {
+                    Log::info("Temp file deleted: {$tempPath}");
+                } else {
+                    Log::warning("Failed to delete temp file: {$tempPath}");
+                }
+            } else {
+                Log::info("Temp file already gone: {$tempPath}");
+            }
+        } catch (\Exception $e) {
+            Log::error("Error deleting temp file {$tempPath}: " . $e->getMessage());
+        }
+    }
+
+    public function failed(\Exception $exception)
+    {
+        Log::error("=== JOB PERMANENTLY FAILED ===");
+        Log::error("Client: {$this->clientName}");
+        Log::error("Record ID: {$this->recordId}");
+        Log::error("Exception: " . $exception->getMessage());
+
+        DB::table('records')
+            ->where('id', $this->recordId)
+            ->update([
+                'attachment_status' => 'failed',
+                'updated_at' => now()
+            ]);
+
+        $this->cleanupTempFile($this->tempFilePath);
+    }
+
+    /**
+     * Get job tags for monitoring
+     */
+    public function tags()
+    {
+        return [
+            'attachment',
+            'client:' . $this->clientName,
+            'record:' . $this->recordId,
+            'type:' . $this->type,
+            'file:' . basename($this->tempFilePath)
+        ];
+    }
+}

+ 66 - 0
app/Mail/ExportNotification.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+
+class ExportNotification extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    public $emailData;
+    private $filePath;
+    private $fileName;
+
+    /**
+     * Create a new message instance.
+     */
+    public function __construct($emailData, $filePath, $fileName)
+    {
+        $this->emailData = $emailData;
+        $this->filePath = $filePath;
+        $this->fileName = $fileName;
+    }
+
+    /**
+     * Build the message.
+     */
+    public function build()
+    {
+        try {
+            $email = $this->subject($this->emailData['subject'])
+                        ->view('emails.export-notification')
+                        ->with('data', $this->emailData);
+
+            if (file_exists($this->filePath)) {
+                $email->attach($this->filePath, [
+                    'as' => $this->fileName,
+                    'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+                ]);
+
+                Log::info('Email attachment added', [
+                    'file_path' => $this->filePath,
+                    'file_name' => $this->fileName,
+                    'file_size' => filesize($this->filePath)
+                ]);
+            } else {
+                Log::warning('Export file not found for email attachment', [
+                    'file_path' => $this->filePath
+                ]);
+            }
+
+            return $email;
+
+        } catch (\Exception $e) {
+            Log::error('Error building export notification email', [
+                'error' => $e->getMessage(),
+                'file_path' => $this->filePath,
+                'file_name' => $this->fileName
+            ]);
+            throw $e;
+        }
+    }
+}

+ 9 - 0
app/Models/Course.php

@@ -131,5 +131,14 @@ class Course extends Model
     {
         return \App\Models\MemberCourse::where('course_id', $this->id)->count();
     }
+    public function getTypeFieldAttribute()
+    {
+        return $this->attributes['type'] ?? null;
+    }
 
+    // Method to get type field with capitalization
+    public function getFormattedTypeField()
+    {
+        return ucfirst($this->attributes['type'] ?? 'No Type');
+    }
 }

+ 350 - 0
app/Services/LogoUploadServices.php

@@ -0,0 +1,350 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\UploadedFile;
+use Exception;
+
+class LogoUploadServices
+{
+    /**
+     * The storage disk to use for file operations
+     */
+    private $disk;
+
+    /**
+     * Allowed file extensions for logos
+     */
+    private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+
+    /**
+     * Maximum file size in KB
+     */
+    private const MAX_FILE_SIZE = 2048;
+
+    /**
+     * Constructor
+     */
+    public function __construct()
+    {
+        $this->disk = Storage::disk('s3');
+    }
+
+    /**
+     * Upload logo and manage client sub-folder
+     *
+     * @param UploadedFile $logoFile
+     * @param mixed $azienda
+     * @return string
+     * @throws Exception
+     */
+    public function uploadLogo(UploadedFile $logoFile, $azienda): string
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+
+            if (!$currentClient) {
+                throw new Exception('No current client found in session');
+            }
+
+            $this->validateLogoFile($logoFile);
+
+            $this->ensureClientFolderExists($currentClient);
+
+            $this->deleteExistingLogo($currentClient);
+
+            $logoPath = $this->uploadNewLogo($logoFile, $currentClient);
+
+            $azienda->logo = $logoPath;
+            $azienda->save();
+
+            Log::info("Logo uploaded successfully", [
+                'client' => $currentClient,
+                'path' => $logoPath,
+                'azienda_id' => $azienda->id ?? null
+            ]);
+
+            return $logoPath;
+
+        } catch (Exception $e) {
+            Log::error('Error uploading logo', [
+                'message' => $e->getMessage(),
+                'client' => session('currentClient'),
+                'azienda_id' => $azienda->id ?? null
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Validate the uploaded logo file
+     *
+     * @param UploadedFile $logoFile
+     * @throws Exception
+     */
+    private function validateLogoFile(UploadedFile $logoFile): void
+    {
+        // Check if file is valid
+        if (!$logoFile->isValid()) {
+            throw new Exception('Invalid file upload');
+        }
+
+        // Check file size
+        if ($logoFile->getSize() > (self::MAX_FILE_SIZE * 1024)) {
+            throw new Exception('File size exceeds maximum allowed size of ' . self::MAX_FILE_SIZE . 'KB');
+        }
+
+        // Check file extension
+        $extension = strtolower($logoFile->getClientOriginalExtension());
+        if (!in_array($extension, self::ALLOWED_EXTENSIONS)) {
+            throw new Exception('File type not allowed. Allowed types: ' . implode(', ', self::ALLOWED_EXTENSIONS));
+        }
+
+        // Check mime type
+        $mimeType = $logoFile->getMimeType();
+        $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+        if (!in_array($mimeType, $allowedMimeTypes)) {
+            throw new Exception('Invalid file type');
+        }
+    }
+
+    /**
+     * Ensure client folder exists in the bucket
+     *
+     * @param string $clientName
+     * @throws Exception
+     */
+    private function ensureClientFolderExists(string $clientName): void
+    {
+        try {
+            $files = $this->disk->files($clientName);
+
+            if (empty($files)) {
+                $placeholderPath = $clientName . '/.gitkeep';
+                $this->disk->put($placeholderPath, '');
+
+                Log::info("Created client folder", ['client' => $clientName]);
+            }
+
+        } catch (Exception $e) {
+            Log::error("Error creating client folder", [
+                'client' => $clientName,
+                'error' => $e->getMessage()
+            ]);
+            throw new Exception("Failed to create client folder: " . $e->getMessage());
+        }
+    }
+
+    /**
+     * Delete all existing logo files for the client
+     *
+     * @param string $clientName
+     */
+    private function deleteExistingLogo(string $clientName): void
+    {
+        try {
+            foreach (self::ALLOWED_EXTENSIONS as $extension) {
+                $logoPath = $clientName . '/logo.' . $extension;
+
+                if ($this->disk->exists($logoPath)) {
+                    $this->disk->delete($logoPath);
+                    Log::info("Deleted existing logo", [
+                        'client' => $clientName,
+                        'file' => "logo.{$extension}"
+                    ]);
+                }
+            }
+
+        } catch (Exception $e) {
+            Log::warning("Error deleting existing logo", [
+                'client' => $clientName,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * Upload the new logo file
+     *
+     * @param UploadedFile $logoFile
+     * @param string $clientName
+     * @return string
+     * @throws Exception
+     */
+    private function uploadNewLogo(UploadedFile $logoFile, string $clientName): string
+    {
+        try {
+            $extension = strtolower($logoFile->getClientOriginalExtension());
+
+            $fileName = 'logo.' . $extension;
+
+            $logoPath = $this->disk->putFileAs(
+                $clientName,
+                $logoFile,
+                $fileName,
+                'private'
+            );
+
+            if (!$logoPath) {
+                throw new Exception('Failed to upload file to storage');
+            }
+
+            Log::info("Logo file uploaded", [
+                'client' => $clientName,
+                'path' => $logoPath,
+                'size' => $logoFile->getSize()
+            ]);
+
+            return $logoPath;
+
+        } catch (Exception $e) {
+            Log::error("Error uploading logo file", [
+                'client' => $clientName,
+                'error' => $e->getMessage()
+            ]);
+            throw new Exception("Failed to upload logo: " . $e->getMessage());
+        }
+    }
+
+    /**
+     * Get a temporary URL for the logo
+     *
+     * @param mixed $azienda
+     * @param string $expiresIn
+     * @return string|null
+     */
+    public function getLogoUrl($azienda, string $expiresIn = '+1 hour'): ?string
+    {
+        if (!$azienda->logo) {
+            return null;
+        }
+
+        try {
+            if (!$this->disk->exists($azienda->logo)) {
+                Log::warning("Logo file not found", ['path' => $azienda->logo]);
+                return null;
+            }
+
+            return $this->disk->temporaryUrl($azienda->logo, now()->add($expiresIn));
+
+        } catch (Exception $e) {
+            Log::error("Error generating logo URL", [
+                'path' => $azienda->logo,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * Check if logo file exists
+     *
+     * @param mixed $azienda
+     * @return bool
+     */
+    public function logoExists($azienda): bool
+    {
+        if (!$azienda->logo) {
+            return false;
+        }
+
+        return $this->disk->exists($azienda->logo);
+    }
+
+    /**
+     * Delete logo file and update database
+     *
+     * @param mixed $azienda
+     * @return bool
+     */
+    public function deleteLogo($azienda): bool
+    {
+        try {
+            if ($azienda->logo && $this->disk->exists($azienda->logo)) {
+                $this->disk->delete($azienda->logo);
+
+                Log::info("Logo deleted", [
+                    'path' => $azienda->logo,
+                    'azienda_id' => $azienda->id ?? null
+                ]);
+
+                $azienda->logo = null;
+                $azienda->save();
+
+                return true;
+            }
+
+            return false;
+
+        } catch (Exception $e) {
+            Log::error("Error deleting logo", [
+                'path' => $azienda->logo ?? 'unknown',
+                'error' => $e->getMessage()
+            ]);
+            return false;
+        }
+    }
+
+    /**
+     * Get logo file information
+     *
+     * @param mixed $azienda
+     * @return array|null
+     */
+    public function getLogoInfo($azienda): ?array
+    {
+        if (!$azienda->logo || !$this->disk->exists($azienda->logo)) {
+            return null;
+        }
+
+        try {
+            return [
+                'path' => $azienda->logo,
+                'size' => $this->disk->size($azienda->logo),
+                'last_modified' => $this->disk->lastModified($azienda->logo),
+                'url' => $this->getLogoUrl($azienda),
+                'exists' => true
+            ];
+
+        } catch (Exception $e) {
+            Log::error("Error getting logo info", [
+                'path' => $azienda->logo,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * List all logos for a client
+     *
+     * @param string|null $clientName
+     * @return array
+     */
+    public function listClientLogos(?string $clientName = null): array
+    {
+        $clientName = $clientName ?? session('currentClient');
+
+        if (!$clientName) {
+            return [];
+        }
+
+        try {
+            $files = $this->disk->files($clientName);
+
+            return array_filter($files, function($file) {
+                $filename = basename($file);
+                return strpos($filename, 'logo.') === 0;
+            });
+
+        } catch (Exception $e) {
+            Log::error("Error listing client logos", [
+                'client' => $clientName,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+}

+ 424 - 0
app/Services/MemberFileService.php

@@ -0,0 +1,424 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\UploadedFile;
+use Exception;
+
+class MemberFileService
+{
+    /**
+     * The storage disk to use for file operations
+     */
+    private $disk;
+
+    /**
+     * Allowed file extensions for different file types
+     */
+    private const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
+    private const ALLOWED_DOCUMENT_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'];
+
+    /**
+     * Maximum file sizes in KB
+     */
+    private const MAX_IMAGE_SIZE = 2048; // 2MB
+    private const MAX_DOCUMENT_SIZE = 10240; // 10MB
+
+    /**
+     * Constructor
+     */
+    public function __construct()
+    {
+        $this->disk = Storage::disk('s3');
+    }
+
+    /**
+     * Upload member profile image
+     *
+     * @param UploadedFile $imageFile
+     * @param int $memberId
+     * @return string
+     * @throws Exception
+     */
+    public function uploadProfileImage(UploadedFile $imageFile, int $memberId): string
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+
+            // Validate file
+            $this->validateFile($imageFile, 'image');
+
+            // Delete existing profile image
+            $this->deleteExistingProfileImage($memberId);
+
+            // Create new filename
+            $extension = strtolower($imageFile->getClientOriginalExtension());
+            $filename = 'profile_' . $memberId . '_' . time() . '.' . $extension;
+
+            // Upload to S3
+            $s3Path = $currentClient . '/members/' . $memberId . '/images/' . $filename;
+
+            $uploaded = $this->disk->putFileAs(
+                $currentClient . '/members/' . $memberId . '/images',
+                $imageFile,
+                $filename,
+                'private'
+            );
+
+            if (!$uploaded) {
+                throw new Exception('Failed to upload profile image to S3');
+            }
+
+            Log::info("Profile image uploaded", [
+                'member_id' => $memberId,
+                'path' => $s3Path,
+                'size' => $imageFile->getSize()
+            ]);
+
+            return $s3Path;
+
+        } catch (Exception $e) {
+            Log::error("Error uploading profile image", [
+                'member_id' => $memberId,
+                'error' => $e->getMessage()
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Upload single document file
+     *
+     * @param UploadedFile $documentFile
+     * @param int $memberId
+     * @param string $documentType ('self', 'father', 'mother')
+     * @return string
+     * @throws Exception
+     */
+    public function uploadDocument(UploadedFile $documentFile, int $memberId, string $documentType = 'self'): string
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+
+            // Validate file
+            $this->validateFile($documentFile, 'document');
+
+            // Create filename
+            $originalName = pathinfo($documentFile->getClientOriginalName(), PATHINFO_FILENAME);
+            $extension = strtolower($documentFile->getClientOriginalExtension());
+            $filename = $documentType . '_' . $originalName . '_' . time() . '.' . $extension;
+
+            // Upload to S3
+            $s3Path = $currentClient . '/members/' . $memberId . '/documents/' . $filename;
+
+            $uploaded = $this->disk->putFileAs(
+                $currentClient . '/members/' . $memberId . '/documents',
+                $documentFile,
+                $filename,
+                'private'
+            );
+
+            if (!$uploaded) {
+                throw new Exception('Failed to upload document to S3: ' . $originalName);
+            }
+
+            Log::info("Document uploaded", [
+                'member_id' => $memberId,
+                'type' => $documentType,
+                'path' => $s3Path,
+                'original_name' => $documentFile->getClientOriginalName()
+            ]);
+
+            return $s3Path;
+
+        } catch (Exception $e) {
+            Log::error("Error uploading document", [
+                'member_id' => $memberId,
+                'type' => $documentType,
+                'error' => $e->getMessage()
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Upload certificate file
+     *
+     * @param UploadedFile $certificateFile
+     * @param int $memberId
+     * @return string
+     * @throws Exception
+     */
+    public function uploadCertificate(UploadedFile $certificateFile, int $memberId): string
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+
+            // Validate file
+            $this->validateFile($certificateFile, 'document');
+
+            // Create filename
+            $extension = strtolower($certificateFile->getClientOriginalExtension());
+            $filename = 'certificate_' . $memberId . '_' . time() . '.' . $extension;
+
+            // Upload to S3
+            $s3Path = $currentClient . '/members/' . $memberId . '/certificates/' . $filename;
+
+            $uploaded = $this->disk->putFileAs(
+                $currentClient . '/members/' . $memberId . '/certificates',
+                $certificateFile,
+                $filename,
+                'private'
+            );
+
+            if (!$uploaded) {
+                throw new Exception('Failed to upload certificate to S3');
+            }
+
+            Log::info("Certificate uploaded", [
+                'member_id' => $memberId,
+                'path' => $s3Path,
+                'size' => $certificateFile->getSize()
+            ]);
+
+            return $s3Path;
+
+        } catch (Exception $e) {
+            Log::error("Error uploading certificate", [
+                'member_id' => $memberId,
+                'error' => $e->getMessage()
+            ]);
+            throw $e;
+        }
+    }
+
+    /**
+     * Get file URL for display
+     *
+     * @param string $filePath
+     * @param string $expiresIn
+     * @return string|null
+     */
+    public function getFileUrl(string $filePath, string $expiresIn = '+1 hour'): ?string
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        // Handle legacy local paths - return asset URL
+        if (!$this->isS3Path($filePath)) {
+            return asset('storage/' . $filePath);
+        }
+
+        try {
+            if (!$this->disk->exists($filePath)) {
+                Log::warning("File not found", ['path' => $filePath]);
+                return null;
+            }
+
+            return $this->disk->temporaryUrl($filePath, now()->add($expiresIn));
+
+        } catch (Exception $e) {
+            Log::error("Error generating file URL", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * Delete file from S3
+     *
+     * @param string $filePath
+     * @return bool
+     */
+    public function deleteFile(string $filePath): bool
+    {
+        // Don't try to delete local files
+        if (!$this->isS3Path($filePath)) {
+            return false;
+        }
+
+        try {
+            if ($this->disk->exists($filePath)) {
+                $this->disk->delete($filePath);
+                Log::info("File deleted", ['path' => $filePath]);
+                return true;
+            }
+            return false;
+
+        } catch (Exception $e) {
+            Log::error("Error deleting file", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return false;
+        }
+    }
+
+    /**
+     * Check if path is S3 path
+     */
+    private function isS3Path(string $path): bool
+    {
+        return strpos($path, '/members/') !== false ||
+               strpos($path, session('currentClient', 'default')) === 0;
+    }
+
+    /**
+     * Delete existing profile image for member
+     *
+     * @param int $memberId
+     */
+    private function deleteExistingProfileImage(int $memberId): void
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+            $imagePath = $currentClient . '/members/' . $memberId . '/images/';
+
+            // List all files in the member's image directory
+            $files = $this->disk->files($imagePath);
+
+            foreach ($files as $file) {
+                if (strpos(basename($file), 'profile_' . $memberId) === 0) {
+                    $this->disk->delete($file);
+                    Log::info("Deleted existing profile image", [
+                        'member_id' => $memberId,
+                        'file' => $file
+                    ]);
+                }
+            }
+
+        } catch (Exception $e) {
+            Log::warning("Error deleting existing profile image", [
+                'member_id' => $memberId,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * Validate uploaded file
+     *
+     * @param UploadedFile $file
+     * @param string $type ('image' or 'document')
+     * @throws Exception
+     */
+    private function validateFile(UploadedFile $file, string $type): void
+    {
+        // Check if file is valid
+        if (!$file->isValid()) {
+            throw new Exception('Invalid file upload');
+        }
+
+        $extension = strtolower($file->getClientOriginalExtension());
+        $allowedExtensions = $type === 'image' ? self::ALLOWED_IMAGE_EXTENSIONS : self::ALLOWED_DOCUMENT_EXTENSIONS;
+        $maxSize = $type === 'image' ? self::MAX_IMAGE_SIZE : self::MAX_DOCUMENT_SIZE;
+
+        // Check file extension
+        if (!in_array($extension, $allowedExtensions)) {
+            throw new Exception("File type not allowed. Allowed types: " . implode(', ', $allowedExtensions));
+        }
+
+        // Check file size
+        if ($file->getSize() > ($maxSize * 1024)) {
+            throw new Exception("File size exceeds maximum allowed size of {$maxSize}KB");
+        }
+
+        // Check mime type for images
+        if ($type === 'image') {
+            $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif'];
+            if (!in_array($file->getMimeType(), $allowedMimeTypes)) {
+                throw new Exception('Invalid image file type');
+            }
+        }
+    }
+
+    /**
+     * Create member folder structure in S3
+     *
+     * @param int $memberId
+     */
+    public function createMemberFolders(int $memberId): void
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+
+            $folders = [
+                $currentClient . '/members/' . $memberId . '/images/.gitkeep',
+                $currentClient . '/members/' . $memberId . '/documents/.gitkeep',
+                $currentClient . '/members/' . $memberId . '/certificates/.gitkeep'
+            ];
+
+            foreach ($folders as $folder) {
+                if (!$this->disk->exists($folder)) {
+                    $this->disk->put($folder, '');
+                }
+            }
+
+            Log::info("Created member folder structure", ['member_id' => $memberId]);
+
+        } catch (Exception $e) {
+            Log::error("Error creating member folders", [
+                'member_id' => $memberId,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * Get file info
+     *
+     * @param string $filePath
+     * @return array|null
+     */
+    public function getFileInfo(string $filePath): ?array
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        // Handle legacy local paths
+        if (!$this->isS3Path($filePath)) {
+            $localPath = storage_path('app/public/' . $filePath);
+            if (file_exists($localPath)) {
+                return [
+                    'path' => $filePath,
+                    'name' => basename($filePath),
+                    'size' => filesize($localPath),
+                    'last_modified' => filemtime($localPath),
+                    'url' => $this->getFileUrl($filePath),
+                    'exists' => true,
+                    'storage_type' => 'local'
+                ];
+            }
+            return null;
+        }
+
+        if (!$this->disk->exists($filePath)) {
+            return null;
+        }
+
+        try {
+            return [
+                'path' => $filePath,
+                'name' => basename($filePath),
+                'size' => $this->disk->size($filePath),
+                'last_modified' => $this->disk->lastModified($filePath),
+                'url' => $this->getFileUrl($filePath),
+                'exists' => true,
+                'storage_type' => 's3'
+            ];
+
+        } catch (Exception $e) {
+            Log::error("Error getting file info", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+}

+ 416 - 0
app/Services/RecordFileService.php

@@ -0,0 +1,416 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use Illuminate\Http\UploadedFile;
+
+class RecordFileService
+{
+    /**
+     * Get client name from session, fallback to 'default'
+     */
+    private function getClientName()
+    {
+        $clientName = session('clientName', 'default');
+
+        $clientName = Str::slug($clientName, '_');
+
+        Log::info("Using client name for folders: {$clientName}");
+        return $clientName;
+    }
+
+    /**
+     * Create record folders with client structure
+     */
+    public function createRecordFolders($recordId, $type)
+    {
+        $clientName = $this->getClientName();
+        $type = strtolower($type);
+
+        Log::info("Preparing S3 structure for client: {$clientName}, record {$recordId}, type: {$type}");
+        $folderPath = "{$clientName}/records/{$type}/{$recordId}/attachments";
+        Log::info("S3 folder structure: {$folderPath}");
+
+        return true;
+    }
+
+    /**
+     * Store file temporarily with client structure
+     */
+    public function storeTemporarily($uploadedFile)
+    {
+        try {
+            $clientName = $this->getClientName();
+            $extension = $uploadedFile->getClientOriginalExtension();
+            $fileName = time() . '_' . Str::random(10) . '.' . $extension;
+            $tempPath = "{$clientName}/temp/uploads/{$fileName}";
+
+            Log::info("=== STORING FILE TEMPORARILY ===");
+            Log::info("Client: {$clientName}");
+            Log::info("Original filename: " . $uploadedFile->getClientOriginalName());
+            Log::info("File size: " . $uploadedFile->getSize() . " bytes");
+            Log::info("Temp path: {$tempPath}");
+
+            try {
+                $storedPath = Storage::disk('s3')->putFileAs("{$clientName}/temp/uploads", $uploadedFile, $fileName);
+                Log::info("Method 1 success - putFileAs returned: {$storedPath}");
+
+                if (Storage::disk('s3')->exists($tempPath)) {
+                    $storedSize = Storage::disk('s3')->size($tempPath);
+                    Log::info("File verification successful - size: {$storedSize} bytes");
+
+                    if ($storedSize === $uploadedFile->getSize()) {
+                        Log::info("File sizes match perfectly");
+                        return $tempPath;
+                    } else {
+                        Log::warning("⚠ File size mismatch - Original: {$uploadedFile->getSize()}, Stored: {$storedSize}");
+                        return $tempPath;
+                    }
+                } else {
+                    throw new \Exception("File not found after putFileAs");
+                }
+            } catch (\Exception $e) {
+                Log::warning("Method 1 failed: " . $e->getMessage());
+            }
+
+            try {
+                Log::info("Trying Method 2: put with file contents");
+                $fileContent = file_get_contents($uploadedFile->getRealPath());
+
+                if (!$fileContent) {
+                    throw new \Exception("Could not read file contents");
+                }
+
+                $stored = Storage::disk('s3')->put($tempPath, $fileContent);
+
+                if ($stored && Storage::disk('s3')->exists($tempPath)) {
+                    Log::info("Method 2 success - put with contents");
+                    return $tempPath;
+                } else {
+                    throw new \Exception("Put method failed");
+                }
+            } catch (\Exception $e) {
+                Log::warning("Method 2 failed: " . $e->getMessage());
+            }
+
+            throw new \Exception("All temp storage methods failed");
+
+        } catch (\Exception $e) {
+            Log::error("Error storing file temporarily: " . $e->getMessage());
+            Log::error("Stack trace: " . $e->getTraceAsString());
+            throw $e;
+        }
+    }
+
+    /**
+     * Upload attachment directly to final S3 location with client structure
+     */
+    public function uploadAttachment($file, $recordId, $type)
+    {
+        try {
+            $clientName = $this->getClientName();
+            $type = strtolower($type);
+            $extension = $file->getClientOriginalExtension();
+            $fileName = time() . '_' . Str::random(10) . '.' . $extension;
+            $finalPath = "{$clientName}/records/{$type}/{$recordId}/attachments/{$fileName}";
+
+            Log::info("Uploading attachment to S3:");
+            Log::info("- Client: {$clientName}");
+            Log::info("- Record ID: {$recordId}");
+            Log::info("- Type: {$type}");
+            Log::info("- File path: {$finalPath}");
+            Log::info("- File size: " . $file->getSize() . " bytes");
+
+            $storedPath = Storage::disk('s3')->putFileAs(
+                "{$clientName}/records/{$type}/{$recordId}/attachments",
+                $file,
+                $fileName
+            );
+
+            Log::info("File uploaded successfully to S3: {$storedPath}");
+
+            if (Storage::disk('s3')->exists($finalPath)) {
+                Log::info("S3 upload verified successfully");
+                return $finalPath;
+            } else {
+                throw new \Exception("File verification failed - not found on S3");
+            }
+
+        } catch (\Exception $e) {
+            Log::error("Error uploading attachment to S3: " . $e->getMessage());
+            throw $e;
+        }
+    }
+
+    /**
+     * Upload XML receipt for import functionality with client structure
+     */
+    public function uploadXmlReceipt($file, $recordId, $type)
+    {
+        try {
+            $clientName = $this->getClientName();
+            $type = strtolower($type);
+            $extension = $file->getClientOriginalExtension() ?: 'xml';
+            $fileName = 'receipt_' . time() . '_' . Str::random(8) . '.' . $extension;
+            $finalPath = "{$clientName}/records/{$type}/{$recordId}/attachments/{$fileName}";
+
+            Log::info("Uploading XML receipt to S3:");
+            Log::info("- Client: {$clientName}");
+            Log::info("- Path: {$finalPath}");
+
+            $storedPath = Storage::disk('s3')->putFileAs(
+                "{$clientName}/records/{$type}/{$recordId}/attachments",
+                $file,
+                $fileName
+            );
+
+            Log::info("XML receipt uploaded to S3: {$storedPath}");
+            return $finalPath;
+
+        } catch (\Exception $e) {
+            Log::error("Error uploading XML receipt to S3: " . $e->getMessage());
+            throw $e;
+        }
+    }
+
+    /**
+     * Get S3 attachment URL
+     */
+    public function getAttachmentUrl($filePath)
+    {
+        try {
+            if (!$filePath) {
+                return null;
+            }
+
+            Log::info("Getting S3 attachment URL for: {$filePath}");
+
+            if (!Storage::disk('s3')->exists($filePath)) {
+                Log::warning("S3 attachment file not found: {$filePath}");
+
+                $directory = dirname($filePath);
+                try {
+                    $files = Storage::disk('s3')->files($directory);
+                    Log::info("Files in S3 directory {$directory}: " . json_encode($files));
+                } catch (\Exception $e) {
+                    Log::warning("Could not list S3 directory {$directory}: " . $e->getMessage());
+                }
+
+                return null;
+            }
+
+            $url = Storage::disk('s3')->temporaryUrl($filePath, now()->addHours(1));
+            Log::info("Generated S3 temporary URL for: {$filePath}");
+            return $url;
+
+        } catch (\Exception $e) {
+            Log::error("Error getting S3 attachment URL for {$filePath}: " . $e->getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * Delete attachment from S3
+     */
+    public function deleteAttachment($filePath)
+    {
+        try {
+            if (!$filePath) {
+                return false;
+            }
+
+            Log::info("Deleting S3 attachment: {$filePath}");
+
+            if (Storage::disk('s3')->exists($filePath)) {
+                $deleted = Storage::disk('s3')->delete($filePath);
+                if ($deleted) {
+                    Log::info("S3 attachment deleted successfully: {$filePath}");
+                    return true;
+                } else {
+                    Log::error("Failed to delete S3 attachment: {$filePath}");
+                    return false;
+                }
+            } else {
+                Log::warning("S3 attachment not found for deletion: {$filePath}");
+                return false;
+            }
+        } catch (\Exception $e) {
+            Log::error("Error deleting S3 attachment: " . $e->getMessage());
+            throw $e;
+        }
+    }
+
+    /**
+     * Debug S3 configuration and connectivity
+     */
+    public function debugFileSystem()
+    {
+        $clientName = $this->getClientName();
+
+        Log::info("=== S3 DEBUG ===");
+        Log::info("Client Name: {$clientName}");
+        Log::info("S3 Configuration:");
+        Log::info("- Bucket: " . config('filesystems.disks.s3.bucket'));
+        Log::info("- Region: " . config('filesystems.disks.s3.region'));
+        Log::info("- URL: " . config('filesystems.disks.s3.url'));
+        Log::info("- Key: " . (config('filesystems.disks.s3.key') ? 'Set' : 'Not set'));
+        Log::info("- Secret: " . (config('filesystems.disks.s3.secret') ? 'Set' : 'Not set'));
+
+        try {
+            $testFile = "{$clientName}/test_connection_" . time() . '.txt';
+            $testContent = 'S3 connection test: ' . now();
+
+            Log::info("Testing S3 connection with client structure...");
+            Storage::disk('s3')->put($testFile, $testContent);
+
+            if (Storage::disk('s3')->exists($testFile)) {
+                Log::info("S3 connection test: SUCCESS");
+                Storage::disk('s3')->delete($testFile);
+            } else {
+                Log::error("S3 connection test: FAILED - file not found after upload");
+            }
+        } catch (\Exception $e) {
+            Log::error("S3 connection test: FAILED - " . $e->getMessage());
+        }
+
+        Log::info("=== END S3 DEBUG ===");
+    }
+
+    /**
+     * Clean up old temp files from S3 for specific client or all clients
+     */
+    public function cleanupTempFiles($olderThanHours = 24, $specificClient = null)
+    {
+        try {
+            $clientName = $specificClient ?: $this->getClientName();
+            $tempPath = $specificClient ? "{$specificClient}/temp/uploads" : "{$clientName}/temp/uploads";
+
+            $tempFiles = Storage::disk('s3')->files($tempPath);
+            $cutoffTime = now()->subHours($olderThanHours);
+            $deletedCount = 0;
+
+            Log::info("Cleaning up S3 temp files for client '{$clientName}' older than {$olderThanHours} hours");
+
+            foreach ($tempFiles as $file) {
+                $fileTime = Storage::disk('s3')->lastModified($file);
+                if ($fileTime < $cutoffTime->timestamp) {
+                    if (Storage::disk('s3')->delete($file)) {
+                        $deletedCount++;
+                        Log::info("Cleaned up old S3 temp file: {$file}");
+                    }
+                }
+            }
+
+            Log::info("S3 cleanup completed for client '{$clientName}'. Deleted {$deletedCount} temp files.");
+
+        } catch (\Exception $e) {
+            Log::error("Error cleaning up S3 temp files: " . $e->getMessage());
+        }
+    }
+
+    /**
+     * Clean up temp files for all clients
+     */
+    public function cleanupAllClientTempFiles($olderThanHours = 24)
+    {
+        try {
+            $allDirectories = Storage::disk('s3')->directories('');
+            $deletedCount = 0;
+
+            Log::info("Cleaning up temp files for all clients older than {$olderThanHours} hours");
+
+            foreach ($allDirectories as $clientDir) {
+                $tempPath = "{$clientDir}/temp/uploads";
+
+                if (Storage::disk('s3')->exists($tempPath)) {
+                    $tempFiles = Storage::disk('s3')->files($tempPath);
+                    $cutoffTime = now()->subHours($olderThanHours);
+
+                    foreach ($tempFiles as $file) {
+                        $fileTime = Storage::disk('s3')->lastModified($file);
+                        if ($fileTime < $cutoffTime->timestamp) {
+                            if (Storage::disk('s3')->delete($file)) {
+                                $deletedCount++;
+                                Log::info("Cleaned up old S3 temp file: {$file}");
+                            }
+                        }
+                    }
+                }
+            }
+
+            Log::info("S3 cleanup completed for all clients. Deleted {$deletedCount} temp files.");
+
+        } catch (\Exception $e) {
+            Log::error("Error cleaning up all client temp files: " . $e->getMessage());
+        }
+    }
+
+    /**
+     * Check if a file exists on S3
+     */
+    public function fileExists($filePath)
+    {
+        try {
+            return Storage::disk('s3')->exists($filePath);
+        } catch (\Exception $e) {
+            Log::error("Error checking if S3 file exists: " . $e->getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * Get file size from S3
+     */
+    public function getFileSize($filePath)
+    {
+        try {
+            if (Storage::disk('s3')->exists($filePath)) {
+                return Storage::disk('s3')->size($filePath);
+            }
+            return 0;
+        } catch (\Exception $e) {
+            Log::error("Error getting S3 file size: " . $e->getMessage());
+            return 0;
+        }
+    }
+
+    /**
+     * Get file last modified time from S3
+     */
+    public function getFileLastModified($filePath)
+    {
+        try {
+            if (Storage::disk('s3')->exists($filePath)) {
+                return Storage::disk('s3')->lastModified($filePath);
+            }
+            return null;
+        } catch (\Exception $e) {
+            Log::error("Error getting S3 file last modified: " . $e->getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * Get all files for a specific client and type
+     */
+    public function getClientFiles($type = null, $clientName = null)
+    {
+        try {
+            $clientName = $clientName ?: $this->getClientName();
+            $type = $type ? strtolower($type) : '*';
+
+            $basePath = $type === '*' ? "{$clientName}/records" : "{$clientName}/records/{$type}";
+
+            $files = Storage::disk('s3')->allFiles($basePath);
+            Log::info("Found " . count($files) . " files for client '{$clientName}' and type '{$type}'");
+
+            return $files;
+        } catch (\Exception $e) {
+            Log::error("Error getting client files: " . $e->getMessage());
+            return [];
+        }
+    }
+}

+ 210 - 0
app/Traits/HandlesS3Files.php

@@ -0,0 +1,210 @@
+<?php
+
+namespace App\Traits;
+
+use App\Services\MemberFileService;
+use Illuminate\Support\Facades\Log;
+
+trait HandlesS3Files
+{
+    /**
+     * Get file service instance
+     */
+    protected function getFileService(): MemberFileService
+    {
+        return app(MemberFileService::class);
+    }
+
+    /**
+     * Get file URL for display
+     */
+    public function getFileUrl(?string $filePath, string $expiresIn = '+1 hour'): ?string
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        return $this->getFileService()->getFileUrl($filePath, $expiresIn);
+    }
+
+    /**
+     * Check if file exists in S3
+     */
+    public function fileExists(?string $filePath): bool
+    {
+        if (!$filePath) {
+            return false;
+        }
+
+        try {
+            return \Illuminate\Support\Facades\Storage::disk('s3')->exists($filePath);
+        } catch (\Exception $e) {
+            Log::warning("Error checking file existence", [
+                'path' => $filePath,
+                'error' => $e->getMessage()
+            ]);
+            return false;
+        }
+    }
+
+    /**
+     * Get file info
+     */
+    public function getFileInfo(?string $filePath): ?array
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        return $this->getFileService()->getFileInfo($filePath);
+    }
+
+    /**
+     * Delete file from S3
+     */
+    public function deleteFile(string $filePath): bool
+    {
+        return $this->getFileService()->deleteFile($filePath);
+    }
+
+    /**
+     * Get file name from path
+     */
+    public function getFileName(?string $filePath): ?string
+    {
+        if (!$filePath) {
+            return null;
+        }
+
+        return basename($filePath);
+    }
+
+    /**
+     * Check if path is S3 path
+     */
+    public function isS3Path(?string $path): bool
+    {
+        if (!$path) {
+            return false;
+        }
+
+        return strpos($path, '/members/') !== false ||
+               strpos($path, session('currentClient', 'default')) === 0;
+    }
+
+    /**
+     * Convert file paths array to string for database storage
+     */
+    public function filePathsToString(array $paths): string
+    {
+        return implode('|', array_filter($paths));
+    }
+
+    /**
+     * Convert file paths string from database to array
+     */
+    public function stringToFilePaths(?string $pathsString): array
+    {
+        if (!$pathsString) {
+            return [];
+        }
+
+        return array_filter(explode('|', $pathsString));
+    }
+
+    /**
+     * Get multiple file URLs
+     */
+    public function getMultipleFileUrls(array $filePaths): array
+    {
+        $urls = [];
+        foreach ($filePaths as $path) {
+            $url = $this->getFileUrl($path);
+            if ($url) {
+                $urls[$path] = $url;
+            }
+        }
+        return $urls;
+    }
+
+    /**
+     * Clean up files for deleted member
+     */
+    public function cleanupMemberFiles(int $memberId): void
+    {
+        try {
+            $currentClient = session('currentClient', 'default');
+            $memberPath = $currentClient . '/members/' . $memberId . '/';
+
+            $disk = \Illuminate\Support\Facades\Storage::disk('s3');
+
+            // Delete all files in member's folder
+            $files = $disk->allFiles($memberPath);
+            foreach ($files as $file) {
+                $disk->delete($file);
+            }
+
+            // Delete empty directories
+            $directories = $disk->allDirectories($memberPath);
+            foreach (array_reverse($directories) as $directory) {
+                $disk->deleteDirectory($directory);
+            }
+
+            Log::info("Cleaned up files for deleted member", ['member_id' => $memberId]);
+
+        } catch (\Exception $e) {
+            Log::error("Error cleaning up member files", [
+                'member_id' => $memberId,
+                'error' => $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * Validate file upload
+     */
+    public function validateFileUpload($file, string $type = 'document'): array
+    {
+        $errors = [];
+
+        if (!$file) {
+            $errors[] = 'No file provided';
+            return $errors;
+        }
+
+        if (!$file->isValid()) {
+            $errors[] = 'Invalid file upload';
+            return $errors;
+        }
+
+        $extension = strtolower($file->getClientOriginalExtension());
+        $size = $file->getSize();
+
+        if ($type === 'image') {
+            $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif'];
+            $maxSize = 2048 * 1024; // 2MB
+
+            if (!in_array($extension, $allowedExtensions)) {
+                $errors[] = 'Invalid image format. Allowed: ' . implode(', ', $allowedExtensions);
+            }
+
+            if ($size > $maxSize) {
+                $errors[] = 'Image too large. Maximum size: 2MB';
+            }
+
+        } elseif ($type === 'document') {
+            $allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'];
+            $maxSize = 10240 * 1024; // 10MB
+
+            if (!in_array($extension, $allowedExtensions)) {
+                $errors[] = 'Invalid document format. Allowed: ' . implode(', ', $allowedExtensions);
+            }
+
+            if ($size > $maxSize) {
+                $errors[] = 'Document too large. Maximum size: 10MB';
+            }
+        }
+
+        return $errors;
+    }
+}

+ 1 - 0
composer.json

@@ -11,6 +11,7 @@
         "laravel/framework": "^9.19",
         "laravel/sanctum": "^3.0",
         "laravel/tinker": "^2.7",
+        "league/flysystem-aws-s3-v3": "^3.0",
         "livewire/livewire": "^2.12",
         "phpoffice/phpspreadsheet": "^2.0"
     },

+ 285 - 4
composer.lock

@@ -4,8 +4,157 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "929f88783ff020750e47e3ae1b2963b4",
+    "content-hash": "8aacf85495c8b6cb33caeb8ac21b78c3",
     "packages": [
+        {
+            "name": "aws/aws-crt-php",
+            "version": "v1.2.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/awslabs/aws-crt-php.git",
+                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
+                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.5"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
+                "yoast/phpunit-polyfills": "^1.0"
+            },
+            "suggest": {
+                "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "AWS SDK Common Runtime Team",
+                    "email": "aws-sdk-common-runtime@amazon.com"
+                }
+            ],
+            "description": "AWS Common Runtime for PHP",
+            "homepage": "https://github.com/awslabs/aws-crt-php",
+            "keywords": [
+                "amazon",
+                "aws",
+                "crt",
+                "sdk"
+            ],
+            "support": {
+                "issues": "https://github.com/awslabs/aws-crt-php/issues",
+                "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
+            },
+            "time": "2024-10-18T22:15:13+00:00"
+        },
+        {
+            "name": "aws/aws-sdk-php",
+            "version": "3.263.13",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/aws/aws-sdk-php.git",
+                "reference": "939120791996563677afe75a97ff18f514b7418f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/939120791996563677afe75a97ff18f514b7418f",
+                "reference": "939120791996563677afe75a97ff18f514b7418f",
+                "shasum": ""
+            },
+            "require": {
+                "aws/aws-crt-php": "^1.0.4",
+                "ext-json": "*",
+                "ext-pcre": "*",
+                "ext-simplexml": "*",
+                "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
+                "guzzlehttp/promises": "^1.4.0",
+                "guzzlehttp/psr7": "^1.8.5 || ^2.3",
+                "mtdowling/jmespath.php": "^2.6",
+                "php": ">=5.5"
+            },
+            "require-dev": {
+                "andrewsville/php-token-reflection": "^1.4",
+                "aws/aws-php-sns-message-validator": "~1.0",
+                "behat/behat": "~3.0",
+                "composer/composer": "^1.10.22",
+                "dms/phpunit-arraysubset-asserts": "^0.4.0",
+                "doctrine/cache": "~1.4",
+                "ext-dom": "*",
+                "ext-openssl": "*",
+                "ext-pcntl": "*",
+                "ext-sockets": "*",
+                "nette/neon": "^2.3",
+                "paragonie/random_compat": ">= 2",
+                "phpunit/phpunit": "^4.8.35 || ^5.6.3 || ^9.5",
+                "psr/cache": "^1.0",
+                "psr/http-message": "<1.1",
+                "psr/simple-cache": "^1.0",
+                "sebastian/comparator": "^1.2.3 || ^4.0",
+                "yoast/phpunit-polyfills": "^1.0"
+            },
+            "suggest": {
+                "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
+                "doctrine/cache": "To use the DoctrineCacheAdapter",
+                "ext-curl": "To send requests using cURL",
+                "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
+                "ext-sockets": "To use client-side monitoring"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "Aws\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Amazon Web Services",
+                    "homepage": "http://aws.amazon.com"
+                }
+            ],
+            "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
+            "homepage": "http://aws.amazon.com/sdkforphp",
+            "keywords": [
+                "amazon",
+                "aws",
+                "cloud",
+                "dynamodb",
+                "ec2",
+                "glacier",
+                "s3",
+                "sdk"
+            ],
+            "support": {
+                "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
+                "issues": "https://github.com/aws/aws-sdk-php/issues",
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.263.13"
+            },
+            "time": "2023-04-19T18:23:42+00:00"
+        },
         {
             "name": "barryvdh/laravel-dompdf",
             "version": "v2.1.1",
@@ -1834,6 +1983,72 @@
             ],
             "time": "2023-02-18T15:32:41+00:00"
         },
+        {
+            "name": "league/flysystem-aws-s3-v3",
+            "version": "3.23.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
+                "reference": "97728e7a0d40ec9c6147eb0f4ee4cdc6ff0a8240"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/97728e7a0d40ec9c6147eb0f4ee4cdc6ff0a8240",
+                "reference": "97728e7a0d40ec9c6147eb0f4ee4cdc6ff0a8240",
+                "shasum": ""
+            },
+            "require": {
+                "aws/aws-sdk-php": "^3.220.0",
+                "league/flysystem": "^3.10.0",
+                "league/mime-type-detection": "^1.0.0",
+                "php": "^8.0.2"
+            },
+            "conflict": {
+                "guzzlehttp/guzzle": "<7.0",
+                "guzzlehttp/ringphp": "<1.1.1"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "League\\Flysystem\\AwsS3V3\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Frank de Jonge",
+                    "email": "info@frankdejonge.nl"
+                }
+            ],
+            "description": "AWS S3 filesystem adapter for Flysystem.",
+            "keywords": [
+                "Flysystem",
+                "aws",
+                "file",
+                "files",
+                "filesystem",
+                "s3",
+                "storage"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues",
+                "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.23.1"
+            },
+            "funding": [
+                {
+                    "url": "https://ecologi.com/frankdejonge",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/frankdejonge",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-01-26T18:25:23+00:00"
+        },
         {
             "name": "league/mime-type-detection",
             "version": "1.11.0",
@@ -2320,6 +2535,72 @@
             ],
             "time": "2023-02-06T13:44:46+00:00"
         },
+        {
+            "name": "mtdowling/jmespath.php",
+            "version": "2.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/jmespath/jmespath.php.git",
+                "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
+                "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "symfony/polyfill-mbstring": "^1.17"
+            },
+            "require-dev": {
+                "composer/xdebug-handler": "^3.0.3",
+                "phpunit/phpunit": "^8.5.33"
+            },
+            "bin": [
+                "bin/jp.php"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.8-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/JmesPath.php"
+                ],
+                "psr-4": {
+                    "JmesPath\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                }
+            ],
+            "description": "Declaratively specify how to extract elements from a JSON document",
+            "keywords": [
+                "json",
+                "jsonpath"
+            ],
+            "support": {
+                "issues": "https://github.com/jmespath/jmespath.php/issues",
+                "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
+            },
+            "time": "2024-09-04T18:46:31+00:00"
+        },
         {
             "name": "nesbot/carbon",
             "version": "2.66.0",
@@ -9027,12 +9308,12 @@
     ],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": [],
+    "stability-flags": {},
     "prefer-stable": true,
     "prefer-lowest": false,
     "platform": {
         "php": "^8.0.2"
     },
-    "platform-dev": [],
-    "plugin-api-version": "2.3.0"
+    "platform-dev": {},
+    "plugin-api-version": "2.6.0"
 }

+ 7 - 7
config/filesystems.php

@@ -46,13 +46,13 @@ return [
 
         's3' => [
             'driver' => 's3',
-            'key' => env('AWS_ACCESS_KEY_ID'),
-            'secret' => env('AWS_SECRET_ACCESS_KEY'),
-            'region' => env('AWS_DEFAULT_REGION'),
-            'bucket' => env('AWS_BUCKET'),
-            'url' => env('AWS_URL'),
-            'endpoint' => env('AWS_ENDPOINT'),
-            'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
+            'key' => env('S3_ACCESS_KEY_ID'),
+            'secret' => env('S3_SECRET_ACCESS_KEY'),
+            'region' => env('S3_DEFAULT_REGION', 'eu-south-1'),
+            'bucket' => env('S3_BUCKET'),
+            'root' => env('S3_ENVIRONMENT', 'production'),
+            'endpoint' => env('S3_ENDPOINT', 'https://s3.wasabisys.com'),
+            'use_path_style_endpoint' => true,
             'throw' => false,
         ],
 

+ 36 - 0
database/migrations/2025_06_12_080802_create_jobs_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('jobs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('queue')->index();
+            $table->longText('payload');
+            $table->unsignedTinyInteger('attempts');
+            $table->unsignedInteger('reserved_at')->nullable();
+            $table->unsignedInteger('available_at');
+            $table->unsignedInteger('created_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('jobs');
+    }
+};

+ 33 - 0
database/migrations/2025_06_12_130008_add_attachment_status_to_records_table.php

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('records', function (Blueprint $table) {
+            $table->enum('attachment_status', ['none', 'pending', 'processing', 'completed', 'failed'])
+                ->default('none')
+                ->after('attachment');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('records', function (Blueprint $table) {
+            $table->dropColumn('attachment_status');});
+    }
+};

+ 287 - 12
public/css/chart-reports.css

@@ -293,13 +293,15 @@
 }
 
 .monthly-table-container {
-    background: #f8f9fa;
+    background: white;
+    border: 1px solid #F6F7FF;
     border-radius: 12px;
     box-shadow: var(--shadow-sm);
 }
 
 .members-table-container {
-    background: #f8f9fa;
+    background: white;
+    border: 1px solid #F6F7FF;
     border-radius: 12px;
     box-shadow: var(--shadow-sm);
 }
@@ -311,22 +313,26 @@
 
 .members-table .table-header {
     display: grid;
-    grid-template-columns: 1.2fr 0.8fr 1fr;
+    grid-template-columns: 1fr 0.8fr 1fr 1.5fr;
     gap: 0.5rem;
     margin-bottom: 0.5rem;
     padding-bottom: 0.5rem;
     border-bottom: 2px solid var(--border-color);
 }
 
+
 .members-table .table-row {
     display: grid;
-    grid-template-columns: 1.2fr 0.8fr 1fr;
-    border-bottom: 1px solid #e9ecef;
+    grid-template-columns: 1fr 0.8fr 1fr 1.5fr;
+    gap: 0.5rem;
+    padding: 0.5rem 0;
+    border-bottom: 1px solid #F6F7FF;
     transition: background-color 0.2s ease;
+    align-items: start;
 }
 
 .table-cell.season-name {
-    text-align: left;
+    text-align: center;
     font-weight: 500;
     font-size: 0.7rem;
 }
@@ -338,7 +344,7 @@
 }
 
 .table-cell.variation {
-    text-align: right;
+    text-align: center;
     font-size: 0.7rem;
 }
 
@@ -392,7 +398,7 @@
 }
 
 .table-row.neutral {
-    background-color: rgba(73, 80, 87, 0.02);
+    background-color: white;
 }
 
 .table-cell {
@@ -529,9 +535,9 @@
 }
 
 .course-delta-table {
-    background: rgb(248, 249, 250);
+    background: white;
+    border: 1px solid #F6F7FF;
     border-radius: 16px;
-    border: 1px solid #e5e7eb;
     box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
 }
 
@@ -555,12 +561,12 @@
 .course-table .table-header {
     display: grid;
     grid-template-columns: 1fr 40px 80px 50px;
-    background: #f8fafc;
+    background: white;
     padding: 12px 16px;
     font-weight: 600;
     font-size: 0.875rem;
     color: #374151;
-    border-bottom: 1px solid #e5e7eb;
+    border-bottom: 1px solid #F6F7FF;
 }
 
 .course-table .table-row {
@@ -705,3 +711,272 @@
     margin-top: 20px;
     min-height: 500px;
 }
+.causals-table-container {
+    background: white;
+    border: 1px solid #F6F7FF;
+    border-radius: 12px;
+    box-shadow: var(--shadow-sm);
+}
+
+.causale-indicator {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    margin-right: 8px;
+}
+
+.table-cell.causale-name {
+    text-align: left;
+    font-weight: 500;
+    display: flex;
+    align-items: center;
+}
+
+.causals-table.compact {
+    font-size: 0.875rem;
+}
+
+.causals-table.compact .table-header {
+    display: grid;
+    grid-template-columns: 2fr 60px 45px;
+    gap: 8px;
+    padding: 8px 12px;
+    font-size: 0.7rem;
+}
+
+.causals-table.compact .table-row {
+    display: grid;
+    grid-template-columns: 2fr 60px 45px;
+    gap: 8px;
+    padding: 6px 12px;
+    font-size: 0.75rem;
+}
+
+.causals-table.compact .table-cell.causale {
+    text-align: left;
+    font-weight: 500;
+    display: flex;
+    align-items: center;
+    min-width: 0;
+    overflow: hidden;
+}
+
+.causals-table.compact .table-cell.euro {
+    text-align: right;
+    font-weight: 600;
+    color: var(--success-color);
+    font-size: 0.7rem;
+}
+
+.causals-table.compact .table-cell.percent {
+    text-align: center;
+    font-weight: 600;
+    color: var(--primary-color);
+    font-size: 0.7rem;
+}
+
+.causals-table.compact .causale-indicator {
+    width: 8px;
+    height: 8px;
+    margin-right: 6px;
+    flex-shrink: 0;
+}
+
+.monthly-table .table-row,
+.members-table .table-row,
+.causals-table.compact .table-row {
+    border-bottom: 1px solid #F6F7FF;
+}
+
+.course-table .table-row {
+    border-bottom: 1px solid #F6F7FF;
+}
+
+.monthly-table .table-header,
+.members-table .table-header,
+.causals-table.compact .table-header {
+    border-bottom: 2px solid #F6F7FF;
+}
+
+.table-cell.card-types {
+    text-align: left;
+    padding: 0.25rem 0.5rem;
+}
+
+.card-types-container {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    max-height: 120px;
+    overflow-y: auto;
+}
+
+.card-type-item {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    font-size: 0.75rem;
+    padding: 0.25rem 0;
+    min-height: 20px;
+}
+
+.card-type-indicator {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    flex-shrink: 0;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.card-type-name {
+    flex: 1;
+    font-weight: 500;
+    color: #374151;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.card-type-count {
+    font-weight: 600;
+    color: #6366f1;
+    min-width: 20px;
+    text-align: right;
+    font-size: 0.7rem;
+}
+
+.no-card-types {
+    font-size: 0.75rem;
+    color: #9ca3af;
+    font-style: italic;
+    padding: 0.25rem 0;
+}
+
+/* Responsive adjustments for smaller screens */
+@media (max-width: 1024px) {
+    .members-table .table-header {
+        grid-template-columns: 1fr;
+        gap: 0.25rem;
+    }
+
+    .members-table .table-row {
+        grid-template-columns: 1fr;
+        gap: 0.25rem;
+        padding: 1rem 0;
+        border-bottom: 2px solid #F6F7FF;
+    }
+
+    .table-cell::before {
+        content: attr(data-label) ": ";
+        font-weight: 600;
+        color: #6b7280;
+        text-transform: uppercase;
+        font-size: 0.75rem;
+        letter-spacing: 0.5px;
+    }
+
+    .table-cell.season-name::before {
+        content: "Stagione: ";
+    }
+
+    .table-cell.members-count::before {
+        content: "Tesserati: ";
+    }
+
+    .table-cell.variation::before {
+        content: "Variazione: ";
+    }
+
+    .table-cell.card-types::before {
+        content: "Tipologie: ";
+    }
+
+    .card-types-container {
+        margin-top: 0.5rem;
+        max-height: none;
+        overflow-y: visible;
+    }
+
+    .card-type-item {
+        background: #f8f9fa;
+        border-radius: 6px;
+        padding: 0.5rem;
+        margin-bottom: 0.25rem;
+    }
+}
+
+/* Scrollbar styling for card types container */
+.card-types-container::-webkit-scrollbar {
+    width: 4px;
+}
+
+.card-types-container::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 2px;
+}
+
+.card-types-container::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 2px;
+}
+
+.card-types-container::-webkit-scrollbar-thumb:hover {
+    background: #a8a8a8;
+}
+
+.chart-body,
+.chart-body *,
+.modern-chart-layout,
+.modern-chart-layout * {
+    box-sizing: border-box;
+    max-width: 100%;
+}
+
+.chart-container canvas,
+.modern-chart-container canvas {
+    max-width: 100% !important;
+    width: 100% !important;
+    height: auto !important;
+    display: block;
+}
+
+.dashboard-container,
+.chart-card,
+.chart-body {
+    overflow-x: hidden;
+    max-width: 100%;
+}
+
+#courses-chart-\2024 2025-\*,
+canvas[id^="courses-chart-"] {
+    max-width: 100% !important;
+    width: 100% !important;
+    box-sizing: border-box;
+}
+
+.modern-chart-layout {
+    display: grid;
+    grid-template-columns: minmax(280px, 280px) minmax(0, 1fr);
+    gap: 24px;
+    align-items: start;
+    margin-top: 20px;
+    min-height: 500px;
+    max-width: 100%;
+    overflow: hidden;
+    width: 100%;
+}
+
+.modern-chart-container {
+    background: white;
+    border-radius: 16px;
+    border: 1px solid #e5e7eb;
+    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
+    padding: 20px;
+    height: 500px;
+    max-width: 100%;
+    min-width: 0;
+    overflow: hidden;
+    position: relative;
+    width: 100%;
+}

+ 138 - 0
public/css/style.css

@@ -16779,3 +16779,141 @@ table.tableHead thead {
   right: 60px;
 }
 /* END CSS Ferrari - Modifiche UI */
+.loading-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(255, 255, 255, 0.9);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    z-index: 1000;
+    border-radius: 4px;
+}
+
+.loading-content {
+    text-align: center;
+    color: #0C6197;
+}
+
+.loading-content i {
+    margin-bottom: 15px;
+    color: #0C6197;
+}
+
+.loading-content p {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 500;
+    color: #10172A;
+}
+
+/* Disable pointer events on table during loading */
+.loading-overlay + .table {
+    pointer-events: none;
+    opacity: 0.6;
+}
+
+/* Loading button styles */
+button[disabled] {
+    opacity: 0.7;
+    cursor: not-allowed;
+}
+
+.btn--ui .fa-spinner {
+    margin-right: 5px;
+}
+
+/* Styles for "Varie" clickable links */
+.varie-link:hover {
+    color: #084c6b !important;
+    text-decoration: underline !important;
+}
+
+/* Modal customizations */
+.modal {
+    z-index: 9999 !important; /* Higher than select2 default z-index */
+}
+
+.modal-backdrop {
+    z-index: 9998 !important;
+    background-color: rgba(0, 0, 0, 0.6) !important; /* Darker backdrop */
+}
+
+.modal-content {
+    border-radius: 8px;
+    border: none;
+    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
+    z-index: 10000 !important;
+}
+
+.modal-header {
+    background-color: #0C6197;
+    color: white;
+    border-bottom: none;
+    border-radius: 8px 8px 0 0;
+}
+
+.modal-header .btn-close {
+    filter: invert(1);
+}
+
+.modal-title {
+    font-weight: 600;
+}
+
+.list-group-item {
+    border-left: none;
+    border-right: none;
+    border-top: 1px solid #dee2e6;
+    padding: 12px 15px;
+}
+
+.list-group-item:first-child {
+    border-top: none;
+}
+
+.list-group-item:last-child {
+    border-bottom: none;
+}
+
+/* Ensure select2 dropdown stays below modal */
+.select2-container {
+    z-index: 999 !important;
+}
+
+.select2-dropdown {
+    z-index: 999 !important;
+}
+
+/* Disable body scroll when modal is open */
+body.modal-open {
+    overflow: hidden;
+}
+
+/* Ensure modal dialog is properly centered and above everything */
+.modal-dialog {
+    z-index: 10001 !important;
+    margin: 1.75rem auto;
+}
+
+/* Period filter styles */
+.form-select[disabled] {
+    background-color: #e9ecef;
+    opacity: 0.65;
+}
+
+/* Responsive adjustments for filters */
+@media (max-width: 768px) {
+    .col-md-2, .col-md-3, .col-md-4 {
+        margin-bottom: 10px;
+    }
+
+    .prima--nota_buttons {
+        float: none !important;
+        margin-top: 10px !important;
+        text-align: center;
+    }
+}

+ 206 - 0
resources/views/emails/export-notification.blade.php

@@ -0,0 +1,206 @@
+<!DOCTYPE html>
+<html lang="it">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Prima Nota - Export</title>
+    <style>
+        body {
+            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+            line-height: 1.6;
+            color: #333;
+            background-color: #f8f9fa;
+            margin: 0;
+            padding: 0;
+        }
+        .container {
+            max-width: 600px;
+            margin: 0 auto;
+            background-color: #ffffff;
+            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+        }
+        .header {
+            background: linear-gradient(135deg, #0C6197 0%, #084c6b 100%);
+            color: white;
+            padding: 30px 20px;
+            text-align: center;
+        }
+        .header h1 {
+            margin: 0;
+            font-size: 24px;
+            font-weight: 600;
+        }
+        .header .subtitle {
+            margin: 10px 0 0 0;
+            font-size: 14px;
+            opacity: 0.9;
+        }
+        .content {
+            padding: 30px 20px;
+        }
+        .greeting {
+            font-size: 16px;
+            margin-bottom: 20px;
+        }
+        .export-details {
+            background-color: #f8f9fa;
+            border-left: 4px solid #0C6197;
+            padding: 20px;
+            margin: 20px 0;
+            border-radius: 0 6px 6px 0;
+        }
+        .export-details h3 {
+            margin: 0 0 15px 0;
+            color: #0C6197;
+            font-size: 18px;
+        }
+        .detail-item {
+            display: flex;
+            justify-content: space-between;
+            margin-bottom: 10px;
+            padding: 8px 0;
+            border-bottom: 1px solid #e9ecef;
+        }
+        .detail-item:last-child {
+            border-bottom: none;
+            margin-bottom: 0;
+        }
+        .detail-label {
+            font-weight: 600;
+            color: #495057;
+        }
+        .detail-value {
+            color: #0C6197;
+            font-weight: 500;
+        }
+        .attachment-info {
+            background-color: #e8f4f8;
+            border: 1px solid #b8daff;
+            border-radius: 6px;
+            padding: 15px;
+            margin: 20px 0;
+            text-align: center;
+        }
+        .attachment-icon {
+            font-size: 24px;
+            color: #0C6197;
+            margin-bottom: 10px;
+        }
+        .footer {
+            background-color: #f8f9fa;
+            padding: 20px;
+            text-align: center;
+            border-top: 1px solid #dee2e6;
+        }
+        .footer p {
+            margin: 5px 0;
+            font-size: 12px;
+            color: #6c757d;
+        }
+        .btn {
+            display: inline-block;
+            padding: 12px 24px;
+            background-color: #0C6197;
+            color: white;
+            text-decoration: none;
+            border-radius: 6px;
+            font-weight: 500;
+            margin: 10px 0;
+        }
+        .btn:hover {
+            background-color: #084c6b;
+        }
+        @media (max-width: 600px) {
+            .container {
+                margin: 0;
+                border-radius: 0;
+            }
+            .content {
+                padding: 20px 15px;
+            }
+            .detail-item {
+                flex-direction: column;
+                gap: 5px;
+            }
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>📊Export Prima Nota</h1>
+            <p class="subtitle">I tuoi dati sono pronti per il download</p>
+        </div>
+
+        <div class="content">
+            <div class="greeting">
+                Ciao {{ $data['user_name'] ?? 'Utente' }},
+            </div>
+
+            <p>
+                Il tuo export della Prima Nota è stato generato con successo. Troverai il file Excel allegato a questa email con tutti i dati richiesti.
+            </p>
+
+            <div class="export-details">
+                <h3>📋 Dettagli Export</h3>
+
+                <div class="detail-item">
+                    <span class="detail-label">📅 Periodo:</span>
+                    <span class="detail-value">{{ $data['from_date'] ?? 'N/A' }} - {{ $data['to_date'] ?? 'N/A' }}</span>
+                </div>
+
+                <div class="detail-item">
+                    <span class="detail-label">📊 Record totali:</span>
+                    <span class="detail-value">{{ $data['total_records'] ?? 0 }}</span>
+                </div>
+
+                <div class="detail-item">
+                    <span class="detail-label">🕒 Generato il:</span>
+                    <span class="detail-value">{{ $data['generated_at'] ?? now()->format('d/m/Y H:i:s') }}</span>
+                </div>
+
+                @if(isset($data['filters_applied']))
+                <div class="detail-item">
+                    <span class="detail-label">🔍 Filtri applicati:</span>
+                    <span class="detail-value">{{ $data['filters_applied'] }}</span>
+                </div>
+                @endif
+
+                @if(isset($data['file_size']))
+                <div class="detail-item">
+                    <span class="detail-label">📁 Dimensione file:</span>
+                    <span class="detail-value">{{ $data['file_size'] }}</span>
+                </div>
+                @endif
+            </div>
+
+            <div class="attachment-info">
+                <div class="attachment-icon">📎</div>
+                <strong>File allegato:</strong> Prima Nota Export<br>
+                <small>Il file Excel contiene tutti i dati del periodo selezionato con i filtri applicati.</small>
+            </div>
+
+            <p>
+                <strong>Cosa include questo export:</strong>
+            </p>
+            <ul>
+                <li>📋 Tutte le transazioni del periodo specificato</li>
+                <li>💰 Dettaglio entrate e uscite per metodo di pagamento</li>
+                <li>📊 Totali riassuntivi</li>
+                <li>🏷️ Causali e dettagli delle operazioni</li>
+                <li>👥 Nominativi associati alle transazioni</li>
+            </ul>
+
+            <p>
+                Se hai bisogno di assistenza o hai domande sui dati esportati, non esitare a contattare il supporto.
+            </p>
+        </div>
+
+        <div class="footer">
+            <p>Questa email è stata generata automaticamente dal sistema.</p>
+            <p>Per qualsiasi problema tecnico, contatta il supporto.</p>
+            <p><small>Generato: {{ now()->format('d/m/Y H:i:s') }}</small></p>
+        </div>
+    </div>
+</body>
+</html>

+ 102 - 17
resources/views/emails/receipt.blade.php

@@ -1,17 +1,102 @@
-Gentile cliente,
-<br><br>
-si allega la ricevuta del pagamento effettuato.
-<br><br>
-<br><br>
-Cordiali saluti,
-<br><br>
-<span style="color:blue">
-La segreteria S.S.D. IAO TEAM a r.l.<br>
-contatti: 06 60674794 - sede: via di Villa Grazioli snc - 00046 Grottaferrata (RM) - c.f. 92015570580 - P. IVA 12576361005<br>
-<small>
-Avvertenze ai sensi del "Regolamento generale sulla protezione dei dati o "GDPR" (General Data Protection Regulation)<br>
-le informazioni contenute in questo messaggio di posta elettronica e/o nel/i file/s allegato/i sono da considerarsi strettamente riservate. Il loro utilizzo è consentito esclusivamente al destinatario sopra indicato. Qualora riceveste questo messaggio senza essere il destinatario Vi preghiamo cortesemente di informarci con apposito messaggio e procedere alla distruzione del messaggio stesso, cancellandolo dal Vostro sistema; costituisce comportamento contrario ai principi dettati dal "GDPR" (General Data Protection Regulation) il trattenere il messaggio stesso, divulgarlo anche in parte, distribuirlo ad altri soggetti, ovvero copiarlo.<br>
-Grazie.
-</small>
-</span>
-<br><br>
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Invio Ricevuta</title>
+    <style>
+        body {
+            font-family: Helvetica, Arial, sans-serif;
+            font-size: 14px;
+            line-height: 1.6;
+        }
+        .header {
+            margin-bottom: 20px;
+        }
+        .company-info {
+            color: blue;
+            margin-top: 30px;
+        }
+        .privacy-notice {
+            font-size: 12px;
+            margin-top: 10px;
+        }
+    </style>
+</head>
+<body>
+    @php
+        $azienda = App\Models\Azienda::first();
+    @endphp
+
+    <div class="header">
+        Gentile cliente,
+    </div>
+
+    <p>
+        Si allega la ricevuta del pagamento effettuato.
+    </p>
+
+    <br><br>
+
+    <p>Cordiali saluti,</p>
+
+    <div class="company-info">
+        @if($azienda)
+            <strong>
+                @if($azienda->ragione_sociale)
+                    La segreteria {{$azienda->ragione_sociale}}
+                @elseif($azienda->nome_associazione)
+                    La segreteria {{$azienda->nome_associazione}}
+                @else
+                    La segreteria
+                @endif
+            </strong><br>
+
+            @php
+                $contactInfo = [];
+                if($azienda->telefono) $contactInfo[] = $azienda->telefono;
+                if($azienda->email) $contactInfo[] = $azienda->email;
+            @endphp
+
+            @if(!empty($contactInfo))
+                contatti: {{ implode(' - ', $contactInfo) }}
+            @endif
+
+            @php
+                $addressParts = array_filter([
+                    $azienda->sede_legale_indirizzo,
+                    $azienda->sede_legale_cap,
+                    $azienda->sede_legale_comune,
+                    $azienda->sede_legale_provincia ? '(' . $azienda->sede_legale_provincia . ')' : null
+                ]);
+            @endphp
+
+            @if(!empty($addressParts))
+                - sede: {{ implode(' ', $addressParts) }}
+            @endif
+
+            @if($azienda->codice_fiscale)
+                - c.f. {{$azienda->codice_fiscale}}
+            @endif
+
+            @if($azienda->partita_iva)
+                - P. IVA {{$azienda->partita_iva}}
+            @endif
+
+        @else
+            <strong>La segreteria</strong><br>
+            <span style="color: red; font-weight: bold;">
+                ATTENZIONE: Configurare i dati aziendali nel sistema
+            </span>
+        @endif
+
+        <div class="privacy-notice">
+            <small>
+                Avvertenze ai sensi del "Regolamento generale sulla protezione dei dati o "GDPR" (General Data Protection Regulation)<br>
+                Le informazioni contenute in questo messaggio di posta elettronica e/o nel/i file/s allegato/i sono da considerarsi strettamente riservate.
+                Il loro utilizzo è consentito esclusivamente al destinatario sopra indicato.
+                Qualora riceveste questo messaggio senza essere il destinatario Vi preghiamo cortesemente di informarci con apposito messaggio e procedere alla distruzione del messaggio stesso, cancellandolo dal Vostro sistema;
+                costituisce comportamento contrario ai principi dettati dal "GDPR" (General Data Protection Regulation) il trattenere il messaggio stesso, divulgarlo anche in parte, distribuirlo ad altri soggetti, ovvero copiarlo.<br>
+                Grazie.
+            </small>
+        </div>
+    </div>
+</body>

+ 113 - 21
resources/views/emails/receipt_delete.blade.php

@@ -1,21 +1,113 @@
-Gentile cliente,
-<br><br>
-la ricevuta di pagamento {{$mailData["number"]}}, emessa il {{$mailData["date"]}}, è stata annullata.
-<br><br>
-Ci scusiamo per l’eventuale inconveniente e rimaniamo a disposizione per qualsiasi chiarimento.
-<br><br>
-Grazie per la sua comprensione e collaborazione.
-<br><br>
-<br><br>
-Cordiali saluti,
-<br><br>
-<span style="color:blue">
-La segreteria S.S.D. IAO TEAM a r.l.<br>
-contatti: 06 60674794 - sede: via di Villa Grazioli snc - 00046 Grottaferrata (RM) - c.f. 92015570580 - P. IVA 12576361005<br>
-<small>
-Avvertenze ai sensi del "Regolamento generale sulla protezione dei dati o "GDPR" (General Data Protection Regulation)<br>
-le informazioni contenute in questo messaggio di posta elettronica e/o nel/i file/s allegato/i sono da considerarsi strettamente riservate. Il loro utilizzo è consentito esclusivamente al destinatario sopra indicato. Qualora riceveste questo messaggio senza essere il destinatario Vi preghiamo cortesemente di informarci con apposito messaggio e procedere alla distruzione del messaggio stesso, cancellandolo dal Vostro sistema; costituisce comportamento contrario ai principi dettati dal "GDPR" (General Data Protection Regulation) il trattenere il messaggio stesso, divulgarlo anche in parte, distribuirlo ad altri soggetti, ovvero copiarlo.<br>
-Grazie.
-</small>
-</span>
-<br><br>
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Ricevuta Annullata</title>
+    <style>
+        body {
+            font-family: Helvetica, Arial, sans-serif;
+            font-size: 14px;
+            line-height: 1.6;
+        }
+        .header {
+            margin-bottom: 20px;
+        }
+        .company-info {
+            color: blue;
+            margin-top: 30px;
+        }
+        .privacy-notice {
+            font-size: 12px;
+            margin-top: 10px;
+        }
+    </style>
+</head>
+<body>
+    @php
+        $azienda = App\Models\Azienda::first();
+    @endphp
+
+    <div class="header">
+        Gentile cliente,
+    </div>
+
+    <p>
+        La ricevuta di pagamento <strong>{{$receipt->number . "/" . $receipt->year}}</strong>,
+        emessa il <strong>{{date("d/m/Y", strtotime($receipt->created_at))}}</strong>,
+        è stata annullata.
+    </p>
+
+    <p>
+        Ci scusiamo per l'eventuale inconveniente e rimaniamo a disposizione per qualsiasi chiarimento.
+    </p>
+
+    <p>
+        Grazie per la sua comprensione e collaborazione.
+    </p>
+
+    <br><br>
+
+    <p>Cordiali saluti,</p>
+
+    <div class="company-info">
+        @if($azienda)
+            <strong>
+                @if($azienda->ragione_sociale)
+                    La segreteria {{$azienda->ragione_sociale}}
+                @elseif($azienda->nome_associazione)
+                    La segreteria {{$azienda->nome_associazione}}
+                @else
+                    La segreteria
+                @endif
+            </strong><br>
+
+            @php
+                $contactInfo = [];
+                if($azienda->telefono) $contactInfo[] = $azienda->telefono;
+                if($azienda->email) $contactInfo[] = $azienda->email;
+            @endphp
+
+            @if(!empty($contactInfo))
+                contatti: {{ implode(' - ', $contactInfo) }}
+            @endif
+
+            @php
+                $addressParts = array_filter([
+                    $azienda->sede_legale_indirizzo,
+                    $azienda->sede_legale_cap,
+                    $azienda->sede_legale_comune,
+                    $azienda->sede_legale_provincia ? '(' . $azienda->sede_legale_provincia . ')' : null
+                ]);
+            @endphp
+
+            @if(!empty($addressParts))
+                - sede: {{ implode(' ', $addressParts) }}
+            @endif
+
+            @if($azienda->codice_fiscale)
+                - c.f. {{$azienda->codice_fiscale}}
+            @endif
+
+            @if($azienda->partita_iva)
+                - P. IVA {{$azienda->partita_iva}}
+            @endif
+
+        @else
+            <strong>La segreteria</strong><br>
+            <span style="color: red; font-weight: bold;">
+                ATTENZIONE: Configurare i dati aziendali nel sistema
+            </span>
+        @endif
+
+        <div class="privacy-notice">
+            <small>
+                Avvertenze ai sensi del "Regolamento generale sulla protezione dei dati o "GDPR" (General Data Protection Regulation)<br>
+                Le informazioni contenute in questo messaggio di posta elettronica e/o nel/i file/s allegato/i sono da considerarsi strettamente riservate.
+                Il loro utilizzo è consentito esclusivamente al destinatario sopra indicato.
+                Qualora riceveste questo messaggio senza essere il destinatario Vi preghiamo cortesemente di informarci con apposito messaggio e procedere alla distruzione del messaggio stesso, cancellandolo dal Vostro sistema;
+                costituisce comportamento contrario ai principi dettati dal "GDPR" (General Data Protection Regulation) il trattenere il messaggio stesso, divulgarlo anche in parte, distribuirlo ad altri soggetti, ovvero copiarlo.<br>
+                Grazie.
+            </small>
+        </div>
+    </div>
+</body>
+</html>

+ 1 - 1
resources/views/layouts/app.blade.php

@@ -363,7 +363,7 @@
                         @endif
                     @endif
                     @if(Auth::user()->level == env('LEVEL_ADMIN', 0))
-                        <div class="accordion-item " style="{{Request::is('reports') || Request::is('categories') || Request::is('disciplines') || Request::is('cards') || Request::is('course_subscriptions') || Request::is('courses') || Request::is('course_durations') || Request::is('course_frequencies') || Request::is('course_levels') || Request::is('course_types') || Request::is('banks') || Request::is('causals') || Request::is('vats') || Request::is('payment_methods') || Request::is('users') ? 'background-color: #c5d9e6;' : ''}}">
+                        <div class="accordion-item " style="{{Request::is('reports') ? 'background-color: #c5d9e6;' : ''}}">
                             <h2 class="accordion-header linkMenu">
                                 <a class="accordion-button collapsed" href="/reports">
                                     Reports

+ 202 - 163
resources/views/livewire/azienda.blade.php

@@ -1,217 +1,257 @@
 <div>
 
-        <div class="col-md-12">
-            <div class="card">
-                <div class="card-body">
-                    @if (session()->has('message'))
-                        <div class="alert alert-success" role="alert">
-                            {{ session()->get('message') }}
-                        </div>
-                    @endif
+    <div class="col-md-12">
+        <div class="card">
+            <div class="card-body">
+                @if (session()->has('message'))
+                    <div class="alert alert-success" role="alert">
+                        {{ session()->get('message') }}
+                    </div>
+                @endif
 
-                    @if (session()->has('error'))
-                        <div class="alert alert-danger" role="alert">
-                            {{ session()->get('error') }}
-                        </div>
-                    @endif
-
-                    <div class="tab-content">
-                        <form wire:submit.prevent="save">
-                            <div class="row mb-4">
-                                <div class="col-md-4">
-                                    <div class="logo-container mb-3 d-flex align-items-center">
-                                        @if($azienda && $azienda->logo)
-                                            <div class="me-3">
-                                                <img src="{{ asset('storage/' . $azienda->logo) }}" alt="Logo" style="height: 200px; width: 200px;border-radius: 10px;">
+                @if (session()->has('error'))
+                    <div class="alert alert-danger" role="alert">
+                        {{ session()->get('error') }}
+                    </div>
+                @endif
+
+                <div class="tab-content">
+                    <form wire:submit.prevent="save">
+                        <div class="row mb-4">
+                            <div class="col-md-4">
+                                <div class="logo-container mb-3 d-flex align-items-center">
+                                    {{-- Display current logo from Wasabi --}}
+                                    @if($this->hasLogo && $this->logoUrl)
+                                        <div class="me-3 position-relative">
+                                            <img src="{{ $this->logoUrl }}" alt="Logo"
+                                                style="height: 200px; width: 200px; border-radius: 10px; object-fit: cover;">
+                                            {{-- Remove logo button --}}
+                                            <button type="button"
+                                                class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
+                                                wire:click="removeLogo"
+                                                style="border-radius: 50%; width: 30px; height: 30px; padding: 0;"
+                                                title="Rimuovi logo">
+                                                <i class="fas fa-times"></i>
+                                            </button>
+                                        </div>
+                                    @endif
+
+                                    <div class="me-3 flex-grow-1 d-flex flex-column justify-content-center">
+                                        <div class="mt-2">
+                                            <label class="form-label">Logo</label>
+                                        </div>
+                                        <input type="file"
+                                            class="form-control form-control-lg @error('temp_logo') is-invalid @enderror"
+                                            wire:model="temp_logo" style="width: 300px;"
+                                            accept="image/jpeg,image/png,image/jpg,image/gif,image/webp">
+
+                                        @error('temp_logo')
+                                            <span class="text-danger">{{ $message }}</span>
+                                        @enderror
+
+                                        {{-- Preview new logo before upload --}}
+                                        @if ($temp_logo)
+                                            <div class="mt-2">
+                                                <p class="text-muted small">Anteprima nuovo logo:</p>
+                                                <img src="{{ $temp_logo->temporaryUrl() }}" class="mt-2"
+                                                    style="max-width: 200px; max-height: 200px; border-radius: 10px; object-fit: cover;">
                                             </div>
                                         @endif
-                                        <div class="me-3 flex-grow-1 d-flex flex-column justify-content-center">
-                                            <div class="mt-2">
-                                                <label class="form-label">Logo</label>
+
+                                        {{-- Loading indicator --}}
+                                        <div wire:loading wire:target="temp_logo" class="mt-2">
+                                            <div class="spinner-border spinner-border-sm text-primary" role="status">
+                                                <span class="visually-hidden">Caricamento...</span>
                                             </div>
-                                            <input type="file" class="form-control form-control-lg" wire:model="temp_logo" style="width: 300px;">
-                                            @if ($temp_logo)
-                                                <img src="{{ $temp_logo->temporaryUrl() }}" class="mt-2" style="max-width: 200px; max-height: 200px;">
-                                            @endif
+                                            <span class="text-primary ms-2">Caricamento logo...</span>
                                         </div>
                                     </div>
                                 </div>
                             </div>
+                        </div>
 
 
-                            <div class="mb-4">
+                        <div class="mb-4">
                             <h5 class="mb-3 blu-text">Anagrafica società</h5>
-                                <div class="row">
-                                    <div class="col-md-6 mb-3">
-                                        <label class="form-label">Ragione sociale*</label>
-                                        <input type="text" class="form-control form-control-lg @error('ragione_sociale') is-invalid @enderror"
-                                                wire:model="ragione_sociale">
-                                        @error('ragione_sociale') <span class="text-danger">{{ $message }}</span> @enderror
-                                    </div>
-                                    <div class="col-md-6 mb-3">
-                                        <label class="form-label">Nome associazione/società</label>
-                                        <input type="text" class="form-control form-control-lg" wire:model="nome_associazione">
-                                    </div>
+                            <div class="row">
+                                <div class="col-md-6 mb-3">
+                                    <label class="form-label">Ragione sociale*</label>
+                                    <input type="text"
+                                        class="form-control form-control-lg @error('ragione_sociale') is-invalid @enderror"
+                                        wire:model="ragione_sociale">
+                                    @error('ragione_sociale') <span class="text-danger">{{ $message }}</span> @enderror
                                 </div>
-                                <div class="row">
-                                    <div class="col-md-6 mb-3">
-                                        <label class="form-label">Tipologia* (ASD/SSD/Polisportiva ecc.)</label>
-                                        <input type="text" class="form-control form-control-lg" wire:model="tipologia">
-                                    </div>
-                                    <div class="col-md-6  mb-3">
-                                        <label class="form-label">Discipline</label>
-                                        <div wire:ignore>
-                                            <select class="form-select discipline-select" style="border-radius: 20px;" multiple wire:model="selectedDisciplines">
-                                                @forelse($discipline as $disciplineScelte)
-                                                    <option value="{{ $disciplineScelte->id }}">
-                                                        {{ $disciplineScelte->name }}
-                                                    </option>
-                                                @empty
-                                                    <option disabled>Nessuna disciplina trovata</option>
-                                                @endforelse
-                                            </select>
-                                        </div>
+                                <div class="col-md-6 mb-3">
+                                    <label class="form-label">Nome associazione/società</label>
+                                    <input type="text" class="form-control form-control-lg"
+                                        wire:model="nome_associazione">
+                                </div>
+                            </div>
+                            <div class="row">
+                                <div class="col-md-6 mb-3">
+                                    <label class="form-label">Tipologia* (ASD/SSD/Polisportiva ecc.)</label>
+                                    <input type="text" class="form-control form-control-lg" wire:model="tipologia">
+                                </div>
+                                <div class="col-md-6  mb-3">
+                                    <label class="form-label">Discipline</label>
+                                    <div wire:ignore>
+                                        <select class="form-select discipline-select" style="border-radius: 20px;"
+                                            multiple wire:model="selectedDisciplines">
+                                            @forelse($discipline as $disciplineScelte)
+                                                <option value="{{ $disciplineScelte->id }}">
+                                                    {{ $disciplineScelte->name }}
+                                                </option>
+                                            @empty
+                                                <option disabled>Nessuna disciplina trovata</option>
+                                            @endforelse
+                                        </select>
                                     </div>
                                 </div>
                             </div>
-                            <h5 class="mb-3 blu-text">Sede legale</h5>
+                        </div>
+                        <h5 class="mb-3 blu-text">Sede legale</h5>
+                        <div class="row">
+                            <div class="col-md-3 mb-3">
+                                <label class="form-label">Nazione</label>
+                                <input type="text" class="form-control" wire:model="sede_legale_nazione">
+                            </div>
+                            <div class="col-md-3 mb-3">
+                                <label class="form-label">Provincia</label>
+                                <input type="text" class="form-control" wire:model="sede_legale_provincia">
+                            </div>
+                            <div class="col-md-6 mb-3">
+                                <label class="form-label">Comune</label>
+                                <input type="text" class="form-control" wire:model="sede_legale_comune">
+                            </div>
+                        </div>
+                        <div class="row">
+                            <div class="col-md-6 mb-3">
+                                <label class="form-label">Indirizzo</label>
+                                <input type="text" class="form-control" wire:model="sede_legale_indirizzo">
+                            </div>
+                            <div class="col-md-3 mb-3">
+                                <label class="form-label">CAP</label>
+                                <input type="text" class="form-control" wire:model="sede_legale_cap">
+                            </div>
+                        </div>
+
+                        <div class="form-check mb-3">
+                            <input class="form-check-input mt-1" type="checkbox" wire:model="same_address"
+                                id="sameAddress">
+                            <label class="form-check-label" style="font-size: medium" for="sameAddress">
+                                Sede operativa uguale a sede legale
+                            </label>
+                        </div>
+
+                        @if(!$same_address)
+                            <h5 class="mb-3 blu-text">Sede operativa</h5>
                             <div class="row">
                                 <div class="col-md-3 mb-3">
                                     <label class="form-label">Nazione</label>
-                                    <input type="text" class="form-control" wire:model="sede_legale_nazione">
+                                    <input type="text" class="form-control" wire:model="sede_operativa_nazione">
                                 </div>
                                 <div class="col-md-3 mb-3">
                                     <label class="form-label">Provincia</label>
-                                    <input type="text" class="form-control" wire:model="sede_legale_provincia">
+                                    <input type="text" class="form-control" wire:model="sede_operativa_provincia">
                                 </div>
                                 <div class="col-md-6 mb-3">
                                     <label class="form-label">Comune</label>
-                                    <input type="text" class="form-control" wire:model="sede_legale_comune">
+                                    <input type="text" class="form-control" wire:model="sede_operativa_comune">
                                 </div>
                             </div>
                             <div class="row">
                                 <div class="col-md-6 mb-3">
                                     <label class="form-label">Indirizzo</label>
-                                    <input type="text" class="form-control" wire:model="sede_legale_indirizzo">
+                                    <input type="text" class="form-control" wire:model="sede_operativa_indirizzo">
                                 </div>
                                 <div class="col-md-3 mb-3">
                                     <label class="form-label">CAP</label>
-                                    <input type="text" class="form-control" wire:model="sede_legale_cap">
+                                    <input type="text" class="form-control" wire:model="sede_operativa_cap">
                                 </div>
                             </div>
+                        @endif
 
-                            <div class="form-check mb-3">
-                                <input class="form-check-input mt-1" type="checkbox" wire:model="same_address" id="sameAddress">
-                                <label class="form-check-label" style="font-size: medium" for="sameAddress">
-                                    Sede operativa uguale a sede legale
-                                </label>
+                        <h5 class="mb-3 blu-text">Contatti</h5>
+                        <div class="row">
+                            <div class="col-md-6 mb-3">
+                                <label class="form-label">Email*</label>
+                                <input type="email" class="form-control @error('email') is-invalid @enderror"
+                                    wire:model="email">
+                                @error('email') <span class="text-danger">{{ $message }}</span> @enderror
                             </div>
-
-                            @if(!$same_address)
-                                <h5 class="mb-3 blu-text">Sede operativa</h5>
-                                <div class="row">
-                                    <div class="col-md-3 mb-3">
-                                        <label class="form-label">Nazione</label>
-                                        <input type="text" class="form-control" wire:model="sede_operativa_nazione">
-                                    </div>
-                                    <div class="col-md-3 mb-3">
-                                        <label class="form-label">Provincia</label>
-                                        <input type="text" class="form-control" wire:model="sede_operativa_provincia">
-                                    </div>
-                                    <div class="col-md-6 mb-3">
-                                        <label class="form-label">Comune</label>
-                                        <input type="text" class="form-control" wire:model="sede_operativa_comune">
-                                    </div>
-                                </div>
-                                <div class="row">
-                                    <div class="col-md-6 mb-3">
-                                        <label class="form-label">Indirizzo</label>
-                                        <input type="text" class="form-control" wire:model="sede_operativa_indirizzo">
-                                    </div>
-                                    <div class="col-md-3 mb-3">
-                                        <label class="form-label">CAP</label>
-                                        <input type="text" class="form-control" wire:model="sede_operativa_cap">
-                                    </div>
-                                </div>
-                            @endif
-
-                            <h5 class="mb-3 blu-text">Contatti</h5>
-                            <div class="row">
-                                <div class="col-md-6 mb-3">
-                                    <label class="form-label">Email*</label>
-                                    <input type="email" class="form-control @error('email') is-invalid @enderror"
-                                            wire:model="email">
-                                    @error('email') <span class="text-danger">{{ $message }}</span> @enderror
-                                </div>
-                                <div class="col-md-6 mb-3">
-                                    <label class="form-label">Pec*</label>
-                                    <input type="email" class="form-control @error('pec') is-invalid @enderror"
-                                            wire:model="pec">
-                                    @error('pec') <span class="text-danger">{{ $message }}</span> @enderror
-                                </div>
+                            <div class="col-md-6 mb-3">
+                                <label class="form-label">Pec*</label>
+                                <input type="email" class="form-control @error('pec') is-invalid @enderror"
+                                    wire:model="pec">
+                                @error('pec') <span class="text-danger">{{ $message }}</span> @enderror
                             </div>
-                            <div class="row">
-                                <div class="col-md-6 mb-3">
-                                    <label class="form-label">Telefono</label>
-                                    <input type="text" class="form-control" wire:model="telefono">
-                                </div>
-                                <div class="col-md-6 mb-3">
-                                    <label class="form-label">Cellulare*</label>
-                                    <input type="text" class="form-control @error('cellulare') is-invalid @enderror"
-                                            wire:model="cellulare">
-                                    @error('cellulare') <span class="text-danger">{{ $message }}</span> @enderror
-                                </div>
+                        </div>
+                        <div class="row">
+                            <div class="col-md-6 mb-3">
+                                <label class="form-label">Telefono</label>
+                                <input type="text" class="form-control" wire:model="telefono">
                             </div>
-
-                            <h5 class="mb-3  blu-text">Dati fiscali</h5>
-                            <div class="row">
-                                <div class="col-md-3 mb-3">
-                                    <label class="form-label">Partita IVA</label>
-                                    <input type="text" class="form-control " wire:model="partita_iva">
-                                </div>
-                                <div class="col-md-3 mb-3">
-                                    <label class="form-label">Codice fiscale</label>
-                                    <input type="text" class="form-control " wire:model="codice_fiscale">
-                                </div>
-                                <div class="col-md-3 mb-3">
-                                    <label class="form-label">Codice SDI</label>
-                                    <input type="text" class="form-control " wire:model="codice_sdi">
-                                </div>
+                            <div class="col-md-6 mb-3">
+                                <label class="form-label">Cellulare*</label>
+                                <input type="text" class="form-control @error('cellulare') is-invalid @enderror"
+                                    wire:model="cellulare">
+                                @error('cellulare') <span class="text-danger">{{ $message }}</span> @enderror
                             </div>
+                        </div>
 
-                            <div class="mt-4 d-flex justify-content-start">
-                                <button type="button" class="btn--ui lightGrey" onclick="annulla()">Annulla</button>
-                                <button type="submit" class="btn text-light"style="background-color:#0C6197;margin-left:15px; ">Salva</button>
+                        <h5 class="mb-3  blu-text">Dati fiscali</h5>
+                        <div class="row">
+                            <div class="col-md-3 mb-3">
+                                <label class="form-label">Partita IVA</label>
+                                <input type="text" class="form-control " wire:model="partita_iva">
                             </div>
-                        </form>
-                    </div>
+                            <div class="col-md-3 mb-3">
+                                <label class="form-label">Codice fiscale</label>
+                                <input type="text" class="form-control " wire:model="codice_fiscale">
+                            </div>
+                            <div class="col-md-3 mb-3">
+                                <label class="form-label">Codice SDI</label>
+                                <input type="text" class="form-control " wire:model="codice_sdi">
+                            </div>
+                        </div>
+
+                        <div class="mt-4 d-flex justify-content-start">
+                            <button type="button" class="btn--ui lightGrey" onclick="annulla()">Annulla</button>
+                            <button type="submit" class="btn text-light"
+                                style="background-color:#0C6197;margin-left:15px; ">Salva</button>
+                        </div>
+                    </form>
                 </div>
             </div>
         </div>
     </div>
-    <style>
-        /* Custom CSS for rounded multi-select */
+</div>
+<style>
+    /* Custom CSS for rounded multi-select */
 
-        /* If using Choices.js */
-        .discipline-select + .choices {
-            border-radius: 20px !important;
-        }
-        .discipline-select + .choices .choices__inner {
-            border-radius: 20px !important;
-        }
-        .discipline-select + .choices .choices__list--dropdown {
-            border-radius: 15px !important;
-        }
-        .blu-text {
-            color: #0C6197 !important;
-        }
-        </style>
+    /* If using Choices.js */
+    .discipline-select+.choices {
+        border-radius: 20px !important;
+    }
+
+    .discipline-select+.choices .choices__inner {
+        border-radius: 20px !important;
+    }
+
+    .discipline-select+.choices .choices__list--dropdown {
+        border-radius: 15px !important;
+    }
+
+    .blu-text {
+        color: #0C6197 !important;
+    }
+</style>
 @push('scripts')
     <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
     <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
     <script>
-        $(document).ready(function() {
+        $(document).ready(function () {
             $('.discipline-select').select2({
                 placeholder: 'Seleziona discipline',
                 allowClear: true
@@ -223,8 +263,7 @@
             });
         });
 
-        function annulla()
-        {
+        function annulla() {
             window.onbeforeunload = null;
             document.location.href = '/dashboard';
         }

+ 86 - 23
resources/views/livewire/member.blade.php

@@ -11,9 +11,19 @@
                             <div class="avatar--wrapper d-flex align-items-center justify-content-between w-50">
 
                                 <figure class="m-0 avatar--wrapper_img">
-                                    @if ($currentMember->image != '')
-                                        <img src="{{ asset('storage/app/public/'.$currentMember->image) }}" style="max-width:200px">
-                                    @endif
+                                       @if ($currentMember->image != '')
+                                            @php
+                                                $fileService = app(App\Services\MemberFileService::class);
+                                                $imageUrl = $fileService->getFileUrl($currentMember->image);
+                                            @endphp
+                                            @if($imageUrl)
+                                                <img src="{{ $imageUrl }}" style="max-width:200px" alt="Profile Image">
+                                            @else
+                                                <div style="width: 200px; height: 150px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
+                                                    <span style="color: #999;">Image not available</span>
+                                                </div>
+                                            @endif
+                                        @endif
                                 </figure>
                             </div>
                         </header>
@@ -601,10 +611,16 @@
                                                             @error('image') <span class="error">{{ $message }}</span> @enderror
                                                             <input class="form-control" type="file" wire:model="image">
                                                             @if ($image)
-                                                                <img src="{{ $image->temporaryUrl() }}" style="max-width:200px">
+                                                                <img src="{{ $image->temporaryUrl() }}" style="max-width:200px" alt="Preview">
                                                             @endif
                                                             @if ($image_old)
-                                                                <img src="{{ asset('storage/app/public/'.$image_old) }}" style="max-width:200px">
+                                                                @php
+                                                                    $fileService = app(App\Services\MemberFileService::class);
+                                                                    $imageUrl = $fileService->getFileUrl($image_old);
+                                                                @endphp
+                                                                @if($imageUrl)
+                                                                    <img src="{{ $imageUrl }}" style="max-width:200px" alt="Current Image">
+                                                                @endif
                                                             @endif
                                                         </div>
                                                     </div>
@@ -736,19 +752,28 @@
                                                         <input class="form-control" type="file" wire:model="documents" multiple><br>
                                                         <label for="document_files" class="form-label">Caricati</label>
                                                         @if ($document_files !== null && count(array_filter($document_files)) > 0)
-                                                            @foreach ($document_files as $idx => $d)
-                                                                @if (!empty($d))
-                                                                    <div class="row">
-                                                                        <div class="col-6">
-                                                                            <a href="{{ asset('storage/app/public/'.$d) }}" target="_blank" class="form-label">{{$d}}</a>
-                                                                        </div>
-                                                                        <div class="col-6">
-                                                                            <a wire:click="removeDocument({{$idx}},'self')" class="form-label">(elimina)</a><br>
-                                                                        </div>
+                                                        @foreach ($document_files as $idx => $d)
+                                                            @if (!empty($d))
+                                                                <div class="row">
+                                                                    <div class="col-6">
+                                                                        @php
+                                                                            $fileService = app(App\Services\MemberFileService::class);
+                                                                            $fileUrl = $fileService->getFileUrl($d);
+                                                                            $fileName = basename($d);
+                                                                        @endphp
+                                                                        @if($fileUrl)
+                                                                            <a href="{{ $fileUrl }}" target="_blank" class="form-label">{{ $fileName }}</a>
+                                                                        @else
+                                                                            <span class="form-label text-muted">{{ $fileName }} (not available)</span>
+                                                                        @endif
                                                                     </div>
-                                                                @endif
-                                                            @endforeach
-                                                        @endif
+                                                                    <div class="col-6">
+                                                                        <a wire:click="removeDocument({{$idx}},'self')" class="form-label" style="cursor: pointer;">(elimina)</a><br>
+                                                                    </div>
+                                                                </div>
+                                                            @endif
+                                                        @endforeach
+                                                    @endif
                                                     </div>
                                                 </div>
 
@@ -795,10 +820,19 @@
                                                                     @if (!empty($d))
                                                                         <div class="row">
                                                                             <div class="col-6">
-                                                                                <a href="{{ asset('storage/app/public/'.$d) }}" target="_blank" class="form-label">{{$d}}</a>
+                                                                                @php
+                                                                                    $fileService = app(App\Services\MemberFileService::class);
+                                                                                    $fileUrl = $fileService->getFileUrl($d);
+                                                                                    $fileName = basename($d);
+                                                                                @endphp
+                                                                                @if($fileUrl)
+                                                                                    <a href="{{ $fileUrl }}" target="_blank" class="form-label">{{ $fileName }}</a>
+                                                                                @else
+                                                                                    <span class="form-label text-muted">{{ $fileName }} (not available)</span>
+                                                                                @endif
                                                                             </div>
                                                                             <div class="col-6">
-                                                                                <a wire:click="removeDocument({{$idx}},'father')" class="form-label">(elimina)</a><br>
+                                                                                <a wire:click="removeDocument({{$idx}},'father')" class="form-label" style="cursor: pointer;">(elimina)</a><br>
                                                                             </div>
                                                                         </div>
                                                                     @endif
@@ -848,10 +882,19 @@
                                                                 @if (!empty($d))
                                                                     <div class="row">
                                                                         <div class="col-6">
-                                                                            <a href="{{ asset('storage/app/public/'.$d) }}" target="_blank" class="form-label">{{$d}}</a>
+                                                                            @php
+                                                                                $fileService = app(App\Services\MemberFileService::class);
+                                                                                $fileUrl = $fileService->getFileUrl($d);
+                                                                                $fileName = basename($d);
+                                                                            @endphp
+                                                                            @if($fileUrl)
+                                                                                <a href="{{ $fileUrl }}" target="_blank" class="form-label">{{ $fileName }}</a>
+                                                                            @else
+                                                                                <span class="form-label text-muted">{{ $fileName }} (not available)</span>
+                                                                            @endif
                                                                         </div>
                                                                         <div class="col-6">
-                                                                            <a wire:click="removeDocument({{$idx}},'mother')" class="form-label">(elimina)</a><br>
+                                                                            <a wire:click="removeDocument({{$idx}},'mother')" class="form-label" style="cursor: pointer;">(elimina)</a><br>
                                                                         </div>
                                                                     </div>
                                                                 @endif
@@ -879,7 +922,19 @@
                                                                         <tr>
                                                                             <td>{{$member_certificate->type == 'A' ? 'Agonistico' : 'Non agonistico'}}</td>
                                                                             <td>{{$member_certificate->expire_date ? date("d/m/Y", strtotime($member_certificate->expire_date)) : ''}}</td>
-                                                                            <td>{!!$member_certificate->filename != '' ? '<a href="/storage/app/public/' . $member_certificate->filename . '" target="_blank">Visualizza</a>' : ''!!}</td>
+                                                                            <td>
+                                                                                @if($member_certificate->filename != '')
+                                                                                    @php
+                                                                                        $fileService = app(App\Services\MemberFileService::class);
+                                                                                        $certificateUrl = $fileService->getFileUrl($member_certificate->filename);
+                                                                                    @endphp
+                                                                                    @if($certificateUrl)
+                                                                                        <a href="{{ $certificateUrl }}" target="_blank">Visualizza</a>
+                                                                                    @else
+                                                                                        <span class="text-muted">File non disponibile</span>
+                                                                                    @endif
+                                                                                @endif
+                                                                            </td>
                                                                             <td>
                                                                                 <button type="button" class="btn" wire:click="editCertificate({{ $member_certificate->id }})" data-bs-toggle="popover"  data-bs-trigger="hover focus" data-bs-placement="bottom" data-bs-content="Modifica"><i class="fa-regular fa-pen-to-square"></i></button>
                                                                                 <button type="button" class="btn" onclick="confirm('Sei sicuro?') || event.stopImmediatePropagation()" wire:click="deleteCertificate({{ $member_certificate->id }})" data-bs-toggle="popover" data-bs-trigger="hover focus" data-bs-placement="bottom" data-bs-content="Elimina"><i class="fa-regular fa-trash-can"></i></button>
@@ -911,7 +966,15 @@
                                                                             <input class="form-control" type="file" wire:model="certificate_filename">
                                                                         </div>
                                                                         <p class="caption text-center mt-1">Formati consentiti: .jpg, .pdf, .docx</p>
-                                                                        {!!$certificate_filename_old != '' ? '<br><a href="/storage/app/public/' . $certificate_filename_old . '" target="_blank">Visualizza</a>' : ''!!}
+                                                                        @if($certificate_filename_old != '')
+                                                                            @php
+                                                                                $fileService = app(App\Services\MemberFileService::class);
+                                                                                $certificateUrl = $fileService->getFileUrl($certificate_filename_old);
+                                                                            @endphp
+                                                                            @if($certificateUrl)
+                                                                                <br><a href="{{ $certificateUrl }}" target="_blank">Visualizza certificato corrente</a>
+                                                                            @endif
+                                                                        @endif
                                                                     </div>
                                                                 </form>
                                                             </div>

+ 837 - 234
resources/views/livewire/records.blade.php

@@ -7,69 +7,74 @@
         </div>
 
     </header>
-
     <section id="subheader" class="">
-        <!--
-        <form action="" class="group--action d-flex align-items-center">
-        <select class="form-select form-select-lg me-1" aria-label=".form-select-lg example">
-            <option selected>Open this select menu</option>
-            <option value="1">One</option>
-            <option value="2">Two</option>
-            <option value="3">Three</option>
-            </select>
-            <button type="submit" class="btn--ui">applica</button>
-        </form>
-        -->
-
-            <div class="row g-3">
-                <div class="col-md-2">
-                    Utente
-                    <select name="search_member_id" class="form-select filterMember" wire:model="filterMember">
-                        <option value="">--Seleziona--
-                        @foreach($members as $member)
-                            <option value="{{$member->id}}">{{$member->last_name}} {{$member->first_name}}
-                        @endforeach
-                    </select>
-                </div>
-                <div class="col-md-4">
-                    Causale
-                    <select name="search_causal_id[]" class="form-select filterCausals me-2" multiple="multiple" wire:model="filterCausals">
-                        @foreach($causals as $causal)
-                            <option value="{{$causal["id"]}}">{!!$causal["name"]!!}
-                        @endforeach
-                    </select>
-                </div>
-                <div class="col-md-2">
-                    <span class="date_span">Dal</span><input type="date" wire:model="fromDate" class="form-control">
-                </div>
-                <div class="col-md-2">
-                    <span class="date_span ms-2">al</span><input type="date" wire:model="toDate" class="form-control">
-                </div>
-                <div class="col-md-2">
-                    <div class="prima--nota_buttons ms-auto " style="float:right; margin-top:25px;">
-                        <button class="btn--ui lightGrey reset reset" style="margin-left:5px;color:#10172A;" onclick="reset()">RESET</button>
-                    </div>
-                </div>
+        <div class="row g-3">
+            <div class="col-md-3">
+                Utente
+                <select name="search_member_id" class="form-select filterMember" wire:model="filterMember">
+                    <option value="">--Seleziona--
+                    @foreach($members as $member)
+                        <option value="{{$member->id}}">{{$member->last_name}} {{$member->first_name}}
+                    @endforeach
+                </select>
             </div>
-            <div style="float:left; margin-top:10px; margin-bottom:10px;">
-                <div class="dropdown">
-                  <button class="btn--ui_outline light dropdown-toggle" type="button" id="exportDropdown" data-bs-toggle="dropdown" aria-expanded="false"
-                  style="color:#10172A;">
-                    ESPORTA
-                  </button>
-                  <ul class="dropdown-menu" aria-labelledby="exportDropdown">
-                    <li><a class="dropdown-item" href="#" wire:click="export()">Excel</a></li>
-                    <li><a class="dropdown-item" href="#" id="print">Stampa</a></li>
-                  </ul>
+            <div class="col-md-4">
+                Causale
+                <select name="search_causal_id[]" class="form-select filterCausals me-2" multiple="multiple" wire:model="filterCausals">
+                    @foreach($causals as $causal)
+                        <option value="{{$causal["id"]}}">{!!$causal["name"]!!}
+                    @endforeach
+                </select>
+            </div>
+            <div class="col-md-3">
+                Periodo
+                <select wire:model="selectedPeriod" class="form-select" @if($isFiltering) disabled @endif style="height: 43px!important;">
+                    <option value="OGGI">Oggi</option>
+                    <option value="IERI">Ieri</option>
+                    <option value="MESE CORRENTE">Mese Corrente</option>
+                    <option value="MESE PRECEDENTE">Mese Precedente</option>
+                    <option value="ULTIMO TRIMESTRE">Ultimo Trimestre</option>
+                    <option value="ULTIMO QUADRIMESTRE">Ultimo Quadrimestre</option>
+                </select>
+            </div>
+            <div class="col-md-2">
+                <div class="prima--nota_buttons ms-auto" style="float:right; margin-top:25px;">
+                    <button class="btn--ui primary" wire:click="applyFilters" style="margin-right:5px;" @if($isFiltering) disabled @endif>
+                        @if($isFiltering)
+                            <i class="fas fa-spinner fa-spin"></i> CARICAMENTO...
+                        @else
+                            FILTRA
+                        @endif
+                    </button>
+                    <button class="btn--ui lightGrey reset reset" style="margin-left:5px;color:#10172A;" wire:click="resetFilters" @if($isFiltering) disabled @endif>RESET</button>
                 </div>
-              </div>
+            </div>
+        </div>
+        <div style="float:left; margin-top:10px; margin-bottom:10px;">
+            <div class="dropdown">
+            <button class="btn--ui_outline light dropdown-toggle" type="button" id="exportDropdown" data-bs-toggle="dropdown" aria-expanded="false"
+            style="color:#10172A;" @if($isFiltering) disabled @endif>
+                ESPORTA
+            </button>
+            <ul class="dropdown-menu" aria-labelledby="exportDropdown">
+                <li><a class="dropdown-item" href="#" wire:click="openExportModal">Excel</a></li>
+                <li><a class="dropdown-item" href="#" id="print">Stampa</a></li>
+            </ul>
+            </div>
+        </div>
     </section>
 
-    <section id="resume-table"  class="scrollTable records-table">
+    <section id="resume-table" class="scrollTable records-table" style="position: relative;">
+
+        @if($isFiltering)
+            <div class="loading-overlay">
+                <div class="loading-content">
+                    <i class="fas fa-spinner fa-spin fa-3x"></i>
+                    <p>Caricamento dati in corso...</p>
+                </div>
+            </div>
+        @endif
 
-        <!--
-        <canvas id="recordChart"></canvas>
-            -->
         <table class="table tablesaw tableHead tablesaw-stack" id="tablesaw-350" width="100%">
             <thead>
                 <tr>
@@ -95,7 +100,6 @@
                         @elseif($p->type == 'IN')
                             <th scope="col" style="text-align:center; border-left:3px solid white;">Entrate</th>
                             <th scope="col" style="text-align:center;"></th>
-
                         @elseif($p->type == 'OUT')
                             <th style="border-left:3px solid white;"></th>
                             <th scope="col" style="text-align:center;">Uscite</th>
@@ -104,18 +108,32 @@
                 </tr>
             </thead>
             <tbody id="checkall-target">
-                @php
-                $count = 0;
-                @endphp
+                @php $count = 0; @endphp
                 @foreach($records as $causal => $record)
                     <tr>
                         @php
-                        $check = strpos($causal, "$") ? explode("$", $causal)[1] : $causal;
-                        list($d, $c, $n, $det, $del) = explode("§", $check);
+                        $parts = explode("§", $causal);
+                        $d = $parts[0] ?? '';
+                        $c = $parts[1] ?? '';
+                        $n = $parts[2] ?? '';
+                        $det = $parts[3] ?? '';
+                        $del = $parts[4] ?? '';
+
+                        $detailParts = explode('|', $det);
+                        $isMultiple = count($detailParts) > 1;
+                        $displayDetail = $isMultiple ? 'Varie' : $det;
                         @endphp
                         <td style="background-color:{{$count % 2 == 0 ? 'white' : '#f2f4f7'}}">{{date("d/m/Y", strtotime($d))}}</td>
                         <td style="border-left:3px solid white !important;background-color:{{$count % 2 == 0 ? 'white' : '#f2f4f7'}}">{{$c}}</td>
-                        <td style="border-left:3px solid white !important;background-color:{{$count % 2 == 0 ? 'white' : '#f2f4f7'}}">{{$det}}</td>
+                        <td style="border-left:3px solid white !important;background-color:{{$count % 2 == 0 ? 'white' : '#f2f4f7'}}">
+                            @if($isMultiple)
+                                <span class="varie-link" data-causals="{{implode('|', array_slice($detailParts, 1))}}" style="color: #0C6197; cursor: pointer; text-decoration: underline;">
+                                    {{$displayDetail}}
+                                </span>
+                            @else
+                                {{$displayDetail}}
+                            @endif
+                        </td>
                         <td style="border-left:3px solid white !important;background-color:{{$count % 2 == 0 ? 'white' : '#f2f4f7'}}">
                             @if($del == 'DELETED')
                                 <span style='color:red'>Annullata</span>
@@ -140,9 +158,7 @@
                             @endif
                         @endforeach
                     </tr>
-                    @php
-                    $count++;
-                    @endphp
+                    @php $count++; @endphp
                 @endforeach
             </tbody>
             <tfoot>
@@ -203,56 +219,212 @@
                 </tr>
             </tfoot>
         </table>
+        <button type="button" class="btn btn-floating btn-lg" id="btn-back-to-bottom"><i class="fas fa-arrow-down"></i></button>
+        <button type="button" class="btn btn-floating btn-lg" id="btn-back-to-top"><i class="fas fa-arrow-up"></i></button>
+    </section>
 
-        <!--
-        <div class="paginator d-flex justify-content-center">
-            <nav aria-label="Page navigation example">
-                <ul class="pagination">
-                    <li class="page-item">
-                    <a class="page-link" href="#" aria-label="Previous">
-                        <span aria-hidden="true"></span>
-                    </a>
-                    </li>
-                    <li class="page-item"><a class="page-link" href="#">1</a></li>
-                    <li class="page-item"><a class="page-link" href="#">2</a></li>
-                    <li class="page-item"><a class="page-link" href="#">3</a></li>
-                    <li class="page-item"><a class="page-link" href="#">3</a></li>
-
-                    <li class="page-item"><span class="more-page">...</span></li>
-
-                    <li class="page-item">
-                    <a class="page-link" href="#" aria-label="Next">
-                        <span aria-hidden="true"></span>
-                    </a>
-                    </li>
-                </ul>
-                </nav>
+    <div class="modal fade" id="causalsModal" tabindex="-1" aria-labelledby="causalsModalLabel" aria-hidden="true">
+        <div class="modal-dialog modal-lg">
+            <div class="modal-content">
+                <div class="modal-header" style="background-color: #0C6197!important;">
+                    <h5 class="modal-title" id="causalsModalLabel">Dettaglio Causali</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="CHIUDI"></button>
+                </div>
+                <div class="modal-body">
+                    <div class="row">
+                        <div class="col-12">
+                            <h6>Causali incluse:</h6>
+                            <ul id="causalsList" class="list-group list-group-flush">
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+                <div class="modal-footer" style="background-color: #FFF!important;">
+                    <button type="button" class="btn--ui lightGrey me-2" data-bs-dismiss="modal">CHIUDI</button>
+                </div>
+            </div>
         </div>
-        -->
-        <button type="button" class="btn btn-floating btn-lg" id="btn-back-to-bottom" ><i class="fas fa-arrow-down"></i></button>
-        <button type="button" class="btn btn-floating btn-lg" id="btn-back-to-top" ><i class="fas fa-arrow-up"></i></button>
-    </section>
+    </div>
 
+    <div class="modal fade" id="exportModal" tabindex="-1" aria-labelledby="exportModalLabel" aria-hidden="true">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header" style="background-color: #0C6197!important;">
+                <h5 class="modal-title" id="exportModalLabel">Seleziona Periodo per Export</h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="CHIUDI"></button>
+            </div>
+            <div class="modal-body">
+                <div class="row g-3">
+                    <div class="col-md-6">
+                        <label for="exportFromDate" class="form-label">Data Inizio</label>
+                        <input type="date" class="form-control" id="exportFromDate" wire:model.defer="exportFromDate">
+                    </div>
+                    <div class="col-md-6">
+                        <label for="exportToDate" class="form-label">Data Fine</label>
+                        <input type="date" class="form-control" id="exportToDate" wire:model.defer="exportToDate">
+                    </div>
+                </div>
 
+                <div class="row mt-4">
+                    <div class="col-12">
+                        <div class="form-check export-method-check">
+                            <input class="form-check-input" type="checkbox" id="sendViaEmail" wire:model.defer="sendViaEmail">
+                            <label class="form-check-label" for="sendViaEmail">
+                                <i class="fas fa-envelope me-2"></i>Invia via Email
+                                <small class="d-block text-muted mt-1">L'export verrà elaborato in background e inviato alla tua email</small>
+                            </label>
+                        </div>
+                    </div>
+                </div>
 
+                <div class="row mt-3" style="display: none;" id="emailAddressRow">
+                    <div class="col-12">
+                        <label for="exportEmailAddress" class="form-label">
+                            <i class="fas fa-envelope me-1"></i>Indirizzo Email
+                        </label>
+                        <input type="email" class="form-control" id="exportEmailAddress"
+                               wire:model.defer="exportEmailAddress"
+                               placeholder="inserisci@email.com">
+                        <div class="invalid-feedback" id="emailValidationFeedback">
+                            Inserisci un indirizzo email valido
+                        </div>
+                        <small class="form-text text-muted">
+                            Il file Excel verrà inviato a questo indirizzo
+                        </small>
+                    </div>
+                </div>
 
+                <div class="row mt-3" style="display: none;" id="emailSubjectRow">
+                    <div class="col-12">
+                        <label for="exportEmailSubject" class="form-label">
+                            <i class="fas fa-tag me-1"></i>Oggetto Email
+                        </label>
+                        <input type="text" class="form-control" id="exportEmailSubject"
+                               wire:model.defer="exportEmailSubject"
+                               placeholder="Prima Nota - Export">
+                        <small class="form-text text-muted">
+                            Personalizza l'oggetto dell'email
+                        </small>
+                    </div>
+                </div>
 
+                <div class="row mt-3">
+                    <div class="col-12">
+                        <div class="alert alert-info d-flex align-items-start">
+                            <i class="fas fa-info-circle me-2 mt-1"></i>
+                            <div>
+                                <strong>Informazioni Export:</strong>
+                                <ul class="mb-0 mt-1">
+                                    <li>L'export includerà tutti i record nel periodo selezionato</li>
+                                    <li>Verranno applicati i filtri attualmente attivi</li>
+                                    <li id="emailProcessingInfo" style="display: none;">L'elaborazione avverrà in background, potrai continuare a usare l'applicazione</li>
+                                </ul>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer" style="background-color: #FFF!important;">
+                <button type="button" class="btn--ui lightGrey me-2" data-bs-dismiss="modal">ANNULLA</button>
+                <button type="button" class="btn--ui primary" onclick="handleExportClick()" id="exportButton">
+                    <span id="loadingState" style="display: none;">
+                        <div class="spinner-border spinner-border-sm me-2" role="status">
+                            <span class="visually-hidden">Loading...</span>
+                        </div>
+                        ELABORAZIONE...
+                    </span>
+                    <span id="normalState">
+                        <i class="fas fa-download me-1"></i>
+                        ESPORTA
+                    </span>
+                </button>
+            </div>
+        </div>
+    </div>
 </div>
 
+<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 11000;">
+    <!-- Toasts will be dynamically added here -->
+</div>
+</div>
 
 @push('scripts')
     <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
 @endpush
+
 @push('scripts')
     <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
     <style>
+        .loading-overlay {
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background-color: rgba(255, 255, 255, 0.9);
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            z-index: 1000;
+            border-radius: 4px;
+        }
+
+        .loading-content {
+            text-align: center;
+            color: #0C6197;
+        }
+
+        .loading-content i {
+            margin-bottom: 15px;
+            color: #0C6197;
+        }
+
+        .loading-content p {
+            margin: 0;
+            font-size: 16px;
+            font-weight: 500;
+            color: #10172A;
+        }
+
+        .modal {
+            z-index: 9999 !important;
+        }
+
+        .modal-backdrop {
+            z-index: 9998 !important;
+            background-color: rgba(0, 0, 0, 0.6) !important;
+        }
+
+        .modal-content {
+            border-radius: 8px;
+            border: none;
+            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
+            z-index: 10000 !important;
+        }
+
+        .modal-header {
+            color: white;
+            border-bottom: none;
+            border-radius: 8px 8px 0 0;
+        }
+
+        .modal-header .btn-close {
+            filter: invert(1);
+        }
+
+        .modal-title {
+            font-weight: 600;
+        }
+
+        .varie-link:hover {
+            color: #084c6b !important;
+            text-decoration: underline !important;
+        }
 
         #btn-back-to-top {
             background-color: #0C6197;
             color: white;
             position: fixed;
-            /* bottom: 20px; */
-            /* right: 20px; */
             display: none;
         }
 
@@ -260,17 +432,23 @@
             background-color: #0C6197;
             color: white;
             position: fixed;
-            /* top: 120px; */
-            /* right: 20px; */
             z-index: 9999;
             display: none;
         }
 
+        button[disabled] {
+            opacity: 0.7;
+            cursor: not-allowed;
+        }
+
+        .btn--ui .fa-spinner {
+            margin-right: 5px;
+        }
+
         .scrollTable {
-            margin-left: 0px ;
-            margin-right: 0px ;
+            margin-left: 0px;
+            margin-right: 0px;
             padding: 15px;
-            /*max-width: 800px !important;*/
             overflow-x: auto;
             overflow-y: auto;
             white-space: nowrap;
@@ -283,47 +461,44 @@
         }
 
         table thead {
-        /* Important */
             position: sticky;
             z-index: 100;
             top: 0;
         }
-        .select2-container--default .select2-selection--single{
+
+        .select2-container--default .select2-selection--single {
             background-color: #E9F0F5;
             border: 0.0625rem solid #DFE5EB;
             font-size: 0.75rem;
-        }
-        .select2-selection
-        {
             height: 38px !important;
         }
-        .select2-selection__rendered
-        {
-            padding-top:3px;
+
+        .select2-selection__rendered {
+            padding-top: 3px;
         }
+
         .select2 {
-            width:100% !important;
-        }
-        .i{
-            font-size:16px;
-            font-weight:bold;
-        }
-        .cellBorder
-        {
-            border-left: 1px solid grey;
+            width: 100% !important;
         }
 
-        .select2-selection--multiple{
+        .select2-selection--multiple {
             overflow: hidden !important;
             height: auto !important;
         }
+
         .select2-container {
             box-sizing: border-box;
             display: inline-block;
             margin: 0;
             position: relative;
             vertical-align: middle;
+            z-index: 999 !important;
         }
+
+        .select2-dropdown {
+            z-index: 999 !important;
+        }
+
         .select2-container .select2-selection--single {
             box-sizing: border-box;
             cursor: pointer;
@@ -332,6 +507,7 @@
             user-select: none;
             -webkit-user-select: none;
         }
+
         .select2-container .select2-selection--single .select2-selection__rendered {
             display: block;
             padding-left: 8px;
@@ -340,9 +516,11 @@
             text-overflow: ellipsis;
             white-space: nowrap;
         }
+
         button#exportDropdown.btn--ui_outline.light {
             font-weight: normal !important;
         }
+
         .btn--ui_outline.light.dropdown-toggle:active,
         .btn--ui_outline.light.dropdown-toggle:focus,
         .btn--ui_outline.light.dropdown-toggle.show {
@@ -356,6 +534,167 @@
             color: #10172A !important;
         }
 
+        .form-select {
+            height: 38px !important;
+        }
+
+        .form-control {
+            height: 43px !important;
+        }
+
+        #exportModal .modal-body {
+            padding: 1.5rem;
+        }
+
+        #exportModal .form-label {
+            font-weight: 600;
+            color: #10172A;
+            margin-bottom: 0.5rem;
+        }
+
+        #exportModal .text-muted {
+            font-size: 0.875rem;
+        }
+
+        .btn--ui[disabled] .fa-spinner {
+            margin-right: 0.5rem;
+        }
+
+        body.modal-open {
+            overflow: hidden;
+        }
+
+        .modal-dialog {
+            z-index: 10001 !important;
+            margin: 1.75rem auto;
+        }
+
+        .list-group-item {
+            border-left: none;
+            border-right: none;
+            border-top: 1px solid #dee2e6;
+            padding: 12px 15px;
+        }
+
+        .list-group-item:first-child {
+            border-top: none;
+        }
+
+        .list-group-item:last-child {
+            border-bottom: none;
+        }
+
+        @media (max-width: 768px) {
+            .col-md-2, .col-md-3, .col-md-4 {
+                margin-bottom: 10px;
+            }
+
+            .prima--nota_buttons {
+                float: none !important;
+                margin-top: 10px !important;
+                text-align: center;
+            }
+        }
+
+        .export-method-check {
+            padding: 16px 16px 16px 50px;
+            background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+            border-radius: 8px;
+            border: 2px solid #e9ecef;
+            transition: all 0.3s ease;
+            cursor: pointer;
+            position: relative;
+        }
+
+        .export-method-check:hover {
+            border-color: #0C6197;
+            background: linear-gradient(135deg, #e8f4f8 0%, #d1ecf1 100%);
+        }
+
+        .export-method-check .form-check-input {
+            position: absolute;
+            left: 16px;
+            top: 50%;
+            transform: translateY(-50%);
+            margin: 0;
+            width: 20px;
+            height: 20px;
+            background-color: #fff;
+            border: 2px solid #dee2e6;
+            border-radius: 4px;
+            cursor: pointer;
+        }
+
+        .export-method-check .form-check-input:checked {
+            background-color: #0C6197;
+            border-color: #0C6197;
+        }
+
+        .export-method-check .form-check-input:checked ~ .form-check-label {
+            color: #0C6197;
+            font-weight: 600;
+        }
+
+        .export-method-check .form-check-label {
+            font-weight: 500;
+            color: #495057;
+            cursor: pointer;
+            margin-left: 0;
+            display: block;
+        }
+
+        .form-check-input:focus {
+            border-color: #0C6197;
+            outline: 0;
+            box-shadow: 0 0 0 0.2rem rgba(12, 97, 151, 0.25);
+        }
+        #emailAddressRow.show, #emailSubjectRow.show {
+            display: block !important;
+            animation: slideDown 0.3s ease-out;
+        }
+
+        @keyframes slideDown {
+            from {
+                opacity: 0;
+                transform: translateY(-10px);
+            }
+            to {
+                opacity: 1;
+                transform: translateY(0);
+            }
+        }
+
+        .invalid-feedback {
+            display: none;
+        }
+
+        .is-invalid ~ .invalid-feedback {
+            display: block;
+        }
+
+        .alert-info {
+            background-color: rgba(12, 97, 151, 0.1);
+            border-color: rgba(12, 97, 151, 0.2);
+            color: #0C6197;
+        }
+
+        .spinner-border-sm {
+            width: 1rem;
+            height: 1rem;
+        }
+
+        .toast {
+            min-width: 300px;
+        }
+
+        .toast-body {
+            font-weight: 500;
+        }
+
+        .btn--ui:disabled {
+            opacity: 0.7;
+            cursor: not-allowed;
+        }
     </style>
     <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>
@@ -364,18 +703,22 @@
 
 @push('scripts')
     <script>
+        function closeSelect2Dropdowns() {
+            $('.filterCausals').each(function() {
+                if ($(this).hasClass('select2-hidden-accessible')) {
+                    $(this).select2('close');
+                }
+            });
+            $('.filterMember').each(function() {
+                if ($(this).hasClass('select2-hidden-accessible')) {
+                    $(this).select2('close');
+                }
+            });
+        }
 
-        Livewire.on('load-table', () => {
-            load();
-        });
-
-
-        function load()
-        {
+        function load() {
             $(document).ready(function(){
-
                 $(document).on("keypress", $('.filterCausals'), function (e) {
-
                     setTimeout(() => {
                         $(".select2-results__option").each(function(){
                             var txt = $(this).html();
@@ -385,13 +728,24 @@
                     }, 100);
                 });
 
-                $('.filterCausals').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-                $('.filterCausals').on('change', function (e) {
+                if (!$('.filterCausals').hasClass('select2-hidden-accessible')) {
+                    $('.filterCausals').select2({
+                        "language": {"noResults": function(){return "Nessun risultato";}},
+                        "dropdownParent": $('body'),
+                        "width": "100%"
+                    });
+                }
+
+                $('.filterCausals').off('change.customHandler').on('change.customHandler', function (e) {
                     var data = $('.filterCausals').select2("val");
                     @this.set('filterCausals', data);
                 });
 
-                $('.filterCausals').on('select2:open', function (e) {
+                $('.filterCausals').off('select2:open.customHandler').on('select2:open.customHandler', function (e) {
+                    if ($('#causalsModal').hasClass('show')) {
+                        $('#causalsModal').modal('hide');
+                    }
+
                     setTimeout(() => {
                         $(".select2-results__option").each(function(){
                             var txt = $(this).html();
@@ -401,45 +755,30 @@
                     }, 100);
                 });
 
-                $('.filterMember').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-                $('.filterMember').on('change', function (e) {
+                if (!$('.filterMember').hasClass('select2-hidden-accessible')) {
+                    $('.filterMember').select2({
+                        "language": {"noResults": function(){return "Nessun risultato";}},
+                        "dropdownParent": $('body'),
+                        "width": "100%"
+                    });
+                }
+
+                $('.filterMember').off('change.customHandler').on('change.customHandler', function (e) {
                     var data = $('.filterMember').select2("val");
                     @this.set('filterMember', data);
                 });
 
+                $('.filterMember').off('select2:open.customHandler').on('select2:open.customHandler', function (e) {
+                    if ($('#causalsModal').hasClass('show')) {
+                        $('#causalsModal').modal('hide');
+                    }
+                });
             });
         }
 
-        Livewire.on('load-select', () => {
-            $('.filterCausals').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-            $('.filterCausals').on('change', function (e) {
-                var data = $('.filterCausals').select2("val");
-                @this.set('filterCausals', data);
-            });
-            $('.filterMember').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-            $('.filterMember').on('change', function (e) {
-                var data = $('.filterMember').select2("val");
-                @this.set('filterMember', data);
-            });
-            setMaxWidth();
-            setMaxHeight();
-        });
-
-        load();
-
-
-    </script>
-
-@endpush
-
-@push('scripts')
-    <script>
-
-        function printData()
-        {
-
-            var divToPrint=document.getElementById("tablesaw-350");
-            newWin= window.open("");
+        function printData() {
+            var divToPrint = document.getElementById("tablesaw-350");
+            newWin = window.open("");
             var htmlToPrint = '' +
                 '<style type="text/css">' +
                 'table th, table td {' +
@@ -454,102 +793,366 @@
             newWin.close();
         }
 
-        document.querySelector("#print").addEventListener("click", function(){
-        printData();
-        });
+        function scrollFunction() {
+            const element = document.getElementById('resume-table');
+            const mybuttonBottom = document.getElementById("btn-back-to-bottom");
+            const mybutton = document.getElementById("btn-back-to-top");
+
+            if (element.scrollTop > 20) {
+                mybutton.style.display = "block";
+                mybuttonBottom.style.display = "block";
+            } else {
+                mybutton.style.display = "none";
+                mybuttonBottom.style.display = "none";
+            }
+        }
 
-        $( document ).ready( function(){
+        function backToTop() {
+            $('#resume-table').scrollTop(0);
+        }
 
-            setMaxWidth();
-            setMaxHeight();
-            $( window ).bind( "resize", setMaxWidth ); //Remove this if it's not needed. It will react when window changes size.
-            $( window ).bind( "resize", setMaxHeight );
+        function backToBottom() {
+            $('#resume-table').scrollTop($('#resume-table')[0].scrollHeight);
+        }
 
-            $(".open-filter").click(function(){
-                setMaxWidth();
-                setMaxHeight();
+        $(document).ready(function() {
+            load();
+
+            document.querySelector("#print").addEventListener("click", function(){
+                printData();
             });
 
+            const element = document.getElementById('resume-table');
+            element.onscroll = scrollFunction;
+
+            const mybuttonBottom = document.getElementById("btn-back-to-bottom");
+            const mybutton = document.getElementById("btn-back-to-top");
+
+            mybutton.addEventListener("click", backToTop);
+            mybuttonBottom.addEventListener("click", backToBottom);
+
+            $(document).on('click', '.varie-link', function(e) {
+                e.preventDefault();
+                e.stopPropagation();
+
+                closeSelect2Dropdowns();
+
+                const causalsData = $(this).data('causals');
+
+                if (causalsData) {
+                    const causals = causalsData.split('|');
+
+                    $('#causalsList').empty();
+
+                    causals.forEach(function(causal) {
+                        if (causal.trim()) {
+                            $('#causalsList').append(
+                                '<li class="list-group-item">' +
+                                '<i class="fas fa-tags me-2" style="color: #0C6197;"></i>' +
+                                causal.trim() +
+                                '</li>'
+                            );
+                        }
+                    });
+
+                    $('#causalsModal').modal('show');
+
+                    $('#causalsModal').on('shown.bs.modal', function () {
+                        $(this).find('.btn-close').focus();
+                    });
+                }
+            });
+
+            $('#causalsModal').on('show.bs.modal', function () {
+                closeSelect2Dropdowns();
+                $('body').addClass('modal-open');
+            });
+
+            $('#causalsModal').on('hidden.bs.modal', function () {
+                $('body').removeClass('modal-open');
+            });
         });
 
-            function setMaxWidth() {
-                // $("#resume-table").width( Math.round( $(window ).width() - size ) ) ;
-            }
+        Livewire.on('load-table', () => {
+            load();
+        });
+
+        Livewire.on('load-select', () => {
+            load();
+        });
+
+        Livewire.on('filters-reset', () => {
+            $('.filterMember').val('').trigger('change');
+            $('.filterCausals').val('').trigger('change');
+            load();
+        });
+
+        Livewire.on('show-export-modal', () => {
+            $('#exportModal').modal('show');
+        });
+
+        Livewire.on('hide-export-modal', () => {
+            $('#exportModal').modal('hide');
+        });
 
-            function setMaxHeight() {
-                //  $("#resume-table").height( Math.round( $(window ).height() - 300 ) ) ;
+        function showToast(type, message, duration = 5000) {
+            const toastContainer = document.querySelector('.toast-container');
+            if (!toastContainer) {
+                console.error('Toast container not found');
+                return;
             }
 
-            let mybuttonBottom = document.getElementById("btn-back-to-bottom");
-            let mybutton = document.getElementById("btn-back-to-top");
+            const toastId = 'toast-' + Date.now();
 
-            // When the user scrolls down 20px from the top of the document, show the button
-            window.onscroll = function () {
-                scrollFunction();
+            const toastColors = {
+                success: 'bg-success',
+                error: 'bg-danger',
+                warning: 'bg-warning',
+                info: 'bg-info'
             };
 
-            const element = document.getElementById('resume-table');
-                element.onscroll = (e)=>{
-                    scrollFunction();
-                };
-                /*if (element.scrollTop < lastScrollTop){
-                    // upscroll
-                    return;
-                }
-                lastScrollTop = element.scrollTop <= 0 ? 0 : element.scrollTop;
-                    if (element.scrollTop + element.offsetHeight>= element.scrollHeight ){
-                    console.log("End");
+            const toastIcons = {
+                success: 'fa-check-circle',
+                error: 'fa-exclamation-circle',
+                warning: 'fa-exclamation-triangle',
+                info: 'fa-info-circle'
+            };
+
+            const toast = document.createElement('div');
+            toast.id = toastId;
+            toast.className = `toast align-items-center text-white ${toastColors[type]} border-0`;
+            toast.setAttribute('role', 'alert');
+            toast.innerHTML = `
+                <div class="d-flex">
+                    <div class="toast-body">
+                        <i class="fas ${toastIcons[type]} me-2"></i>
+                        ${message}
+                    </div>
+                    <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
+                </div>
+            `;
+
+            toastContainer.appendChild(toast);
+
+            if (typeof bootstrap !== 'undefined') {
+                const bsToast = new bootstrap.Toast(toast, { delay: duration });
+                bsToast.show();
+
+                toast.addEventListener('hidden.bs.toast', function() {
+                    if (toastContainer.contains(toast)) {
+                        toastContainer.removeChild(toast);
                     }
-                }*/
-
-                function scrollFunction() {
-                if (
-                    element.scrollTop > 20
-                ) {
-                    mybutton.style.display = "block";
-                    mybuttonBottom.style.display = "block";
+                });
+
+                return bsToast;
+            } else {
+                toast.style.display = 'block';
+                setTimeout(() => {
+                    if (toastContainer.contains(toast)) {
+                        toastContainer.removeChild(toast);
+                    }
+                }, duration);
+            }
+        }
+
+        document.addEventListener('DOMContentLoaded', function() {
+            const sendViaEmailCheckbox = document.getElementById('sendViaEmail');
+            const emailAddressRow = document.getElementById('emailAddressRow');
+            const emailSubjectRow = document.getElementById('emailSubjectRow');
+            const emailProcessingInfo = document.getElementById('emailProcessingInfo');
+            const exportIcon = document.getElementById('exportIcon');
+            const exportButtonText = document.getElementById('exportButtonText');
+            const emailInput = document.getElementById('exportEmailAddress');
+
+            function toggleEmailFields() {
+                if (sendViaEmailCheckbox && sendViaEmailCheckbox.checked) {
+                    if (emailAddressRow) {
+                        emailAddressRow.style.display = 'block';
+                        emailAddressRow.classList.add('show');
+                    }
+                    if (emailSubjectRow) {
+                        emailSubjectRow.style.display = 'block';
+                        emailSubjectRow.classList.add('show');
+                    }
+                    if (emailProcessingInfo) {
+                        emailProcessingInfo.style.display = 'list-item';
+                    }
+
+                    if (exportIcon) exportIcon.className = 'fas fa-paper-plane me-1';
+                    if (exportButtonText) exportButtonText.textContent = 'INVIA EMAIL';
                 } else {
-                    mybutton.style.display = "none";
-                    mybuttonBottom.style.display = "none";
+                    if (emailAddressRow) {
+                        emailAddressRow.style.display = 'none';
+                        emailAddressRow.classList.remove('show');
+                    }
+                    if (emailSubjectRow) {
+                        emailSubjectRow.style.display = 'none';
+                        emailSubjectRow.classList.remove('show');
+                    }
+                    if (emailProcessingInfo) {
+                        emailProcessingInfo.style.display = 'none';
+                    }
+
+                    if (exportIcon) exportIcon.className = 'fas fa-download me-1';
+                    if (exportButtonText) exportButtonText.textContent = 'ESPORTA';
                 }
             }
 
-            function scrollFunctionOld() {
-            if (
-                document.body.scrollTop > 20 ||
-                document.documentElement.scrollTop > 20
-            ) {
-                mybutton.style.display = "block";
-                mybuttonBottom.style.display = "block";
-            } else {
-                mybutton.style.display = "none";
-                mybuttonBottom.style.display = "none";
+            function validateEmail(email) {
+                const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+                return emailRegex.test(email);
             }
+
+            if (sendViaEmailCheckbox) {
+                sendViaEmailCheckbox.addEventListener('change', toggleEmailFields);
             }
-            // When the user clicks on the button, scroll to the top of the document
-            mybutton.addEventListener("click", backToTop);
-            mybuttonBottom.addEventListener("click", backToBottom);
 
-            function backToTop() {
-                $('#resume-table').scrollTop(0);
-                /*document.body.scrollTop = 0;
-                document.documentElement.scrollTop = 0;*/
+            if (emailInput) {
+                emailInput.addEventListener('blur', function() {
+                    if (sendViaEmailCheckbox && sendViaEmailCheckbox.checked && this.value) {
+                        if (validateEmail(this.value)) {
+                            this.classList.remove('is-invalid');
+                            this.classList.add('is-valid');
+                        } else {
+                            this.classList.remove('is-valid');
+                            this.classList.add('is-invalid');
+                        }
+                    }
+                });
+
+                emailInput.addEventListener('input', function() {
+                    this.classList.remove('is-invalid', 'is-valid');
+                });
             }
-            function backToBottom() {
-                $('#resume-table').scrollTop($('#resume-table')[0].scrollHeight);
-                //window.scrollTo(0, document.body.scrollHeight);
+
+            if (typeof $ !== 'undefined') {
+                $('#exportModal').on('shown.bs.modal', function() {
+                    toggleEmailFields();
+                });
             }
+        });
+
+        document.addEventListener('livewire:load', function () {
+            console.log('Livewire loaded, setting up export event listeners');
 
-            function reset()
-            {
-                $('.filterMember').val('');
-                $('.filterMember').trigger('change');
-                $('.filterCausals').val('');
-                $('.filterCausals').trigger('change');
-                var today = new Date().toISOString().split('T')[0];
+            Livewire.on('export-email-queued', function() {
+                console.log('Export email queued event received');
+                showToast('info',
+                    '<strong>Export avviato!</strong><br>' +
+                    'L\'elaborazione è in corso in background. Riceverai l\'email a breve.',
+                    8000
+                );
+            });
+
+            Livewire.on('export-email-sent', function() {
+                console.log('Export email sent event received');
+                showToast('success',
+                    '<strong>Email inviata!</strong><br>' +
+                    'L\'export è stato completato e inviato alla tua email.',
+                    6000
+                );
+            });
 
-                window.livewire.find(document.querySelector('[wire\\:id]').getAttribute('wire:id')).set('fromDate', today);
-                window.livewire.find(document.querySelector('[wire\\:id]').getAttribute('wire:id')).set('toDate', today);
+            Livewire.on('export-email-error', function(message) {
+                console.log('Export email error event received:', message);
+                showToast('error',
+                    '<strong>Errore nell\'export:</strong><br>' +
+                    (message || 'Si è verificato un errore durante l\'elaborazione.'),
+                    10000
+                );
+            });
+
+            Livewire.on('show-export-modal', function() {
+                console.log('Show export modal event received');
+                if (typeof $ !== 'undefined') {
+                    $('#exportModal').modal('show');
+                }
+            });
+
+            Livewire.on('hide-export-modal', function() {
+                console.log('Hide export modal event received');
+                if (typeof $ !== 'undefined') {
+                    $('#exportModal').modal('hide');
+                }
+            });
+        });
+
+        if (typeof Livewire !== 'undefined') {
+            document.addEventListener('livewire:initialized', function () {
+                console.log('Livewire initialized, setting up export event listeners');
+
+                Livewire.on('export-email-queued', function() {
+                    showToast('info',
+                        '<strong>Export avviato!</strong><br>' +
+                        'L\'elaborazione è in corso in background. Riceverai l\'email a breve.',
+                        8000
+                    );
+                });
+
+                Livewire.on('export-email-sent', function() {
+                    showToast('success',
+                        '<strong>Email inviata!</strong><br>' +
+                        'L\'export è stato completato e inviato alla tua email.',
+                        6000
+                    );
+                });
+
+                Livewire.on('export-email-error', function(message) {
+                    showToast('error',
+                        '<strong>Errore nell\'export:</strong><br>' +
+                        (message || 'Si è verificato un errore durante l\'elaborazione.'),
+                        10000
+                    );
+                });
+
+                Livewire.on('show-export-modal', function() {
+                    if (typeof $ !== 'undefined') {
+                        $('#exportModal').modal('show');
+                    }
+                });
+
+                Livewire.on('hide-export-modal', function() {
+                    if (typeof $ !== 'undefined') {
+                        $('#exportModal').modal('hide');
+                    }
+                });
+            });
+        }
+
+        window.addEventListener('load', function() {
+            if (typeof showToast === 'function') {
+                console.log('showToast function is available globally');
+            } else {
+                console.error('showToast function is not available globally');
             }
+        });
+
+
+        function handleExportClick() {
+            showExportLoading();
+
+            @this.call('exportWithDateRange');
+        }
+
+        function showExportLoading() {
+            document.getElementById('normalState').style.display = 'none';
+            document.getElementById('loadingState').style.display = 'inline-flex';
+            document.getElementById('exportButton').disabled = true;
+        }
+
+        function hideExportLoading() {
+            document.getElementById('normalState').style.display = 'inline-flex';
+            document.getElementById('loadingState').style.display = 'none';
+            document.getElementById('exportButton').disabled = false;
+        }
+
+        // Listen for when export is complete
+        Livewire.on('export-complete', function() {
+            hideExportLoading();
+        });
+
+        Livewire.on('hide-export-modal', function() {
+            hideExportLoading();
+        });
     </script>
 @endpush

Разница между файлами не показана из-за своего большого размера
+ 905 - 132
resources/views/livewire/records_out.blade.php


+ 200 - 57
resources/views/livewire/reports.blade.php

@@ -59,8 +59,13 @@
                                 id="causals-season-title">{{ $seasonFilter }}</span></h3>
                     </div>
                     <div class="chart-body">
-                        <div class="chart-container">
-                            <canvas id="causals-chart-{{ str_replace('-', '', $seasonFilter) }}"></canvas>
+                        <div style="display: grid; grid-template-columns: 1fr 700px; gap: 1rem; align-items: start;">
+                            <div class="causals-table-container" id="causals-table">
+                            </div>
+
+                            <div class="chart-container">
+                                <canvas id="causals-chart-{{ str_replace('-', '', $seasonFilter) }}"></canvas>
+                            </div>
                         </div>
                     </div>
                 </div>
@@ -72,7 +77,7 @@
                         <h3 class="chart-title">Tesserati per Stagione</h3>
                     </div>
                     <div class="chart-body">
-                        <div style="display: grid; grid-template-columns: 1fr 300px; gap: 1rem; align-items: start;">
+                        <div style="display: grid; grid-template-columns: 1fr 500px; gap: 1rem; align-items: start;">
                             <div class="chart-container">
                                 <canvas id="members-chart-{{ str_replace('-', '', $seasonFilter) }}"></canvas>
                             </div>
@@ -209,12 +214,12 @@
                 const ctx = canvas.getContext('2d');
 
                 const incomeGradient = ctx.createLinearGradient(0, 0, 0, 400);
-                incomeGradient.addColorStop(0, 'rgba(0, 184, 148, 0.8)');
-                incomeGradient.addColorStop(1, 'rgba(0, 184, 148, 0.2)');
+                incomeGradient.addColorStop(0, 'rgba(0, 184, 148, 1)');
+                incomeGradient.addColorStop(1, 'rgba(0, 184, 148, 1)');
 
                 const expenseGradient = ctx.createLinearGradient(0, 0, 0, 400);
-                expenseGradient.addColorStop(0, 'rgba(255, 107, 107, 0.8)');
-                expenseGradient.addColorStop(1, 'rgba(255, 107, 107, 0.2)');
+                expenseGradient.addColorStop(0, 'rgba(255, 107, 107, 1)');
+                expenseGradient.addColorStop(1, 'rgba(255, 107, 107, 1)');
 
                 this.charts[chartId] = new Chart(ctx, {
                     type: 'bar',
@@ -227,6 +232,8 @@
                                 backgroundColor: incomeGradient,
                                 borderColor: '#00b894',
                                 borderWidth: 2,
+                                borderRadius: 12,
+                                borderSkipped: false,
                             },
                             {
                                 label: 'Uscite',
@@ -234,6 +241,8 @@
                                 backgroundColor: expenseGradient,
                                 borderColor: '#ff6b6b',
                                 borderWidth: 2,
+                                borderRadius: 12,
+                                borderSkipped: false,
                             }
                         ]
                     },
@@ -250,11 +259,11 @@
                                 }
                             },
                             tooltip: {
-                                backgroundColor: 'rgba(255, 255, 255, 0.95)',
+                                backgroundColor: 'rgba(255, 255, 255,1)',
                                 titleColor: '#212529',
                                 bodyColor: '#495057',
                                 borderColor: '#e9ecef',
-                                borderWidth: 1,
+                                borderWidth: 12,
                                 cornerRadius: 8,
                                 callbacks: {
                                     label: function (context) {
@@ -279,6 +288,16 @@
                                 }
                             }
                         },
+                        elements: {
+                            bar: {
+                                borderRadius: {
+                                    topLeft: 12,
+                                    topRight: 12,
+                                    bottomLeft: 0,
+                                    bottomRight: 0
+                                }
+                            }
+                        },
                         animation: {
                             duration: 1000,
                             easing: 'easeOutQuart'
@@ -286,8 +305,6 @@
                     }
                 });
             },
-
-            // Replace your createCausalsChart method with this fixed version:
             createCausalsChart: function (seasonKey, causalsData) {
                 const chartId = `causals-chart-${seasonKey}`;
                 const canvas = document.getElementById(chartId);
@@ -329,15 +346,18 @@
                     options: {
                         responsive: true,
                         maintainAspectRatio: false,
-                        cutout: '60%',
+                        cutout: '30%',
+                        layout: {
+                            padding: {
+                                top: 10,
+                                right: 10,
+                                bottom: 10,
+                                left: 10
+                            }
+                        },
                         plugins: {
                             legend: {
-                                position: 'left',
-                                labels: {
-                                    usePointStyle: true,
-                                    padding: 15,
-                                    font: { size: 11, weight: '500' }
-                                }
+                                display: false
                             },
                             tooltip: {
                                 backgroundColor: 'rgba(255, 255, 255, 0.95)',
@@ -346,19 +366,32 @@
                                 borderColor: '#e9ecef',
                                 borderWidth: 1,
                                 cornerRadius: 8,
+                                titleFont: {
+                                    size: 13,
+                                    weight: 'bold'
+                                },
+                                bodyFont: {
+                                    size: 12,
+                                    weight: '500'
+                                },
+                                padding: 12,
                                 callbacks: {
+                                    title: function (context) {
+                                        return context[0].label;
+                                    },
                                     label: function (context) {
                                         const value = context.raw;
                                         const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
-                                        return context.label + ': €' +
-                                            new Intl.NumberFormat('it-IT', {
+                                        return [
+                                            `Importo: €${new Intl.NumberFormat('it-IT', {
                                                 minimumFractionDigits: 2,
                                                 maximumFractionDigits: 2
-                                            }).format(value) +
-                                            ` (${percentage}%)`;
+                                            }).format(value)}`,
+                                            `Percentuale: ${percentage}%`
+                                        ];
                                     }
                                 }
-                            }
+                            },
                         },
                         animation: {
                             animateRotate: true,
@@ -366,8 +399,51 @@
                         }
                     }
                 });
+
+                this.updateCausalsTable(causalsData, dataValues, total);
             },
 
+            updateCausalsTable: function (causalsData, dataValues, total) {
+                const container = document.getElementById('causals-table');
+                if (!container) return;
+
+                const colors = [
+                    '#3b5bdb', '#00b894', '#22b8cf', '#ffd43b', '#ff6b6b',
+                    '#8e44ad', '#e67e22', '#95a5a6', '#f1c40f', '#e74c3c'
+                ];
+
+                let tableHtml = `
+        <div class="causals-table compact">
+            <div class="table-header">
+                <div class="table-cell causale">Causale</div>
+                <div class="table-cell euro">Importo</div>
+                <div class="table-cell percent">%</div>
+            </div>
+    `;
+
+                causalsData.inLabels.forEach((label, index) => {
+                    const value = dataValues[index] || 0;
+                    const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
+                    const color = colors[index % colors.length];
+
+                    tableHtml += `
+            <div class="table-row">
+                <div class="table-cell causale">
+                    <span class="causale-indicator" style="background-color: ${color}"></span>
+                    ${label}
+                </div>
+                <div class="table-cell euro">€${new Intl.NumberFormat('it-IT', {
+                        minimumFractionDigits: 2,
+                        maximumFractionDigits: 2
+                    }).format(value)}</div>
+                <div class="table-cell percent">${percentage}%</div>
+            </div>
+        `;
+                });
+
+                tableHtml += '</div>';
+                container.innerHTML = tableHtml;
+            },
             createMembersChart: function (seasonKey, membersData) {
                 const chartId = `members-chart-${seasonKey}`;
                 const canvas = document.getElementById(chartId);
@@ -381,30 +457,61 @@
                 gradient.addColorStop(0, 'rgba(59, 91, 219, 0.3)');
                 gradient.addColorStop(1, 'rgba(59, 91, 219, 0.05)');
 
-                this.charts[chartId] = new Chart(ctx, {
-                    type: 'line',
-                    data: {
-                        labels: membersData.labels,
-                        datasets: [{
-                            label: 'Membri Tesserati',
-                            data: membersData.datasets[0].data,
-                            borderColor: '#3b5bdb',
+                const processedDatasets = membersData.datasets.map((dataset, index) => {
+                    if (dataset.label === 'Totale Membri Tesserati') {
+                        return {
+                            ...dataset,
                             backgroundColor: gradient,
+                            borderColor: '#3b5bdb',
                             borderWidth: 3,
-                            fill: true,
-                            tension: 0.4,
                             pointBackgroundColor: '#3b5bdb',
                             pointBorderColor: '#ffffff',
                             pointBorderWidth: 2,
                             pointRadius: 6,
-                            pointHoverRadius: 8
-                        }]
+                            pointHoverRadius: 8,
+                            type: 'line',
+                            order: 1,
+                            fill: true
+                        };
+                    } else {
+                        return {
+                            ...dataset,
+                            borderWidth: 2,
+                            pointRadius: 4,
+                            pointHoverRadius: 6,
+                            pointBorderColor: '#ffffff',
+                            pointBorderWidth: 1,
+                            type: 'line',
+                            order: 2,
+                            fill: false,
+                            backgroundColor: 'transparent'
+                        };
+                    }
+                });
+
+                this.charts[chartId] = new Chart(ctx, {
+                    type: 'line',
+                    data: {
+                        labels: membersData.labels,
+                        datasets: processedDatasets
                     },
                     options: {
                         responsive: true,
                         maintainAspectRatio: false,
+                        interaction: {
+                            mode: 'index',
+                            intersect: false,
+                        },
                         plugins: {
-                            legend: { display: false },
+                            legend: {
+                                display: true,
+                                position: 'top',
+                                labels: {
+                                    usePointStyle: true,
+                                    padding: 15,
+                                    font: { weight: '500', size: 12 }
+                                }
+                            },
                             tooltip: {
                                 backgroundColor: 'rgba(255, 255, 255, 0.95)',
                                 titleColor: '#212529',
@@ -413,18 +520,31 @@
                                 borderWidth: 1,
                                 cornerRadius: 8,
                                 callbacks: {
+                                    title: function (context) {
+                                        return 'Stagione: ' + context[0].label;
+                                    },
                                     label: function (context) {
-                                        return 'Tesserati: ' + context.parsed.y;
+                                        return context.dataset.label + ': ' + context.parsed.y;
                                     }
                                 }
                             }
                         },
                         scales: {
-                            x: { grid: { display: false } },
+                            x: {
+                                grid: { display: false },
+                                ticks: {
+                                    font: { weight: '500' }
+                                }
+                            },
                             y: {
                                 beginAtZero: true,
                                 grid: { color: 'rgba(0, 0, 0, 0.05)' },
-                                ticks: { precision: 0 }
+                                ticks: {
+                                    precision: 0,
+                                    callback: function (value) {
+                                        return Math.floor(value); // Ensure integer values
+                                    }
+                                }
                             }
                         },
                         animation: {
@@ -434,7 +554,6 @@
                     }
                 });
             },
-
             updateMonthlyTable: function (monthlyData) {
                 const container = document.getElementById('monthly-table');
                 if (!container) return;
@@ -478,14 +597,18 @@
                 if (!container) return;
 
                 const seasonLabels = membersData.labels;
-                const memberCounts = membersData.datasets[0].data;
+                const totalDataset = membersData.datasets.find(d => d.label === 'Totale Membri Tesserati');
+                const cardTypeDatasets = membersData.datasets.filter(d => d.label !== 'Totale Membri Tesserati');
+
+                const memberCounts = totalDataset ? totalDataset.data : [];
 
                 let tableHtml = `
                     <div class="members-table">
                         <div class="table-header">
                             <div class="table-cell">Stagione</div>
-                            <div class="table-cell">Tesserati</div>
+                            <div class="table-cell">Totale</div>
                             <div class="table-cell">Variazione</div>
+                            <div class="table-cell">Tipologie</div>
                         </div>
                 `;
 
@@ -507,13 +630,38 @@
                         }
                     }
 
+                    // Build card type breakdown
+                    let cardTypeBreakdown = '';
+                    cardTypeDatasets.forEach((dataset, datasetIndex) => {
+                        const count = dataset.data[index] || 0;
+                        if (count > 0) {
+                            const color = dataset.borderColor || '#6b7280';
+                            cardTypeBreakdown += `
+                    <div class="card-type-item">
+                        <span class="card-type-indicator" style="background-color: ${color}"></span>
+                        <span class="card-type-name">${dataset.label}</span>
+                        <span class="card-type-count">${count}</span>
+                    </div>
+                `;
+                        }
+                    });
+
+                    if (!cardTypeBreakdown) {
+                        cardTypeBreakdown = '<div class="no-card-types">Nessun dettaglio</div>';
+                    }
+
                     tableHtml += `
-                        <div class="table-row ${rowClass}">
-                            <div class="table-cell season-name">${season}</div>
-                            <div class="table-cell members-count">${new Intl.NumberFormat('it-IT').format(current)}</div>
-                            <div class="table-cell variation">${variationText}</div>
-                        </div>
-                    `;
+            <div class="table-row ${rowClass}">
+                <div class="table-cell season-name">${season}</div>
+                <div class="table-cell members-count">${new Intl.NumberFormat('it-IT').format(current)}</div>
+                <div class="table-cell variation">${variationText}</div>
+                <div class="table-cell card-types">
+                    <div class="card-types-container">
+                        ${cardTypeBreakdown}
+                    </div>
+                </div>
+            </div>
+        `;
                 });
 
                 tableHtml += '</div>';
@@ -527,7 +675,6 @@
                 const seasonKey = '{{ str_replace('-', '', $seasonFilter) }}';
                 console.log('Selected course:', selectedCourse, 'for season:', seasonFilter);
 
-                // Add this check at the beginning
                 if (!selectedCourse || selectedCourse.trim() === '') {
                     console.log('No course selected, skipping chart creation');
                     return;
@@ -572,7 +719,7 @@
                             y: {
                                 beginAtZero: true,
                                 grid: {
-                                    color: 'rgba(0, 0, 0, 0.1)',
+                                    color: 'rgba(0, 0, 0, 1)',
                                     borderDash: [5, 5]
                                 },
                                 ticks: {
@@ -593,7 +740,7 @@
                                 }
                             },
                             tooltip: {
-                                backgroundColor: 'rgba(255, 255, 255, 0.95)',
+                                backgroundColor: 'rgba(255, 255, 255, 1)',
                                 titleColor: '#212529',
                                 bodyColor: '#495057',
                                 borderColor: '#e9ecef',
@@ -699,14 +846,12 @@
                     const ctx = canvasElement.getContext('2d');
 
                     const earnedGradient = ctx.createLinearGradient(0, 0, 0, 400);
-                    earnedGradient.addColorStop(0, 'rgba(16, 185, 129, 0.9)');
-                    earnedGradient.addColorStop(1, 'rgba(16, 185, 129, 0.3)');
+                    earnedGradient.addColorStop(0, 'rgba(16, 185, 129, 1)');
+                    earnedGradient.addColorStop(1, 'rgba(16, 185, 129, 1)');
 
                     const totalData = courseData.datasets.find(d => d.label === 'Pagamenti Attesi')?.data || [];
                     const earnedData = courseData.datasets.find(d => d.label === 'Pagamenti Effettuati')?.data || [];
 
-                    // REMOVED: verticalMissingLinesPlugin completely
-
                     this.charts[chartId] = new Chart(ctx, {
                         type: 'bar',
                         data: {
@@ -804,7 +949,6 @@
                                         usePointStyle: true,
                                         padding: 15,
                                         font: { weight: '500', size: 12 },
-                                        // REMOVED: custom generateLabels function that added red dashed line legend
                                     }
                                 },
                                 tooltip: {
@@ -904,7 +1048,6 @@
                     const total = parseFloat(row.total) || 0;
                     const delta = Math.max(0, total - earned);
 
-                    // Fix percentage calculation and display logic
                     let percentage = 0;
                     let percentageDisplay = '—';
                     let percentageClass = 'neutral';

+ 120 - 46
resources/views/receipt.blade.php

@@ -1,34 +1,93 @@
 <!DOCTYPE html>
 <html>
 <head>
-  <title></title>
-  <style>
-    body {
-        font-family: Helvetica, Arial, sans-serif;
-        font-size:14px;
-    }
-    footer {
-        position: fixed;
-        bottom: -20px;
-        left: 0px;
-        right: 0px;
-        height: 50px;
-
-    }
+    <title></title>
+    <style>
+        body {
+            font-family: Helvetica, Arial, sans-serif;
+            font-size:14px;
+        }
+        footer {
+            position: fixed;
+            bottom: -20px;
+            left: 0px;
+            right: 0px;
+            height: 50px;
+        }
+        .logo {
+            max-width: 200px;
+            max-height: 100px;
+            object-fit: contain;
+        }
     </style>
 </head>
 <body>
-    <img src="{{public_path() . env('LOGO')}}" width="200">
+    @php
+        $azienda = App\Models\Azienda::first();
+        $logoService = app(App\Services\LogoUploadServices::class);
+        $logoUrl = $azienda ? $logoService->getLogoUrl($azienda) : null;
+    @endphp
+
+    {{-- Display logo from database/Wasabi instead of env --}}
+    @if($logoUrl)
+        <img src="{{ $logoUrl }}" class="logo" alt="Logo">
+    @elseif($azienda && $azienda->logo)
+        {{-- Fallback if URL generation fails --}}
+        <div style="width: 200px; height: 100px; border: 1px dashed #ccc; display: flex; align-items: center; justify-content: center;">
+            <small>Logo non disponibile</small>
+        </div>
+    @endif
+
     <br><br><br>
     <div align="right"><b>RICEVUTA DI PAGAMENTO N. {{$receipt->number . "/" . $receipt->year}} del {{date("d/m/Y", strtotime($receipt->created_at))}}</b></div><br><br>
+
     @if($receipt->status == 99)
         <div align="right"><b style="color:red">ANNULLATA</b></div><br><br>
     @endif
+
     <br>
-    {{env('RAGIONE_SOCIALE', '')}}<br><br>
-    <b>Indirizzo</b>: {{env('INDIRIZZO', '')}} {{env('LOCALITA', '')}} ({{env('PROVINCIA', '')}})<br><br>
-    <b>C.F.</b>: {{env('CODICE_FISCALE', '')}}<br><br>
-    <b>P.IVA</b>: {{env('PARTITA_IVA', '')}}<br><br>
+
+    @if($azienda)
+        @if($azienda->ragione_sociale)
+            {{$azienda->ragione_sociale}}
+        @elseif($azienda->nome_associazione)
+            {{$azienda->nome_associazione}}
+        @endif
+        <br><br>
+
+        <b>Indirizzo</b>:
+        @php
+            $addressParts = array_filter([
+                $azienda->sede_legale_indirizzo,
+                $azienda->sede_legale_cap,
+                $azienda->sede_legale_comune,
+                $azienda->sede_legale_provincia ? '(' . $azienda->sede_legale_provincia . ')' : null
+            ]);
+        @endphp
+        {{ implode(' ', $addressParts) }}
+        <br><br>
+
+        @if($azienda->codice_fiscale)
+            <b>C.F.</b>: {{$azienda->codice_fiscale}}<br><br>
+        @endif
+
+        @if($azienda->partita_iva)
+            <b>P.IVA</b>: {{$azienda->partita_iva}}<br><br>
+        @endif
+
+        @if($azienda->email)
+            <b>Email</b>: {{$azienda->email}}<br><br>
+        @endif
+
+        @if($azienda->pec)
+            <b>PEC</b>: {{$azienda->pec}}<br><br>
+        @endif
+    @else
+        <div style="color: red; font-weight: bold;">
+            ATTENZIONE: Configurare i dati aziendali nel sistema
+        </div><br><br>
+    @endif
+
     <hr><br>
 
     <b>Intestata a</b><br><br>
@@ -72,44 +131,59 @@
     @endif
     <hr><br>
 
-   @php
+    @php
     $total = 0;
     $totalSconto = 0;
     $totalPrediscount = 0;
     $hasDiscount = false;
-@endphp
+    @endphp
 
-@foreach($receipt->rows as $row)
-    <b>Causale</b>: {{@$row->causal->getTree()}}<br><br>
-    <b>Dettaglio causale</b>: {{$row->note != '' ? $row->note : ''}}<br><br>
+    @foreach($receipt->rows as $row)
+        <b>Causale</b>: {{@$row->causal->getTree()}}<br><br>
+        @if ($row->note != '')
+            <b>Dettaglio causale</b>: {{$row->note}}<br><br>
+        @endif
 
-    @if($row->sconto > 0)
-        @php $hasDiscount = true; @endphp
-        <b>Importo</b>: {{formatPrice($row->prediscount_amount)}}<br><br>
-        <b>Sconto</b>: {{formatPrice($row->sconto)}}<br><br>
-    @endif
+        @if($row->sconto > 0)
+            @php $hasDiscount = true; @endphp
+            <b>Importo</b>: {{formatPrice($row->prediscount_amount)}}<br><br>
+            <b>Sconto</b>: {{formatPrice($row->sconto)}}<br><br>
+        @endif
 
-    <b>{{$row->sconto > 0 ? 'Importo finale' : 'Importo'}}</b>: {{formatPrice($row->amount)}}<br><br>
-    <hr><br>
-    @php
-        $totalSconto += $row->sconto;
-        $totalPrediscount += $row->prediscount_amount;
-        $total += $row->amount;
-    @endphp
-@endforeach
+        <b>{{$row->sconto > 0 ? 'Importo finale' : 'Importo'}}</b>: {{formatPrice($row->amount)}}<br><br>
+        <hr><br>
+        @php
+            $totalSconto += $row->sconto;
+            $totalPrediscount += $row->prediscount_amount;
+            $total += $row->amount;
+        @endphp
+    @endforeach
 
-<br><br>
+    <br><br>
 
-@if($hasDiscount)
-    <b>Totale prescontato</b>: {{formatPrice($totalPrediscount)}}<br><br>
-    <b>Sconto totale</b>: {{formatPrice($totalSconto)}}<br><br>
-@endif
+    @if($hasDiscount)
+        <b>Totale</b>: {{formatPrice($totalPrediscount)}}<br><br>
+        <b>Sconto totale</b>: {{formatPrice($totalSconto)}}<br><br>
+    @endif
 
-<b>Totale</b>: {{formatPrice($total)}}<br><br>
+    <b>Totale</b>: {{formatPrice($total)}}<br><br>
 
-<footer>
-    <small>{{env('LOCALITA', '')}} ({{env('PROVINCIA', '')}}) li {{date("d/m/Y", strtotime($receipt->created_at))}}</small>
-</footer>
+    <footer>
+        <small>
+            @if($azienda)
+                @php
+                    $locationParts = array_filter([
+                        $azienda->sede_legale_comune,
+                        $azienda->sede_legale_provincia ? '(' . $azienda->sede_legale_provincia . ')' : null
+                    ]);
+                    $location = implode(' ', $locationParts);
+                @endphp
+                {{ $location ?: 'Sede legale' }} li {{date("d/m/Y", strtotime($receipt->created_at))}}
+            @else
+                {{date("d/m/Y", strtotime($receipt->created_at))}}
+            @endif
+        </small>
+    </footer>
 
 </body>
 </html>

Некоторые файлы не были показаны из-за большого количества измененных файлов