Luca Parisio 2 månader sedan
förälder
incheckning
dec01c9342
100 ändrade filer med 2608 tillägg och 276 borttagningar
  1. 28 0
      app/Console/Commands/DispatchDueEmails.php
  2. 4 1
      app/Console/Kernel.php
  3. 29 0
      app/Http/Controllers/FileUpload.php
  4. 69 0
      app/Http/Controllers/ReceiptExportController.php
  5. 5 1
      app/Http/Livewire/Causal.php
  6. 9 1
      app/Http/Livewire/Causals.php
  7. 18 9
      app/Http/Livewire/Course.php
  8. 2 2
      app/Http/Livewire/CourseList.php
  9. 2 2
      app/Http/Livewire/CourseMember.php
  10. 20 4
      app/Http/Livewire/Dashboard.php
  11. 353 130
      app/Http/Livewire/EmailComunications.php
  12. 74 15
      app/Http/Livewire/Member.php
  13. 26 5
      app/Http/Livewire/Profile.php
  14. 40 19
      app/Http/Livewire/Rate.php
  15. 75 9
      app/Http/Livewire/RecordIN.php
  16. 142 51
      app/Http/Livewire/RecordINOUT.php
  17. 49 20
      app/Http/Livewire/Reports.php
  18. 7 0
      app/Http/Livewire/Supplier.php
  19. 13 4
      app/Http/Livewire/User.php
  20. 74 0
      app/Jobs/SendEmailMessage.php
  21. 58 0
      app/Mail/GenericMail.php
  22. 42 0
      app/Models/Azienda.php
  23. 2 1
      app/Models/Causal.php
  24. 73 0
      app/Models/EmailMessage.php
  25. 38 0
      app/Models/EmailMessageAttachment.php
  26. 28 0
      app/Models/EmailMessageRecipient.php
  27. 60 0
      app/Models/EmailRecipients.php
  28. 1 1
      app/Models/EmailScheduled.php
  29. 6 1
      app/Models/EmailTemplate.php
  30. 28 0
      app/Models/EmailTemplateAttachment.php
  31. 38 0
      database/migrations/2025_09_23_195123_create_email_attachments_table.php
  32. 33 0
      database/migrations/2025_09_24_131918_add_no_reports_to_causals_table.php
  33. 47 0
      database/migrations/2025_10_01_144330_rename_user_id_to_member_id_in_email_scheduled_recipients_table.php
  34. 41 0
      database/migrations/2025_10_02_071721_create_email_recipients_table.php
  35. 53 0
      database/migrations/2025_10_18_135607_create_email_messages.php
  36. BIN
      public/apple-touch-icon.png
  37. 333 0
      public/assets/js/datatable_export_action.js
  38. 55 0
      public/assets/libraries/ckeditor5/LICENSE-ckeditor5.md
  39. 4 0
      public/assets/libraries/ckeditor5/ckeditor5-content.css
  40. 4 0
      public/assets/libraries/ckeditor5/ckeditor5-editor.css
  41. 4 0
      public/assets/libraries/ckeditor5/ckeditor5.css
  42. 0 0
      public/assets/libraries/ckeditor5/ckeditor5.css.map
  43. 4 0
      public/assets/libraries/ckeditor5/ckeditor5.js
  44. 0 0
      public/assets/libraries/ckeditor5/ckeditor5.js.map
  45. 10 0
      public/assets/libraries/ckeditor5/ckeditor5.umd.js
  46. 0 0
      public/assets/libraries/ckeditor5/ckeditor5.umd.js.map
  47. 289 0
      public/assets/libraries/ckeditor5/config.js
  48. 8 0
      public/assets/libraries/ckeditor5/translations/af.d.ts
  49. 4 0
      public/assets/libraries/ckeditor5/translations/af.js
  50. 6 0
      public/assets/libraries/ckeditor5/translations/af.umd.js
  51. 8 0
      public/assets/libraries/ckeditor5/translations/ar.d.ts
  52. 4 0
      public/assets/libraries/ckeditor5/translations/ar.js
  53. 6 0
      public/assets/libraries/ckeditor5/translations/ar.umd.js
  54. 8 0
      public/assets/libraries/ckeditor5/translations/ast.d.ts
  55. 4 0
      public/assets/libraries/ckeditor5/translations/ast.js
  56. 6 0
      public/assets/libraries/ckeditor5/translations/ast.umd.js
  57. 8 0
      public/assets/libraries/ckeditor5/translations/az.d.ts
  58. 4 0
      public/assets/libraries/ckeditor5/translations/az.js
  59. 6 0
      public/assets/libraries/ckeditor5/translations/az.umd.js
  60. 8 0
      public/assets/libraries/ckeditor5/translations/be.d.ts
  61. 4 0
      public/assets/libraries/ckeditor5/translations/be.js
  62. 6 0
      public/assets/libraries/ckeditor5/translations/be.umd.js
  63. 8 0
      public/assets/libraries/ckeditor5/translations/bg.d.ts
  64. 4 0
      public/assets/libraries/ckeditor5/translations/bg.js
  65. 6 0
      public/assets/libraries/ckeditor5/translations/bg.umd.js
  66. 8 0
      public/assets/libraries/ckeditor5/translations/bn.d.ts
  67. 4 0
      public/assets/libraries/ckeditor5/translations/bn.js
  68. 6 0
      public/assets/libraries/ckeditor5/translations/bn.umd.js
  69. 8 0
      public/assets/libraries/ckeditor5/translations/bs.d.ts
  70. 4 0
      public/assets/libraries/ckeditor5/translations/bs.js
  71. 6 0
      public/assets/libraries/ckeditor5/translations/bs.umd.js
  72. 8 0
      public/assets/libraries/ckeditor5/translations/ca.d.ts
  73. 4 0
      public/assets/libraries/ckeditor5/translations/ca.js
  74. 6 0
      public/assets/libraries/ckeditor5/translations/ca.umd.js
  75. 8 0
      public/assets/libraries/ckeditor5/translations/cs.d.ts
  76. 4 0
      public/assets/libraries/ckeditor5/translations/cs.js
  77. 6 0
      public/assets/libraries/ckeditor5/translations/cs.umd.js
  78. 8 0
      public/assets/libraries/ckeditor5/translations/da.d.ts
  79. 4 0
      public/assets/libraries/ckeditor5/translations/da.js
  80. 6 0
      public/assets/libraries/ckeditor5/translations/da.umd.js
  81. 8 0
      public/assets/libraries/ckeditor5/translations/de-ch.d.ts
  82. 4 0
      public/assets/libraries/ckeditor5/translations/de-ch.js
  83. 6 0
      public/assets/libraries/ckeditor5/translations/de-ch.umd.js
  84. 8 0
      public/assets/libraries/ckeditor5/translations/de.d.ts
  85. 4 0
      public/assets/libraries/ckeditor5/translations/de.js
  86. 6 0
      public/assets/libraries/ckeditor5/translations/de.umd.js
  87. 8 0
      public/assets/libraries/ckeditor5/translations/el.d.ts
  88. 4 0
      public/assets/libraries/ckeditor5/translations/el.js
  89. 6 0
      public/assets/libraries/ckeditor5/translations/el.umd.js
  90. 8 0
      public/assets/libraries/ckeditor5/translations/en-au.d.ts
  91. 4 0
      public/assets/libraries/ckeditor5/translations/en-au.js
  92. 6 0
      public/assets/libraries/ckeditor5/translations/en-au.umd.js
  93. 8 0
      public/assets/libraries/ckeditor5/translations/en-gb.d.ts
  94. 4 0
      public/assets/libraries/ckeditor5/translations/en-gb.js
  95. 6 0
      public/assets/libraries/ckeditor5/translations/en-gb.umd.js
  96. 8 0
      public/assets/libraries/ckeditor5/translations/en.d.ts
  97. 4 0
      public/assets/libraries/ckeditor5/translations/en.js
  98. 6 0
      public/assets/libraries/ckeditor5/translations/en.umd.js
  99. 8 0
      public/assets/libraries/ckeditor5/translations/eo.d.ts
  100. 4 0
      public/assets/libraries/ckeditor5/translations/eo.js

+ 28 - 0
app/Console/Commands/DispatchDueEmails.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\EmailMessage;
+use App\Jobs\SendEmailMessage;
+
+class DispatchDueEmails extends Command
+{
+    protected $signature = 'emails:dispatch-due';
+    protected $description = 'Invia le email programmate giunte a scadenza';
+
+    public function handle()
+    {
+        app(\App\Http\Middleware\TenantMiddleware::class)->setupTenantConnection();
+
+        EmailMessage::where('status', 'scheduled')
+            ->where('schedule_at', '<=', now())
+            ->chunkById(100, function ($chunk) {
+                foreach ($chunk as $msg) {
+                    dispatch(new SendEmailMessage($msg->id));
+                }
+            });
+
+        return Command::SUCCESS;
+    }
+}

+ 4 - 1
app/Console/Kernel.php

@@ -16,6 +16,9 @@ class Kernel extends ConsoleKernel
     protected function schedule(Schedule $schedule)
     {
         $schedule->command('password:cleanup')->dailyAt('02:00');
+
+        // invia email programmate
+        $schedule->command('emails:dispatch-due')->everyMinute();
     }
 
     /**
@@ -25,7 +28,7 @@ class Kernel extends ConsoleKernel
      */
     protected function commands()
     {
-        $this->load(__DIR__.'/Commands');
+        $this->load(__DIR__ . '/Commands');
 
         require base_path('routes/console.php');
     }

+ 29 - 0
app/Http/Controllers/FileUpload.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class FileUpload extends Controller
+{
+    public function upload(Request $request)
+    {
+        if ($request->hasFile('upload')) {
+            $file = $request->file('upload');
+            $originName = $file->getClientOriginalName();
+            $extension = $file->getClientOriginalExtension();
+            $fileName = pathinfo($originName, PATHINFO_FILENAME) . '_' . time() . '.' . $extension;
+            
+            $file->move(public_path('uploads'), $fileName);
+
+            $url = asset('uploads/' . $fileName);
+
+
+            return response()->json([
+                'fileName' => $fileName,
+                'uploaded' => true,
+                'url' => $url
+            ]);
+        }
+    }
+}

+ 69 - 0
app/Http/Controllers/ReceiptExportController.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\Receipt;
+use Illuminate\Support\Str;
+use ZipArchive;
+use Barryvdh\DomPDF\Facade\Pdf;
+
+class ReceiptExportController extends Controller
+{
+    public function exportZip(Request $request)
+    {
+        $q = Receipt::query()->with('member');
+
+        if ($request->filled('filterStatus')) {
+            $q->where('status', $request->input('filterStatus'));
+        }
+        if ($request->filled('filterFrom')) {
+            $q->whereDate('date', '>=', $request->input('filterFrom'));
+        }
+        if ($request->filled('filterTo')) {
+            $q->whereDate('date', '<=', $request->input('filterTo'));
+        }
+        if ($request->filled('filterMember')) {
+            $q->where('member_id', $request->input('filterMember'));
+        }
+
+        // Eventuale ricerca globale
+        if ($search = $request->input('search')) {
+            $q->where(function ($sub) use ($search) {
+                $sub->where('number', 'like', "%{$search}%")
+                    ->orWhere('status', 'like', "%{$search}%")
+                    ->orWhereHas('member', function ($mq) use ($search) {
+                        $mq->where('first_name', 'like', "%{$search}%")
+                            ->orWhere('last_name', 'like', "%{$search}%");
+                    });
+            });
+        }
+
+        $receipts = $q->get();
+
+        if ($receipts->isEmpty()) {
+            return abort(404, 'Nessuna ricevuta trovata.');
+        }
+
+        $zipFileName = now()->format('Ymd') . '_Ricevute.zip';
+        $zipPath = storage_path('app/' . $zipFileName);
+
+        $zip = new \ZipArchive();
+        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
+            return abort(500, 'Impossibile creare lo ZIP.');
+        }
+
+        foreach ($receipts as $receipt) {
+            $lastName  = Str::slug($receipt->member->last_name ?? 'sconosciuto', '_');
+            $firstName = Str::slug($receipt->member->first_name ?? 'sconosciuto', '_');
+            $pdfName   = "Ricevuta_{$receipt->number}_{$lastName}_{$firstName}.pdf";
+
+            $pdf = PDF::loadView('receipt', ['receipt' => $receipt]);
+            $zip->addFromString($pdfName, $pdf->output());
+        }
+
+        $zip->close();
+
+        return response()->download($zipPath)->deleteFileAfterSend(true);
+    }
+}

+ 5 - 1
app/Http/Livewire/Causal.php

@@ -8,7 +8,7 @@ use App\Http\Middleware\TenantMiddleware;
 
 class Causal extends Component
 {
-    public $recordsIn, $recordsOut, $parent_id,  $name, $enabled, $corrispettivo_fiscale, $no_receipt, $money, $user_status, $no_first, $no_records, $type, $dataId, $update = false, $add = false;
+    public $recordsIn, $recordsOut, $parent_id,  $name, $enabled, $corrispettivo_fiscale, $no_receipt, $money, $user_status, $no_first, $no_records, $no_reports, $type, $dataId, $update = false, $add = false;
 
     public $corrispettivo_causal_id = 0;
 
@@ -119,6 +119,7 @@ class Causal extends Component
         $this->user_status = false;
         $this->no_first = false;
         $this->no_records = false;
+        $this->no_reports = false;
         $this->enabled = true;
     }
 
@@ -165,6 +166,7 @@ class Causal extends Component
                 'user_status' => $this->user_status,
                 'no_first' => $this->no_first,
                 'no_records' => $this->no_records,
+                'no_reports' => $this->no_reports,
                 'enabled' => $this->enabled
             ]);
             session()->flash('success', 'Causale creata');
@@ -189,6 +191,7 @@ class Causal extends Component
                 $this->user_status = $causal->user_status;
                 $this->no_first = $causal->no_first;
                 $this->no_records = $causal->no_records;
+                $this->no_reports = $causal->no_reports;
                 $this->enabled = $causal->enabled;
                 $this->corrispettivo_fiscale = $causal->corrispettivo_fiscale;
                 $this->type = $causal->type;
@@ -213,6 +216,7 @@ class Causal extends Component
                 'user_status' => $this->user_status,
                 'no_first' => $this->no_first,
                 'no_records' => $this->no_records,
+                'no_reports' => $this->no_reports,
                 'money' => $this->money,
                 'no_receipt' => $this->no_receipt,
                 'corrispettivo_fiscale' => $this->corrispettivo_fiscale,

+ 9 - 1
app/Http/Livewire/Causals.php

@@ -19,6 +19,8 @@ class Causals extends Component
     public $emit = 'setCausal';
     public $idx = -1;
     public $causal_id = null;
+    public $show_hidden = true;
+
     public function boot()
     {
         app(TenantMiddleware::class)->setupTenantConnection();
@@ -30,7 +32,7 @@ class Causals extends Component
         $this->idx = $idx;
         $this->causal_id = $causal_id;
 
-        if ($this->causal_id != null) {
+        if ($this->causal_id != null && $this->causal_id != 0) {
             $c = \App\Models\Causal::findOrFail($this->causal_id);
             $ids = array_reverse($c->recursiveParent($c->parent_id, [$c->id]));
 
@@ -78,6 +80,12 @@ class Causals extends Component
             return $query;
         };
 
+        if ($this->show_hidden == false) {
+            $visibilityFilter = function($query) {
+                return $query->where('hidden', false);
+            };
+        }
+
         if ($this->level_1_id > 0) {
             $this->level_2 = \App\Models\Causal::where('parent_id', $this->level_1_id)
                 ->where('type', $this->type)

+ 18 - 9
app/Http/Livewire/Course.php

@@ -35,7 +35,7 @@ class Course extends Component
     public $msgPrices = '';
     public $msgWhen = '';
 
-    public $course_types = [];
+    // public $course_types = [];
     public $course_durations = [];
     public $course_frequencies = [];
     public $course_levels = [];
@@ -140,7 +140,7 @@ class Course extends Component
             $this->monthList[$i][11] = "Novembre";
             $this->monthList[$i][12] = "Dicembre";
         }
-        $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->get();
+        // $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->get();
         $this->course_durations = \App\Models\CourseDuration::select('*')->where('enabled', true)->get();
         $this->course_levels = \App\Models\CourseLevel::select('*')->where('enabled', true)->get();
         $this->course_subscriptions = \App\Models\CourseSubscription::select('*')->where('enabled', true)->get();
@@ -157,8 +157,9 @@ class Course extends Component
         if (isset($_GET["year"]))
             $this->selectedYear = $_GET["year"];
         else
-            $this->selectedYear =  sizeof($this->course_years) > 0 ? $this->course_years[0] : '';
-            //$this->selectedYear = date("Y") . "-" . (date("Y") + 1);
+            // $this->selectedYear =  sizeof($this->course_years) > 0 ? $this->course_years[0] : '';
+            $this->selectedYear = date("Y") . "-" . (date("Y") + 1);
+
         $this->records = \App\Models\Course::where('parent_id', null)->where('year', $this->selectedYear)->with('type', 'duration')->get();
         return view('livewire.course');
     }
@@ -189,11 +190,18 @@ class Course extends Component
             $this->msgPrices = '';
             $this->msgWhen = '';
 
-            if ($this->when[0]['from'] == '')
-                $this->msgWhen = 'Devi inserire almeno un giorno';
-
+            if ($this->type == 'standard') {
+                if ($this->when[0]['from'] == '')
+                    $this->msgWhen = 'Devi inserire almeno un giorno';
+            }
+    
             if ($this->prices[0]['course_subscription_id'] == null)
                 $this->msgPrices = 'Devi inserire almeno un prezzo';
+            
+            $subscriptions = array_column($this->prices, 'course_subscription_id');
+            $unique_subscriptions = array_unique($subscriptions);
+            if (count($subscriptions) != count($unique_subscriptions))
+                $this->msgPrices = 'È possibile aggiungere solo un abbonamento per ciascun tipo';
 
             if ($this->msgPrices == '' &&  $this->msgWhen == '')
             {
@@ -423,8 +431,9 @@ class Course extends Component
         $newYear = date("Y") . "-" . (date("Y") + 1);
         if ($course->year != '')
         {
-            list($u, $y) = explode("-", $course->year);
-            $newYear = ($u + 1) . "-" . ($u + 2);
+            // list($u, $y) = explode("-", $course->year);
+            // $newYear = ($u + 1) . "-" . ($u + 2);
+            $newYear = $course->year;
         }
         $newCourse->year = $newYear;
         $newCourse->save();

+ 2 - 2
app/Http/Livewire/CourseList.php

@@ -41,7 +41,7 @@ class CourseList extends Component
     public $filterDuration = [];
 
     public $course_durations = [];
-    public $course_types = [];
+    // public $course_types = [];
     public $course_frequencies = [];
     public $course_levels = [];
     public $course_years = [];
@@ -64,7 +64,7 @@ class CourseList extends Component
         $this->selectedCourseId = 0;
         $this->selectedMemberId = 0;
 
-        $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->get();
+        // $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->get();
         $this->course_durations = \App\Models\CourseDuration::select('*')->where('enabled', true)->get();
         $this->course_levels = \App\Models\CourseLevel::select('*')->where('enabled', true)->get();
         $this->course_frequencies = \App\Models\CourseFrequency::select('*')->where('enabled', true)->get();

+ 2 - 2
app/Http/Livewire/CourseMember.php

@@ -13,7 +13,7 @@ class CourseMember extends Component
 
     public $courses = [];
     public $course_frequencies = [];
-    public $course_types = [];
+    // public $course_types = [];
     public $course_levels = [];
     public $course_durations = [];
     public $course_years = [];
@@ -59,7 +59,7 @@ class CourseMember extends Component
             $this->type = \App\Models\Course::findOrFail($_GET["id"])->type;
         }
 
-        $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->get();
+        // $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->get();
         $this->course_frequencies = \App\Models\CourseFrequency::select('*')->where('enabled', true)->get();
         $this->course_levels = \App\Models\CourseLevel::select('*')->where('enabled', true)->get();
         $this->course_durations = \App\Models\CourseDuration::select('*')->where('enabled', true)->get();

+ 20 - 4
app/Http/Livewire/Dashboard.php

@@ -4,6 +4,7 @@ namespace App\Http\Livewire;
 
 use Livewire\Component;
 use Carbon\Carbon;
+use DateTimeZone;
 use Illuminate\Support\Facades\Log;
 
 class Dashboard extends Component
@@ -26,8 +27,9 @@ class Dashboard extends Component
     public $expiredCertificatesChange = 0;
     public $suspendedSubscriptionsChange = 0;
 
-    public $toReceive = 0;
+    public $received = 0;
     public $toPay = 0;
+    public $paid = 0;
 
     public $courses = [];
     public $fields = [];
@@ -198,17 +200,30 @@ class Dashboard extends Component
 
         try {
             $currentMonth = now()->format('Y-m');
+            $currentDate = now()->format('Y-m-d');
             Log::info('Calculating financial stats for month', ['month' => $currentMonth]);
 
-            $this->toReceive = \App\Models\Record::where('type', 'IN')
+            $this->received = \App\Models\Record::where('type', 'IN')
                 ->whereRaw('DATE_FORMAT(date, "%Y-%m") = ?', [$currentMonth])
                 ->where(function ($query) {
                     $query->where('deleted', false)->orWhere('deleted', null);
                 })
                 ->sum('amount') ?? 0;
 
+            $this->paid = \App\Models\Record::where('type', 'OUT')
+                ->whereRaw('DATE_FORMAT(date, "%Y-%m") = ?', [$currentMonth])
+                ->where('data_pagamento', '<=', $currentDate)
+                ->where(function ($query) {
+                    $query->where('deleted', false)->orWhere('deleted', null);
+                })
+                ->sum('amount') ?? 0;
+
             $this->toPay = \App\Models\Record::where('type', 'OUT')
                 ->whereRaw('DATE_FORMAT(date, "%Y-%m") = ?', [$currentMonth])
+                ->where(function ($query) use($currentDate) {
+                    $query->where('data_pagamento', '>', $currentDate)
+                        ->orWhereNull('data_pagamento');
+                })
                 ->where(function ($query) {
                     $query->where('deleted', false)->orWhere('deleted', null);
                 })
@@ -216,7 +231,8 @@ class Dashboard extends Component
 
             $endTime = microtime(true);
             Log::info('Financial stats loaded successfully', [
-                'to_receive' => $this->toReceive,
+                'received' => $this->received,
+                'paid' => $this->paid,
                 'to_pay' => $this->toPay,
                 'execution_time_ms' => round(($endTime - $startTime) * 1000, 2)
             ]);
@@ -624,7 +640,7 @@ class Dashboard extends Component
                 $newNote = [
                     'id' => uniqid(),
                     'text' => trim($this->notes),
-                    'created_at' => now()->format('d/m/Y H:i'),
+                    'created_at' => now()->timezone('Europe/Rome')->format('d/m/Y H:i'),
                     'completed' => false
                 ];
 

+ 353 - 130
app/Http/Livewire/EmailComunications.php

@@ -3,204 +3,427 @@
 namespace App\Http\Livewire;
 
 use Livewire\Component;
-use Illuminate\Support\Facades\Auth;
+use Livewire\WithFileUploads;
+use Illuminate\Support\Facades\DB;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Storage;
+
 use App\Http\Middleware\TenantMiddleware;
-use App\Models\EmailTemplate;
-use App\Models\EmailScheduled;
-use App\Models\Category;
+use App\Models\EmailMessage;
 use App\Models\Member;
-use App\Models\User;
-use Carbon\Carbon;
-use Illuminate\Support\Facades\Mail;
-use Illuminate\Support\Facades\Log;
 
 class EmailComunications extends Component
 {
-    public $records, $subject, $message, $selectedRecipients = [], $scheduledDateTime, $sendNow = 'true';
-    public $dataId, $update = false, $add = false;
-    public $users = [];
-
-    protected $rules = [
-        'subject' => 'required|string|max:255',
-        'message' => 'required|string',
-        'selectedRecipients' => 'required|array|min:1',
-        'scheduledDateTime' => 'required_if:sendNow,false|date|after:now',
-    ];
-
-    protected $messages = [
-        'subject.required' => 'L\'oggetto è obbligatorio',
-        'message.required' => 'Il messaggio è obbligatorio',
-        'selectedRecipients.required' => 'Seleziona almeno un gruppo di destinatari',
-        'scheduledDateTime.required_if' => 'La data di programmazione è obbligatoria',
-        'scheduledDateTime.after' => 'La data di programmazione deve essere futura',
-    ];
-
-    public $sortField = 'name';
-    public $sortAsc = true;
+    use WithFileUploads;
+
+    public ?int $messageId = null;
+    public string $subject = '';
+    public string $content_html = '';
+
+    public array $recipients = [];
+
+    public array $existingAttachments = [];
+    public $newAttachments = [];
+
+    public string $mode = 'now'; // 'now' | 'schedule'
+    public ?string $schedule_at = null;
+
+    public $records;
+    public $categories;
+    public $courses;
+
+    public bool $showForm = false;
+    public bool $locked = false;
+
+    public $success;
+    public $error;
 
     public function boot()
     {
         app(TenantMiddleware::class)->setupTenantConnection();
     }
 
-    public function sortBy($field)
+    public function mount()
+    {
+        if (auth()->user()?->level != env('LEVEL_ADMIN', 0)) {
+            return redirect()->to('/dashboard');
+        }
+
+        $this->categories = [];
+        $this->getCategories(\App\Models\Category::select('id', 'name')->where('parent_id', null)->orderBy('name')->get(), 0);
+
+        $this->courses = [];
+        $this->getCourses(\App\Models\Course::select('id', 'name')->where('parent_id', null)->orderBy('name', 'ASC')->get(), 0);
+
+        $this->schedule_at = now()->addHour()->format('Y-m-d\TH:i');
+    }
+
+    public function render()
     {
-        if ($this->sortField === $field) {
-            $this->sortAsc = ! $this->sortAsc;
+        if (!$this->showForm) {
+            $this->records = EmailMessage::withCount(['attachments', 'recipients'])
+                ->orderBy('created_at', 'desc')
+                ->get();
         } else {
-            $this->sortAsc = true;
+            $this->categories = [];
+            $this->getCategories(\App\Models\Category::select('id', 'name')->where('parent_id', null)->orderBy('name')->get(), 0);
+
+            $this->courses = [];
+            $this->getCourses(\App\Models\Course::select('id', 'name')->where('parent_id', null)->orderBy('name', 'ASC')->get(), 0);
         }
 
-        $this->sortField = $field;
+        return view('livewire.email_comunications');
     }
 
-    public function resetFields()
+    protected function baseRules(): array
     {
-        $this->subject = '';
-        $this->message = '';
-        $this->selectedRecipients = [];
-        $this->sendNow = true;
-        $this->scheduledDateTime = now()->addHour()->format('Y-m-d\TH:i');
-        $this->emit('load-data-table');
+        return [
+            'subject' => 'required|string|max:255',
+            'content_html' => 'required|string',
+            'recipients' => 'required|array|min:1',
+            'recipients.*.email_address' => 'required|email',
+            'newAttachments' => 'nullable',
+            'newAttachments.*' => 'file|max:20480',
+        ];
     }
 
-    public function mount()
+    protected function validateDraft(): void
     {
-        if (Auth::user()->level != env('LEVEL_ADMIN', 0))
-            return redirect()->to('/dashboard');
+        $this->validate($this->baseRules());
+    }
 
-        $this->users = Member::select('id', 'last_name', 'email')->get();
-        $this->scheduledDateTime = now()->addHour()->format('Y-m-d\TH:i');
+    protected function validateSend(): void
+    {
+        $this->validate($this->baseRules());
     }
 
-    public function render()
+    protected function validateSchedule(): void
     {
-        $this->records = EmailTemplate::orderBy($this->sortField, $this->sortAsc ? 'asc' : 'desc')->get();
-        return view('livewire.email_comunications');
+        $rules = $this->baseRules();
+        $rules['schedule_at'] = 'required|date|after:now';
+        $this->validate($rules);
     }
 
     public function add()
     {
-        $this->resetFields();
-        $this->add = true;
-        $this->update = false;
+        $this->reset(['messageId', 'subject', 'content_html', 'recipients', 'newAttachments', 'mode', 'schedule_at']);
+        $this->mode = 'now';
+        $this->schedule_at = now()->addHour()->format('Y-m-d\TH:i');
+        $this->existingAttachments = [];
+
+        $this->showForm = true;
+
+        $this->dispatchBrowserEvent('load-editor', [
+            'html'   => $this->content_html ?? '',
+            'locked' => $this->locked,
+        ]);
+        $this->dispatchBrowserEvent('init-recipients-table', [
+            'selected' => collect($this->recipients)->pluck('member_id')->filter()->values()->all(),
+        ]);
     }
 
-    public function store()
+    public function edit($id)
     {
-        $this->validate();
-
         try {
-            $template = EmailTemplate::create([
-                'name' => $this->subject,
-                'content' => $this->message,
-                'created_by' => Auth::id(),
-            ]);
+            $msg = EmailMessage::with(['recipients', 'attachments'])->findOrFail($id);
 
-            $recipients = User::whereIn('id', $this->selectedRecipients)->get();
+            $this->messageId = $msg->id;
+            $this->subject = $msg->subject;
+            $this->content_html = $msg->content_html;
+            $this->recipients = $msg->recipients->map(fn($r) => [
+                'member_id' => $r->member_id,
+                'email_address' => $r->email_address,
+                'first_name'    => optional($r->member)->first_name,
+                'last_name'     => optional($r->member)->last_name,
+            ])->toArray();
+            $this->mode = $msg->status === 'scheduled' ? 'schedule' : 'now';
+            $this->schedule_at = optional($msg->schedule_at)?->format('Y-m-d\TH:i');
+            $this->existingAttachments = $msg->attachments->map(fn($a) => [
+                'id'   => $a->id,
+                'name' => $a->name ?: basename($a->path),
+                'size' => $a->size_human,
+                'url'  => $a->public_url,
+                'img'  => $a->is_image,
+            ])->toArray();
 
-            if ($this->sendNow === 'true') { // Cambiare il confronto
-                $this->sendEmailNow($template, $recipients);
-                session()->flash('success', 'Template creato e Email inviate a ' . $recipients->count() . ' destinatari!');
-            } else {
-                $this->scheduleEmail($template, $recipients);
-                $scheduledDate = Carbon::parse($this->scheduledDateTime)->format('d/m/Y H:i');
-                session()->flash('success', 'Template creato e Email programmate per ' . $scheduledDate);
-            }
+            $this->showForm = true;
+            $this->locked = $msg->isLocked();
 
-            $this->resetFields();
-            $this->add = false;
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
+            $this->dispatchBrowserEvent('load-editor', [
+                'html' => $this->content_html ?? '',
+                'locked' => $this->locked,
+            ]);
+            $this->dispatchBrowserEvent('init-recipients-table', [
+                'selected' => collect($this->recipients)->pluck('member_id')->filter()->values()->all(),
+            ]);
+        } catch (\Throwable $ex) {
+            $this->error = 'Errore (' . $ex->getMessage() . ')';
         }
     }
 
-    public function edit($id)
+
+    public function duplicate($id, $withRecipients = true)
     {
         try {
-            $template = EmailTemplate::findOrFail($id);
-            if (!$template) {
-                session()->flash('error', 'Template Email non trovato');
-            } else {
-                $this->subject = $template->name;
-                $this->message = $template->content;
-                $this->dataId = $template->id;
-                $this->update = true;
-                $this->add = false;
+            $copy = EmailMessage::with(['recipients', 'attachments'])->findOrFail($id)->duplicate($withRecipients);
+            $this->edit($copy->id);
+            $this->success = 'Bozza duplicata';
+        } catch (\Throwable $ex) {
+            $this->error = 'Errore (' . $ex->getMessage() . ')';
+        }
+    }
+
+    public function saveDraft($html = null)
+    {
+        if ($html !== null) $this->content_html = $html;
+        $this->validateDraft();
+
+        DB::transaction(function () {
+            $msg = $this->upsertMessage(status: 'draft', scheduleAt: null);
+            $this->upsertRecipients($msg);
+            $this->upsertAttachments($msg);
+            $this->messageId = $msg->id;
+            $this->locked = $msg->isLocked();
+            $this->refreshAttachments($msg);
+        });
+
+        $this->success = 'Bozza salvata';
+
+        $this->dispatchBrowserEvent('load-editor', [
+            'html'   => $this->content_html ?? '',
+            'locked' => $this->locked,
+        ]);
+    }
+
+    public function sendNow($html = null)
+    {
+        if ($html !== null) $this->content_html = $html;
+        $this->validateSend();
+
+        if ($this->messageId) {
+            $existing = EmailMessage::findOrFail($this->messageId);
+            if ($existing->isLocked()) {
+                $this->error = 'Questa email è già in invio o inviata e non può essere modificata.';
+                return;
             }
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
         }
+
+        DB::transaction(function () {
+            $msg = $this->upsertMessage(status: 'processing', scheduleAt: null);
+            $this->upsertRecipients($msg, true);
+            $this->upsertAttachments($msg, true);
+            $this->messageId = $msg->id;
+            $this->locked = true;
+            $this->refreshAttachments($msg);
+        });
+
+        dispatch(new \App\Jobs\SendEmailMessage($this->messageId));
+        $this->success = 'Invio avviato';
+
+        $this->dispatchBrowserEvent('load-editor', [
+            'html'   => $this->content_html ?? '',
+            'locked' => $this->locked,
+        ]);
     }
 
-    public function update()
+    public function scheduleMessage($html = null)
     {
-        $this->validate([
-            'subject' => 'required|string|max:255',
-            'message' => 'required|string',
+        if ($html !== null) $this->content_html = $html;
+        $this->validateSchedule();
+
+        if ($this->messageId) {
+            $existing = EmailMessage::findOrFail($this->messageId);
+            if ($existing->isLocked()) {
+                $this->error = 'Questa email è già in invio o inviata e non può essere modificata.';
+                return;
+            }
+        }
+
+        DB::transaction(function () {
+            $msg = $this->upsertMessage(status: 'scheduled', scheduleAt: \Carbon\Carbon::parse($this->schedule_at));
+            $this->upsertRecipients($msg, true);
+            $this->upsertAttachments($msg, true);
+            $this->messageId = $msg->id;
+            $this->locked = $msg->isLocked();
+            $this->refreshAttachments($msg);
+        });
+
+        $this->success = 'Email programmata';
+
+        $this->dispatchBrowserEvent('load-editor', [
+            'html'   => $this->content_html ?? '',
+            'locked' => $this->locked,
         ]);
+    }
 
-        try {
-            EmailTemplate::whereId($this->dataId)->update([
-                'name' => $this->subject,
-                'content' => $this->message,
+    protected function upsertMessage(string $status, $scheduleAt): EmailMessage
+    {
+        return EmailMessage::updateOrCreate(
+            ['id' => $this->messageId],
+            [
+                'subject'      => $this->subject,
+                'content_html' => $this->content_html,
+                'status'       => $status,
+                'schedule_at'  => $scheduleAt,
+                'created_by'   => auth()->id(),
+            ]
+        );
+    }
+
+    protected function upsertRecipients(EmailMessage $msg, bool $force = false): void
+    {
+        if (!$force && $msg->isLocked()) return;
+
+        $msg->recipients()->delete();
+
+        $rows = collect($this->recipients)->map(fn($r) => [
+            'email_message_id' => $msg->id,
+            'member_id' => $r['member_id'] ?? null,
+            'email_address' => $r['email_address'],
+            'status' => 'pending',
+            'created_at' => now(),
+            'updated_at' => now(),
+        ])->values()->all();
+
+        if ($rows) \App\Models\EmailMessageRecipient::insert($rows);
+    }
+
+    protected function upsertAttachments(EmailMessage $msg, bool $force = false): void
+    {
+        if (!$force && $msg->isLocked()) return;
+
+        $files = is_array($this->newAttachments) ? $this->newAttachments : [$this->newAttachments];
+        foreach ($files as $upload) {
+            if (!$upload) continue;
+            $path = $upload->store('emails/' . \Illuminate\Support\Str::uuid(), 'public');
+            $msg->attachments()->create([
+                'disk' => 'public',
+                'path' => $path,
+                'name' => $upload->getClientOriginalName(),
+                'size' => $upload->getSize(),
             ]);
-            session()->flash('success', 'Template Email aggiornato');
-            $this->resetFields();
-            $this->update = false;
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
         }
     }
 
     public function cancel()
     {
-        $this->add = false;
-        $this->update = false;
-        $this->resetFields();
+        $this->showForm = false;
+        $this->reset(['messageId', 'subject', 'content_html', 'recipients', 'newAttachments', 'mode', 'schedule_at']);
+        $this->mode = 'now';
+        $this->schedule_at = now()->addHour()->format('Y-m-d\TH:i');
+
+        $this->dispatchBrowserEvent('init-archive-table');
     }
 
-    public function sendTemplate($id)
+    public function getCategories($records, $indentation)
     {
-        try {
-            $template = EmailTemplate::findOrFail($id);
-            $this->subject = $template->name;
-            $this->message = $template->content;
-            $this->add = true;
-            $this->update = false;
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
+        foreach ($records as $record) {
+            $this->categories[] = array('id' => $record->id, 'name' => $record->getTree());
+            if (count($record->childs))
+                $this->getCategories($record->childs, $indentation + 1);
         }
     }
 
-    private function sendEmailNow($template, $recipients)
+    public function getCourses($records, $indentation)
     {
-        foreach ($recipients as $recipient) {
-            if ($recipient->email) {
-                try {
-                    Log::info("Email sent to {$recipient->name} ({$recipient->email}): Subject: {$template->name}");
-                } catch (\Exception $e) {
-                    Log::error("Failed to send email to {$recipient->email}: " . $e->getMessage());
+        /** @var \App\Models\Course $record */
+        foreach ($records as $record) {
+            $this->courses[] = array('id' => $record->id, 'name' => $record->getTree());
+            if (count($record->childs))
+                $this->getCourses($record->childs, $indentation + 1);
+        }
+    }
+
+    public function toggleRecipient($id)
+    {
+        $id = (int)$id;
+
+        $idx = collect($this->recipients)->search(fn($r) => (int)($r['member_id'] ?? 0) === $id);
+        if ($idx !== false) {
+            array_splice($this->recipients, $idx, 1);
+            return;
+        }
+
+        $m = Member::select('id', 'email', 'first_name', 'last_name')->find($id);
+        if (!$m || empty($m->email)) return;
+
+        $this->recipients[] = [
+            'member_id' => $m->id,
+            'email_address' => $m->email,
+            'first_name' => $m->first_name,
+            'last_name' => $m->last_name,
+        ];
+    }
+
+    public function removeNewAttachment(int $index): void
+    {
+        if ($this->locked) return;
+        if (is_array($this->newAttachments) && array_key_exists($index, $this->newAttachments)) {
+            array_splice($this->newAttachments, $index, 1);
+        }
+    }
+
+    public function removeExistingAttachment(int $id): void
+    {
+        if ($this->locked || !$this->messageId) return;
+
+        $att = \App\Models\EmailMessageAttachment::find($id);
+        if (!$att || $att->email_message_id !== $this->messageId) return;
+
+        try {
+            if ($att->disk && $att->path) {
+                $disk = Storage::disk($att->disk);
+                $dir = dirname($att->path);
+                $disk->delete($att->path);
+
+                if (empty($disk->files($dir)) && empty($disk->directories($dir))) {
+                    $disk->deleteDirectory($dir);
                 }
-            } else {
-                Log::warning("User {$recipient->id} has no email address");
             }
+        } catch (\Throwable $e) {
         }
+
+        $att->delete();
+
+        $this->existingAttachments = array_values(array_filter(
+            $this->existingAttachments,
+            fn($a) => (int)$a['id'] !== (int)$id
+        ));
     }
 
-    private function scheduleEmail($template, $recipients)
+
+    protected function refreshAttachments(EmailMessage $msg): void
     {
-        $scheduled = EmailScheduled::create([
-            'template_id' => $template->id,
-            'subject' => $template->name,
-            'content' => $template->content,
-            'scheduled_at' => Carbon::parse($this->scheduledDateTime),
-            'status' => 'scheduled',
-            'created_by' => Auth::id(),
-        ]);
+        $this->existingAttachments = $msg->attachments()
+            ->get()
+            ->map(fn($a) => [
+                'id'   => $a->id,
+                'name' => $a->name ?: basename($a->path),
+                'size' => $a->size_human,
+                'url'  => $a->public_url,
+                'img'  => $a->is_image,
+            ])->toArray();
+
+        $this->newAttachments = [];
+    }
+
+    public function deleteMessage(int $id)
+    {
+        $msg = \App\Models\EmailMessage::with(['attachments'])->findOrFail($id);
+
+        if (! in_array($msg->status, ['draft', 'failed'], true)) {
+            return;
+        }
+
+        foreach ($msg->attachments as $a) {
+            try {
+                Storage::disk($a->disk ?? 'public')->delete($a->path);
+            } catch (\Throwable $e) {
+            }
+        }
 
-        $scheduled->recipients()->attach($recipients->pluck('id'));
+        $msg->delete();
 
-        Log::info("Email scheduled for {$this->scheduledDateTime} to {$recipients->count()} recipients: {$template->name}");
+        $this->dispatchBrowserEvent('email-deleted', ['id' => $id]);
     }
 }

+ 74 - 15
app/Http/Livewire/Member.php

@@ -106,7 +106,7 @@ class Member extends Component
 
     public $course_names = array();
     public $course_levels = array();
-    public $course_types = array();
+    // public $course_types = array();
     public $course_frequencies = array();
 
     public $course_subscriptions = array();
@@ -159,8 +159,8 @@ class Member extends Component
     protected $rules = [
         'first_name' => 'required',
         'last_name' => 'required',
-        'email' => 'required',
-        'phone' => 'required',
+        // 'email' => 'required',
+        // 'phone' => 'required',
         'birth_date' => 'before_or_equal:today'
     ];
 
@@ -620,8 +620,12 @@ class Member extends Component
 
     public function checkIsItaly()
     {
-        $n = \App\Models\Nation::findOrFail($this->nation_id);
-        $this->isItaly = $n->is_italy;
+        if ($this->nation_id > 0) {
+            $n = \App\Models\Nation::findOrFail($this->nation_id);
+            $this->isItaly = $n->is_italy;
+        } else {
+            $this->isItaly = false;
+        }
     }
 
     public function checkIsBirthItaly()
@@ -666,7 +670,7 @@ class Member extends Component
     {
 
         $this->course_names = [];
-        $allC = \App\Models\Course::where('type', $this->course_course_type)->orderBy('name')->get();
+        $allC = \App\Models\Course::where('type', $this->course_course_type)->where('enabled', true)->orderBy('name')->get();
         foreach ($allC as $c) {
             $cN = $c->name . " (" . $c->year . ")";
             if (!in_array($cN, $this->course_names))
@@ -812,6 +816,7 @@ class Member extends Component
         $this->update = false;
         $this->emit('setEdit', true);
         $this->emit('setEditCorso', false);
+        $this->dispatchBrowserEvent('scroll-to-top');
     }
 
     public function store($close)
@@ -826,9 +831,9 @@ class Member extends Component
         $rules = [
             'first_name' => 'required',
             'last_name' => 'required',
-            'email' => 'required',
-            'phone' => 'required',
             'birth_nation_id' => 'required',
+            // 'email' => 'required',
+            // 'phone' => 'required',
             'address' => 'required',
             'zip_code' => 'required',
             'nation_id' => 'required',
@@ -886,7 +891,16 @@ class Member extends Component
             $rules['mother_name'] = 'required_without:father_name';
             $rules['mother_email'] = 'required_without:father_email|email';
             $rules['mother_fiscal_code'] = 'required_without:father_fiscal_code';
+        } else {
+            $rules['email'] = 'required';
+            $rules['phone'] = 'required';
         }
+        
+        $std_rules = $rules;
+        // $rules = [
+        //     'first_name' => 'required',
+        //     'last_name' => 'required'
+        // ];
 
         try {
             $this->validate($rules);
@@ -912,6 +926,14 @@ class Member extends Component
             $mother_docs = implode("|", $this->mother_document_files);
 
 
+            $to_complete = false;
+            // try {
+            //     $this->validate($std_rules);
+            // } catch(\Illuminate\Validation\ValidationException $e) {
+            //     $to_complete = true;
+            // } catch (\Exception $e) {
+            //     $to_complete = true;
+            // }
 
             $member = \App\Models\Member::create([
                 'first_name' => strtoupper($this->first_name),
@@ -955,7 +977,7 @@ class Member extends Component
                 'phone3' => $this->phone3,
                 'email' => strtolower($this->email),
                 'image' => $imageName,
-                'to_complete' => false,
+                'to_complete' => $to_complete,
                 'enabled' => $this->enabled
             ]);
             $this->fileService->createMemberFolders($member->id);
@@ -972,6 +994,7 @@ class Member extends Component
             $this->resetFields();
             if ($close) {
                 $this->add = false;
+                return redirect()->to('/members');
             } else {
                 $this->edit($member->id);
                 $this->emit('saved-and-continue', $this->type);
@@ -1166,12 +1189,14 @@ class Member extends Component
                 $this->emit('setIds', $this->nation_id, $this->birth_nation_id);
 
                 $this->emit('load-select');
-
+                
                 $this->emit('load-provinces', $this->nation_id, 'provinceClass');
                 $this->emit('load-provinces', $this->birth_nation_id, 'provinceBirthClass');
-
+                
                 $this->emit('load-cities', $this->province_id, 'cityClass');
                 $this->emit('load-cities', $this->birth_province_id, 'cityBirthClass');
+
+                $this->dispatchBrowserEvent('scroll-to-top');
             }
         } catch (\Exception $ex) {
             session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
@@ -1190,8 +1215,8 @@ class Member extends Component
         $rules = [
             'first_name' => 'required',
             'last_name' => 'required',
-            'email' => 'required',
-            'phone' => 'required',
+            // 'email' => 'required',
+            // 'phone' => 'required',
             'birth_nation_id' => 'required',
             'address' => 'required',
             'zip_code' => 'required',
@@ -1209,6 +1234,7 @@ class Member extends Component
         Log::info('isItaly', ['isItaly' => $this->isItaly]);
         $this->isItaly = $this->checkIsItaly();
 
+
         if ($this->isItaly) {
             $rules['province_id'] = 'required';
             $rules['city_id'] = 'required';
@@ -1241,8 +1267,18 @@ class Member extends Component
             $rules['mother_name'] = 'required_without:father_name';
             $rules['mother_email'] = 'required_without:father_email|email';
             $rules['mother_fiscal_code'] = 'required_without:father_fiscal_code';
+        } else {
+            $rules['email'] = 'required';
+            $rules['phone'] = 'required';
         }
 
+        
+        $std_rules = $rules;
+        // $rules = [
+        //     'first_name' => 'required',
+        //     'last_name' => 'required'
+        // ];
+
         try {
             $this->validate($rules);
         } catch (\Illuminate\Validation\ValidationException $e) {
@@ -1267,6 +1303,15 @@ class Member extends Component
             $father_docs = implode("|", $this->father_document_files);
             $mother_docs = implode("|", $this->mother_document_files);
 
+            $to_complete = false;
+            // try {
+            //     $this->validate($std_rules);
+            // } catch(\Illuminate\Validation\ValidationException $e) {
+            //     $to_complete = true;
+            // } catch (Exception $e) {
+            //     $to_complete = true;
+            // }
+
             \App\Models\Member::whereId($this->dataId)->update([
                 'first_name' => strtoupper($this->first_name),
                 'last_name' => strtoupper($this->last_name),
@@ -1309,7 +1354,7 @@ class Member extends Component
                 'phone2' => $this->phone2,
                 'phone3' => $this->phone3,
                 'email' => strtolower($this->email),
-                'to_complete' => false,
+                'to_complete' => $to_complete,
                 'enabled' => $this->enabled
             ]);
             updateMemberData($this->dataId);
@@ -1317,6 +1362,7 @@ class Member extends Component
             if ($close) {
                 $this->resetFields();
                 $this->update = false;
+                return redirect()->to('/members');
             } else {
                 $this->emit('saved-and-continue', $this->type);
                 $this->dispatchBrowserEvent('scroll-to-top');
@@ -1707,7 +1753,7 @@ class Member extends Component
                 foreach ($all as $a) {
                     $types_ids[] = $a->course_type_id;
                 }
-                $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->whereIn('id', $types_ids)->get();
+                // $this->course_types = \App\Models\CourseType::select('*')->where('enabled', true)->whereIn('id', $types_ids)->get();
 
                 $frequencies_ids = [];
                 $all = \App\Models\Course::where('name', 'like', '%' . $c->name . "%")->where('enabled', true)->where('course_level_id', $this->course_level_id)->where('course_type_id', $this->course_type_id)->get();
@@ -1732,6 +1778,19 @@ class Member extends Component
                 foreach (json_decode($memberCourse->months) as $z) {
                     $this->course_months[] = array("m" => $z->m, "status" => $z->status);
                 }
+
+                $course_subscription_ids = [];
+                $this->course_price_list = [];
+                if ($c->prices != null) {
+                    foreach (json_decode($c->prices) as $z) {
+                        $this->course_price_list[$z->course_subscription_id] = $z->price;
+                        if ($z->price > 0)
+                            $course_subscription_ids[] = $z->course_subscription_id;
+                    }
+                }
+
+                $this->course_subscriptions = \App\Models\CourseSubscription::select('*')->whereIn('id', $course_subscription_ids)->where('enabled', true)->get();
+                
                 $this->courseDataId = $memberCourse->id;
                 $this->updateCourse = true;
                 $this->addCourse = false;

+ 26 - 5
app/Http/Livewire/Profile.php

@@ -20,6 +20,7 @@ class Profile extends Component
     public $telefono;
     public $cellulare;
     public $password;
+    public $password_confirmation;
 
     public function boot()
     {
@@ -44,12 +45,28 @@ class Profile extends Component
 
     public function save()
     {
-        $this->validate([
+
+        $rules = [
             'name' => 'required',
             'cognome' => 'required',
             'email' => 'required|email',
             'password' => 'nullable|min:6',
-        ]);
+            'password_confirmation' => 'nullable|same:password'
+        ];
+
+        $messages = [
+            'name.required' => 'Il nome è obbligatorio',
+            'cognome.required' => 'Il cognome è obbligatorio',
+            'email.required' => 'La mail è obbligatoria',
+            'email.email' => 'La mail deve essere un indirizzo valido',
+            'email.unique' => 'Questa mail è già stata utilizzata',
+            'password.required' => 'La password è obbligatoria',
+            'password.min' => 'La password deve essere di almeno 6 caratteri',
+            'password_confirmation.required' => 'Ripeti la password inserita',
+            'password_confirmation.same' => 'Le password non coincidono',
+        ];
+
+        $this->validate($rules, $messages);
 
         $currentUser = Auth::user();
 
@@ -102,6 +119,7 @@ class Profile extends Component
 
             $this->editMode = false;
             $this->password = ''; // Clear password field
+            $this->password_confirmation = ''; // Clear password_confirmation field
 
         } catch (\Exception $e) {
             Log::error('Profile update failed', [
@@ -187,10 +205,12 @@ class Profile extends Component
 
     public function cancel()
     {
-        $this->editMode = false;
-        $this->password = '';
+        return redirect()->to('/dashboard');
+
+        // $this->editMode = false;
+        // $this->password = '';
 
-        $this->mount();
+        // $this->mount();
     }
 
     private function resetInputFields()
@@ -201,6 +221,7 @@ class Profile extends Component
         $this->telefono = '';
         $this->cellulare = '';
         $this->password = '';
+        $this->password_confirmation = '';
     }
 
     public function render()

+ 40 - 19
app/Http/Livewire/Rate.php

@@ -5,6 +5,8 @@ namespace App\Http\Livewire;
 use Livewire\Component;
 use Barryvdh\DomPDF\Facade\Pdf;
 use App\Models\Member;
+use App\Http\Middleware\TenantMiddleware;
+use Illuminate\Support\Facades\DB;
 
 
 class Rate extends Component
@@ -29,12 +31,35 @@ class Rate extends Component
     public $month = '';
     public $months = [];
     public $disabled = [];
-    public $couse_subscriptions = [];
+    public $course_subscriptions = [];
     public $price_list = [];
     public $type = '';
 
     public $errorMsg = '';
 
+    public function boot()
+    {
+        app(TenantMiddleware::class)->setupTenantConnection();
+    }
+
+    protected function setupTenantConnection()
+    {
+        $user = auth()->user();
+
+        config(['database.connections.tenant' => [
+            'driver' => 'mysql',
+            'host' => '127.0.0.1',
+            'port' => '3306',
+            'database' => $user->tenant_database,
+            'username' => $user->tenant_username,
+            'password' => $user->tenant_password,
+        ]]);
+
+        config(['database.default' => 'tenant']);
+        DB::purge('tenant');
+        DB::reconnect('tenant');
+    }
+
     public function mount()
     {
         // Load members for the dropdown
@@ -64,7 +89,6 @@ class Rate extends Component
             }
 
             $this->course_subscriptions = \App\Models\CourseSubscription::select('*')->whereIn('id', $course_subscription_ids)->where('enabled', true)->get();
-
         }
 
         //$this->course_subscriptions = \App\Models\CourseSubscription::select('*')->where('enabled', true)->get();
@@ -136,13 +160,14 @@ class Rate extends Component
             else
             {
 
-                if ($this->type > 1 && $this->type != sizeof($this->months))
+                if ($this->type > 1 && $this->type < sizeof($this->months))
                 {
-                    $this->errorMsg = 'Hai selezionato un numero di mesi errato in base all\'abbonamento selezionato' . $this->type .'.'. sizeof($this->months);
+                    // $this->errorMsg = 'Hai selezionato un numero di mesi errato in base all\'abbonamento selezionato' . $this->type .'.'. sizeof($this->months);
+                    $this->errorMsg = 'Mesi selezionati superiori a quelli previsti dall’abbonamento';
                 }
-                else
-                {
-
+                elseif ($this->type == 1 && !$this->month) {
+                    $this->errorMsg = 'Seleziona un mese';
+                } else {
                     $rate = new \App\Models\Rate();
                     $rate->member_id = $this->member_id;
                     $rate->member_course_id = $this->member_course_id;
@@ -201,7 +226,7 @@ class Rate extends Component
             //$this->emit('load-data-table');
             session()->flash('success',"Rata eliminata");
         }catch(\Exception $e){
-            session()->flash('error','Errore (' . $ex->getMessage() . ')');
+            session()->flash('error','Errore (' . $e->getMessage() . ')');
         }
     }
 
@@ -215,7 +240,7 @@ class Rate extends Component
             //$this->emit('load-data-table');
             session()->flash('success',"Rata eliminata");
         }catch(\Exception $e){
-            session()->flash('error','Errore (' . $ex->getMessage() . ')');
+            session()->flash('error','Errore (' . $e->getMessage() . ')');
         }
     }
 
@@ -232,23 +257,19 @@ class Rate extends Component
             //$this->emit('load-data-table');
             session()->flash('success',"Rata eliminata");
         }catch(\Exception $e){
-            session()->flash('error','Errore (' . $ex->getMessage() . ')');
+            session()->flash('error','Errore (' . $e->getMessage() . ')');
         }
     }
 
     public function addDeleteMonth($m)
     {
-        if (!in_array($m, $this->months))
-        {
+        if (!in_array($m, $this->months)) {
             $this->months[] = $m;
-        }
-        else
-        {
-            if (($key = array_search($m, $this->months)) !== false)
-            {
-                $this->months = array_slice($this->months, $key, 1);
+        } else {
+            if (($key = array_search($m, $this->months)) !== false) {
+                unset($this->months[$key]);
+                $this->months = array_values($this->months);
             }
         }
     }
-
 }

+ 75 - 9
app/Http/Livewire/RecordIN.php

@@ -123,7 +123,6 @@ class RecordIN extends Component
     public function boot()
     {
         app(TenantMiddleware::class)->setupTenantConnection();
-
     }
 
     public function updatedMemberId()
@@ -551,7 +550,42 @@ class RecordIN extends Component
 
         $this->validate($rules);
 
+        /* Validazione campi azienda per generazione ricevuta */
+        try {
+            if (!$this->financial_movement && !$this->commercial) {
+                $no_receipt_causal_azienda = false;
+                foreach ($this->rows as $row) {
+                    $no_receipt_causal_azienda = false;
+
+                    $cau = \App\Models\Causal::findOrFail($row["causal_id"]);
+                    if ($cau->no_receipt)
+                        $no_receipt_causal_azienda = true;
+                }
+
+                $azienda = \App\Models\Azienda::first();
+
+                $payment_method = \App\Models\PaymentMethod::findOrFail($this->payment_method_id);
+                if (!$payment_method->money) {
+                    if (!$no_receipt_causal_azienda) {
+                        if (!$azienda->isValid()) {
+                            session()->flash('error_ricevuta', implode("", array_map(function ($item) {
+                                return "<li>$item</li>";
+                            }, $azienda->validate())));
+                            return false;
+                        }
+                    }
+                }
+            }
+        } catch (\Exception $ex) {
+            Log::error("Error in store method: " . $ex->getMessage());
+            Log::error("Stack trace: " . $ex->getTraceAsString());
+            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
+        }
+        /* END - Validazione campi azienda per generazione ricevuta */
+
         try {
+
+
             $totalGross = 0;
             $totalSconto = 0;
             $totalNet = 0;
@@ -675,7 +709,7 @@ class RecordIN extends Component
     }
 
 
-public function edit($id)
+    public function edit($id)
     {
         if (!isset($_GET["from"]) && $this->fromPage == '')
             $this->fromPage = 'in';
@@ -691,7 +725,7 @@ public function edit($id)
             } else {
                 if ($record->member_id) {
                     $member = \App\Models\Member::find($record->member_id);
-                    if (!$member || $member->status =='archived') {
+                    if (!$member || $member->status == 'archived') {
                         $this->member_id = null;
                     } else {
                         $this->member_id = $record->member_id;
@@ -770,7 +804,7 @@ public function edit($id)
         $visibleCausals = \App\Models\Causal::select('id', 'name')
             ->where('parent_id', null)
             ->where('type', 'IN')
-            ->where(function($query) {
+            ->where(function ($query) {
                 $query->where('hidden', false)->orWhereNull('hidden');
             })
             ->get();
@@ -779,7 +813,7 @@ public function edit($id)
         $this->payments = \App\Models\PaymentMethod::select('id', 'name')
             ->where('enabled', true)
             ->whereIn('type', array('ALL', 'IN'))
-            ->where(function($query) {
+            ->where(function ($query) {
                 $query->where('hidden', false)->orWhereNull('hidden');
             })
             ->orderBy('name')
@@ -787,7 +821,7 @@ public function edit($id)
 
         if ($this->commercial) {
             $this->members = \App\Models\Member::select(['id', 'first_name', 'last_name', 'fiscal_code'])
-                ->where(function($query) {
+                ->where(function ($query) {
                     $query->where('status', '!=', 'archived');
                 })
                 ->orderBy('last_name')
@@ -795,10 +829,10 @@ public function edit($id)
                 ->get();
         } else {
             $this->members = \App\Models\Member::select(['id', 'first_name', 'last_name', 'fiscal_code'])
-                ->where(function($query) {
+                ->where(function ($query) {
                     $query->where('current_status', 2)->orWhere('current_status', 1);
                 })
-                ->where(function($query) {
+                ->where(function ($query) {
                     $query->where('status', '!=', 'archived');
                 })
                 ->orderBy('last_name')
@@ -822,6 +856,39 @@ public function edit($id)
 
         $this->validate($rules);
 
+        /* Validazione campi azienda per generazione ricevuta */
+        try {
+            if (!$this->financial_movement && !$this->commercial) {
+                $no_receipt_causal_azienda = false;
+                foreach ($this->rows as $row) {
+                    $no_receipt_causal_azienda = false;
+
+                    $cau = \App\Models\Causal::findOrFail($row["causal_id"]);
+                    if ($cau->no_receipt)
+                        $no_receipt_causal_azienda = true;
+                }
+
+                $azienda = \App\Models\Azienda::first();
+
+                $payment_method = \App\Models\PaymentMethod::findOrFail($this->payment_method_id);
+                if (!$payment_method->money) {
+                    if (!$no_receipt_causal_azienda) {
+                        if (!$azienda->isValid()) {
+                            session()->flash('error_ricevuta', implode("", array_map(function ($item) {
+                                return "<li>$item</li>";
+                            }, $azienda->validate())));
+                            return false;
+                        }
+                    }
+                }
+            }
+        } catch (\Exception $ex) {
+            Log::error("Error in store method: " . $ex->getMessage());
+            Log::error("Stack trace: " . $ex->getTraceAsString());
+            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
+        }
+        /* END - Validazione campi azienda per generazione ricevuta */
+
         try {
             $totalGross = 0;
             $totalSconto = 0;
@@ -1064,7 +1131,6 @@ public function edit($id)
 
     public function createReceipt()
     {
-
         // Ulteriore controllo commerciale/non commerciale
         if (!$this->commercial) {
             $create = false;

+ 142 - 51
app/Http/Livewire/RecordINOUT.php

@@ -6,6 +6,8 @@ use Livewire\Component;
 
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
 use Illuminate\Support\Facades\Log;
 use SimpleXMLElement;
 use Illuminate\Support\Facades\Auth;
@@ -56,7 +58,6 @@ class RecordINOUT extends Component
     public function boot()
     {
         app(TenantMiddleware::class)->setupTenantConnection();
-
     }
 
 
@@ -403,6 +404,7 @@ class RecordINOUT extends Component
     {
 
         $rows_in = array();
+        $rows_out = array();
 
         if ($this->filterCausalsIn != null && sizeof($this->filterCausalsIn) > 0) {
             foreach ($this->rows_in as $r) {
@@ -413,7 +415,18 @@ class RecordINOUT extends Component
         } else {
             $rows_in = $this->rows_in;
         }
-        $result = $this->generateExcel($this->columns, $rows_in, $this->records_in, $this->rows_out, $this->records_out, false);
+
+        if ($this->filterCausalsOut != null && sizeof($this->filterCausalsOut) > 0) {
+            foreach ($this->rows_out as $r) {
+                if (in_array($r["id"], $this->filterCausalsOut) || in_array($r["parent_id"], $this->filterCausalsOut) || in_array($r["first_parent_id"], $this->filterCausalsOut)) {
+                    $rows_out[] = $r;
+                }
+            }
+        } else {
+            $rows_out = $this->rows_out;
+        }
+
+        $result = $this->generateExcel($this->columns, $rows_in, $this->records_in, $rows_out, $this->records_out, false);
 
         if ($result['storage_type'] === 's3') {
             try {
@@ -439,12 +452,10 @@ class RecordINOUT extends Component
         $records_in = [];
         $records_out = [];
         $datas = [];
-        if (env('FISCAL_YEAR_MONTH_FROM', 1) > 1)
-        {
+        if (env('FISCAL_YEAR_MONTH_FROM', 1) > 1) {
             if (date("m") < env('FISCAL_YEAR_MONTH_FROM', 1))
                 $year -= 1;
-            for($m=env('FISCAL_YEAR_MONTH_FROM', 1);$m<=12;$m++)
-            {
+            for ($m = env('FISCAL_YEAR_MONTH_FROM', 1); $m <= 12; $m++) {
                 $datas[] = $m . "-" . $year;
             }
             for ($m = 1; $m <= env('FISCAL_YEAR_MONTH_TO', 12); $m++) {
@@ -533,100 +544,180 @@ class RecordINOUT extends Component
 
     public function generateExcel($columns, $rows_in, $records_in, $rows_out, $records_out, $isYearExport)
     {
-        $letters = array('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N');
+        $writeNumber = function ($sheet, string $cell, $value, string $format = '#,##0.00') {
+            $sheet->setCellValueExplicit($cell, (float)$value, DataType::TYPE_NUMERIC);
+            $sheet->getStyle($cell)->getNumberFormat()->setFormatCode($format);
+        };
+
+        $shouldCountFn = function (array $row, array $presentParentIds): bool {
+            $level = $row['level'] ?? 0;
+            if ($level == 0) return true; // padre sempre incluso
+
+            $pid = $row['parent_id'] ?? null;
+            // se non c'è il padre includi il figlio
+            return !in_array($pid, $presentParentIds, true);
+        };
 
         $spreadsheet = new Spreadsheet();
         $activeWorksheet = $spreadsheet->getActiveSheet();
 
-        $activeWorksheet->setCellValue('A1', 'Entrate');
+        // Mappa colonne dinamica
+        $monthCount   = count($columns);
+        $nameColIdx   = 1;
+        $firstDataIdx = 2;
+        $lastDataIdx  = 1 + $monthCount;
+        $rowTotalIdx  = $lastDataIdx + 1;
+
+        $colA = Coordinate::stringFromColumnIndex($nameColIdx);
+        $colStart = Coordinate::stringFromColumnIndex($firstDataIdx);
+        $colEnd = Coordinate::stringFromColumnIndex($lastDataIdx);
+        $colRowTot = Coordinate::stringFromColumnIndex($rowTotalIdx);
+
+        // ========== ENTRATE ==========
+        // Header
+        $activeWorksheet->setCellValue($colA . '1', 'Entrate');
         foreach ($columns as $idx => $column) {
-            $activeWorksheet->setCellValue($letters[$idx + 1] . '1', $this->getMonth($column));
+            $col = Coordinate::stringFromColumnIndex($firstDataIdx + $idx);
+            $activeWorksheet->setCellValue($col . '1', $this->getMonth($column));
         }
+        $activeWorksheet->setCellValue($colRowTot . '1', 'Totale');
 
-        $activeWorksheet->getStyle('A1:N1')->getFont()->setBold(true);
-        $activeWorksheet->getStyle('A1:N1')->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB('C6E0B4'); // Lighter green
+        $activeWorksheet->getStyle($colA . '1:' . $colRowTot . '1')->getFont()->setBold(true);
+        $activeWorksheet->getStyle($colA . '1:' . $colRowTot . '1')
+            ->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('C6E0B4'); // verde chiaro
 
         $count = 2;
 
-        $totals = [];
+        $parentIdsIn = array_column(array_filter($rows_in, function ($r) {
+            return (int)($r['level'] ?? 0) === 0;
+        }), 'id');
+
+        $totalsIn = [];
 
         foreach ($rows_in as $in) {
-            $activeWorksheet->setCellValue('A' . $count, str_repeat("  ", $in["level"]) . $in["name"]);
+            $activeWorksheet->setCellValue($colA . $count, str_repeat("  ", (int)$in["level"]) . $in["name"]);
+
+            $rowSum = 0.0;
 
             foreach ($columns as $idx => $column) {
+                $col = Coordinate::stringFromColumnIndex($firstDataIdx + $idx);
+
                 if (isset($records_in[$column][$in["id"]])) {
-                    $activeWorksheet->setCellValue($letters[$idx + 1] . $count, formatPrice($records_in[$column][$in["id"]]));
-                    if ($in["level"] == 0) {
-                        if (isset($totals[$idx]))
-                            $totals[$idx] += $records_in[$column][$in["id"]];
-                        else
-                            $totals[$idx] = $records_in[$column][$in["id"]];
+                    $val = (float)$records_in[$column][$in["id"]];
+                    $writeNumber($activeWorksheet, $col . $count, $val);
+
+                    if ($shouldCountFn($in, $parentIdsIn)) {
+                        $totalsIn[$idx] = ($totalsIn[$idx] ?? 0) + $val;
                     }
+
+                    $rowSum += $val;
                 }
             }
 
-            if ($in["level"] == 0) {
-                $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFont()->setBold(true);
+            $writeNumber($activeWorksheet, $colRowTot . $count, $rowSum);
+
+            if ((int)$in["level"] === 0) {
+                $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)->getFont()->setBold(true);
             }
 
-            $count += 1;
+            $count++;
         }
 
-        $activeWorksheet->setCellValue('A' . $count, 'Totale');
-        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
+        // Riga "Totale" ENTRATE
+        $activeWorksheet->setCellValue($colA . $count, 'Totale');
+
+        $grandTotalIn = 0.0;
+        foreach ($totalsIn as $idx => $total) {
+            $col = Coordinate::stringFromColumnIndex($firstDataIdx + $idx);
+            $writeNumber($activeWorksheet, $col . $count, (float)$total);
+            $grandTotalIn += (float)$total;
         }
+        // Totale complessivo nella colonna "Totale"
+        $writeNumber($activeWorksheet, $colRowTot . $count, $grandTotalIn);
 
+        $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)->getFont()->setBold(true);
+        $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)
+            ->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('C6E0B4');
+
+        // ========== USCITE ==========
         $count += 2;
-        $activeWorksheet->setCellValue('A' . $count, "Uscite");
+
+        // Header
+        $activeWorksheet->setCellValue($colA . $count, 'Uscite');
         foreach ($columns as $idx => $column) {
-            $activeWorksheet->setCellValue($letters[$idx + 1] . $count, $this->getMonth($column));
+            $col = Coordinate::stringFromColumnIndex($firstDataIdx + $idx);
+            $activeWorksheet->setCellValue($col . $count, $this->getMonth($column));
         }
+        $activeWorksheet->setCellValue($colRowTot . '1', 'Totale'); // già impostato sopra, qui lasciamo l’header a riga corrente
+        $activeWorksheet->setCellValue($colRowTot . $count, 'Totale');
+
+        $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)->getFont()->setBold(true);
+        $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)
+            ->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('F8CBAD'); // rosso chiaro
 
-        $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
+        $count++;
 
-        $count += 1;
+        $parentIdsOut = array_column(array_filter($rows_out, function ($r) {
+            return (int)($r['level'] ?? 0) === 0;
+        }), 'id');
 
-        $totals = [];
+        $totalsOut = [];
 
         foreach ($rows_out as $out) {
-            $activeWorksheet->setCellValue('A' . $count, str_repeat("  ", $out["level"]) . $out["name"]);
+            $activeWorksheet->setCellValue($colA . $count, str_repeat("  ", (int)$out["level"]) . $out["name"]);
+
+            $rowSum = 0.0;
 
             foreach ($columns as $idx => $column) {
+                $col = Coordinate::stringFromColumnIndex($firstDataIdx + $idx);
+
                 if (isset($records_out[$column][$out["id"]])) {
-                    $activeWorksheet->setCellValue($letters[$idx + 1] . $count, formatPrice($records_out[$column][$out["id"]]));
-                    if ($out["level"] == 0) {
-                        if (isset($totals[$idx]))
-                            $totals[$idx] += $records_out[$column][$out["id"]];
-                        else
-                            $totals[$idx] = $records_out[$column][$out["id"]];
+                    $val = (float)$records_out[$column][$out["id"]];
+                    $writeNumber($activeWorksheet, $col . $count, $val);
+
+                    if ($shouldCountFn($out, $parentIdsOut)) {
+                        $totalsOut[$idx] = ($totalsOut[$idx] ?? 0) + $val;
                     }
+
+                    $rowSum += $val;
                 }
             }
 
-            if ($out["level"] == 0) {
-                $activeWorksheet->getStyle('A' . $count . ':N' . $count)->getFont()->setBold(true);
+            $writeNumber($activeWorksheet, $colRowTot . $count, $rowSum);
+
+            if ((int)$out["level"] === 0) {
+                $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)->getFont()->setBold(true);
             }
 
-            $count += 1;
+            $count++;
         }
 
-        $activeWorksheet->setCellValue('A' . $count, 'Totale');
-        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
+        // Riga "Totale" USCITE
+        $activeWorksheet->setCellValue($colA . $count, 'Totale');
+
+        $grandTotalOut = 0.0;
+        foreach ($totalsOut as $idx => $total) {
+            $col = Coordinate::stringFromColumnIndex($firstDataIdx + $idx);
+            $writeNumber($activeWorksheet, $col . $count, (float)$total);
+            $grandTotalOut += (float)$total;
         }
+        $writeNumber($activeWorksheet, $colRowTot . $count, $grandTotalOut);
 
-        $activeWorksheet->getColumnDimension('A')->setWidth(35);
+        $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)->getFont()->setBold(true);
+        $activeWorksheet->getStyle($colA . $count . ':' . $colRowTot . $count)
+            ->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
+            ->getStartColor()->setARGB('F8CBAD');
 
-        for ($i = 1; $i < count($letters); $i++) {
-            $activeWorksheet->getColumnDimension($letters[$i])->setWidth(20);
+        // ========== Larghezze colonne ==========
+        $activeWorksheet->getColumnDimension($colA)->setWidth(35);
+        for ($i = $firstDataIdx; $i <= $rowTotalIdx; $i++) {
+            $activeWorksheet->getColumnDimension(Coordinate::stringFromColumnIndex($i))->setWidth(20);
         }
 
+        // ========== Salvataggio ==========
         $fileSuffix = $isYearExport ? 'AnnoFiscale' : 'Selezione';
         $filename = date("Ymd") . '_Gestionale_' . $fileSuffix . '.xlsx';
 

+ 49 - 20
app/Http/Livewire/Reports.php

@@ -261,6 +261,10 @@ class Reports extends Component
             'causals.parent_id',
             DB::raw('SUM(records_rows.amount) as total_amount')
         )
+            ->where(function ($query) {
+                $query->where('causals.no_reports', '=', '0')
+                    ->orWhereNull('causals.no_reports');
+            })
             ->groupBy('causals.id', 'causals.name', 'causals.parent_id')
             ->orderBy('total_amount', 'desc')
             ->limit($limit)
@@ -443,12 +447,27 @@ class Reports extends Component
             7 => 'Lug',
             8 => 'Ago'
         ];
+        $monthNamesExtended = [
+            0 => 'Settembre',
+            1 => 'Ottobre',
+            2 => 'Novembre',
+            3 => 'Dicembre',
+            4 => 'Gennaio',
+            5 => 'Febbraio',
+            6 => 'Marzo',
+            7 => 'Aprile',
+            8 => 'Maggio',
+            9 => 'Giugno',
+            10 => 'Luglio',
+            11 => 'Agosto'
+        ];
 
         $monthlyData = [];
         foreach ($monthOrder as $i) {
             $monthlyData[$i] = [
                 'earned' => 0,
                 'total' => 0,
+                'suspended' => 0,
                 'participants' => 0
             ];
         }
@@ -486,9 +505,21 @@ class Reports extends Component
                             $monthlyData[$monthNumber]['participants']++;
                             $hasData = true;
 
-                            if (!is_null($rate->record_id) && $rate->record_id !== '') {
+                            // if (!is_null($rate->record_id) && $rate->record_id !== '') {
+                            //     $monthlyData[$monthNumber]['earned'] += $pricePerMonth;
+                            // }
+
+                            // pagamenti effettuati
+                            if (!is_null($rate->record_id) && $rate->record_id !== '' && $rate->status == 1) {
+                                $monthlyData[$monthNumber]['participants']--;
                                 $monthlyData[$monthNumber]['earned'] += $pricePerMonth;
                             }
+                            // pagamenti sospesi
+                            if ($rate->status == 2) {
+                                $monthlyData[$monthNumber]['participants']--;
+                                $monthlyData[$monthNumber]['total'] -= $pricePerMonth;
+                                $monthlyData[$monthNumber]['suspended']++;
+                            }
                         }
                     }
                 }
@@ -516,17 +547,20 @@ class Reports extends Component
             $total = round($monthlyData[$month]['total'], 2);
             $delta = max(0, $total - $earned);
             $participants = $monthlyData[$month]['participants'];
+            $suspended = $monthlyData[$month]['suspended'];
 
             $labels[] = $monthNames[$month];
             $earnedData[] = $earned;
             $totalData[] = $total;
             $participantData[] = $participants;
+            $suspendedData[] = $suspended;
 
             $percentage = $total > 0 ? round(($earned / $total) * 100, 1) : 0;
 
             $tableData[] = [
                 'month' => $monthNames[$month],
                 'participants' => $participants,
+                'suspended' => $suspended,
                 'earned' => $earned,
                 'total' => $total,
                 'delta' => $delta,
@@ -534,32 +568,27 @@ class Reports extends Component
             ];
         }
 
+        $daIncassareData = array_map(function($tot, $inc) {
+            return $tot - $inc;
+        }, $totalData, $earnedData);
+
+
         return [
             'labels' => $labels,
             'datasets' => [
                 [
-                    'label' => 'Pagamenti Effettuati',
-                    'backgroundColor' => 'rgba(16, 185, 129, 0.8)',
-                    'borderColor' => 'rgba(16, 185, 129, 1)',
-                    'borderWidth' => 0,
-                    'borderRadius' => 8,
-                    'borderSkipped' => false,
+                    'label' => 'TOT. INCASSATO',
                     'data' => $earnedData,
-                    'type' => 'bar',
-                    'order' => 2
+                    'participantData' => $participantData,
+                    'suspendedData' => $suspendedData,
+                    'monthNamesExtended' => $monthNamesExtended,
                 ],
                 [
-                    'label' => 'Pagamenti Attesi',
-                    'backgroundColor' => 'transparent',
-                    'backgroundColor' => 'rgba(59, 130, 246, 0.8)',
-                    'borderColor' => 'rgba(59, 130, 246, 1)',
-                    'borderWidth' => 0,
-                    'borderRadius' => 8,
-                    'borderSkippet' => false,
-                    'data' => $totalData,
-                    'type' => 'bar',
-                    'order' => 1,
-                    'participantData' => $participantData
+                    'label' => 'TOT. DA INCASSARE',
+                    'data' => $daIncassareData,
+                    'participantData' => $participantData,
+                    'suspendedData' => $suspendedData,
+                    'monthNamesExtended' => $monthNamesExtended,
                 ]
             ],
             'tableData' => $tableData,

+ 7 - 0
app/Http/Livewire/Supplier.php

@@ -124,6 +124,7 @@ class Supplier extends Component
         $this->emit('load-select');
         $this->add = true;
         $this->update = false;
+        $this->emit('setEdit', true);
     }
 
     private function cleanEmptyFields()
@@ -192,6 +193,7 @@ class Supplier extends Component
             session()->flash('success', 'Fornitore creato');
             $this->resetFields();
             $this->add = false;
+            $this->emit('setEdit', false);
         } catch (\Exception $ex) {
             session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
         }
@@ -229,6 +231,8 @@ class Supplier extends Component
                 $this->checkIsItaly();
                 $this->emit('load-provinces', $this->nation_id, 'provinceClass');
                 $this->emit('load-cities', $this->province_id, 'cityClass');
+
+                $this->emit('setEdit', true);
             }
         } catch (\Exception $ex) {
             session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
@@ -240,6 +244,7 @@ class Supplier extends Component
         $this->add = false;
         $this->update = false;
         $this->resetFields();
+        $this->emit('setEdit', false);
     }
 
     // Replace delete method with anonymize method
@@ -345,6 +350,8 @@ class Supplier extends Component
             session()->flash('success', 'Fornitore aggiornato');
             $this->resetFields();
             $this->update = false;
+
+            $this->emit('setEdit', false);
         } catch (\Exception $ex) {
             session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
         }

+ 13 - 4
app/Http/Livewire/User.php

@@ -19,12 +19,14 @@ class User extends Component
 
     public $records, $name, $cognome, $email, $password, $oldPassword, $level, $enabled, $dataId, $update = false, $add = false, $oldEmail = null;
     public $userExists = false;
+    public $password_confirmation;
 
     protected $rules = [
         'name' => 'required',
         'cognome' => 'required',
         'email' => 'required',
-        'password' => 'required'
+        'password' => 'required',
+        'password_confirmation' => 'required|same:password'
     ];
 
     protected $messages = [
@@ -32,6 +34,8 @@ class User extends Component
         'cognome.required' => 'Il cognome è obbligatorio',
         'email.required' => 'La mail è obbligatoria',
         'password.required' => 'La password è obbligatoria',
+        'password_confirmation.required' => 'Ripeti la password inserita',
+        'password_confirmation.same' => 'Le password non coincidono',
     ];
 
     /**
@@ -329,6 +333,7 @@ class User extends Component
         $this->cognome = '';
         $this->email = '';
         $this->password = '';
+        $this->password_confirmation = '';
         $this->oldPassword = '';
         $this->level = 0;
         $this->enabled = true;
@@ -383,7 +388,8 @@ class User extends Component
             'name' => 'required',
             'cognome' => 'required',
             'email' => 'required|email|unique:users,email',
-            'password' => 'required|min:6'
+            'password' => 'required|min:6',
+            'password_confirmation' => 'required|same:password'
         ];
 
         $messages = [
@@ -393,7 +399,9 @@ class User extends Component
             'email.email' => 'La mail deve essere un indirizzo valido',
             'email.unique' => 'Questa mail è già stata utilizzata',
             'password.required' => 'La password è obbligatoria',
-            'password.min' => 'La password deve essere di almeno 6 caratteri'
+            'password.min' => 'La password deve essere di almeno 6 caratteri',
+            'password_confirmation.required' => 'Ripeti la password inserita',
+            'password_confirmation.same' => 'Le password non coincidono',
         ];
 
         $this->validate($rules, $messages);
@@ -546,7 +554,8 @@ class User extends Component
             'name' => 'required',
             'cognome' => 'required',
             'email' => 'required|email',
-            'password' => 'nullable|min:6'
+            'password' => 'nullable|min:6',
+            'password_confirmation' => 'required|same:password'
         ];
 
         $this->validate($rules, $this->messages);

+ 74 - 0
app/Jobs/SendEmailMessage.php

@@ -0,0 +1,74 @@
+<?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;
+
+class SendEmailMessage implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public function __construct(public int $messageId) {}
+
+    public function handle()
+    {
+        $msg = \App\Models\EmailMessage::with(['recipients', 'attachments'])->findOrFail($this->messageId);
+
+        $msg->update(['status' => 'processing']);
+        try {
+            $attachments = $msg->attachments
+                ->map(fn($a) => [
+                    'disk' => $a->disk,
+                    'path' => $a->path,
+                    'name' => $a->name ?? basename($a->path),
+                ])
+                ->values()->all();
+        } catch (\Throwable $e) {
+            $msg->update(['status' => 'failed', 'error_message' => $e->getMessage()]);
+        }
+
+
+        foreach ($msg->recipients as $r) {
+            if (in_array($r->status, ['sent', 'failed'])) continue;
+
+            try {
+
+                $mailable = new \App\Mail\GenericMail(
+                    $msg->subject,
+                    $msg->content_html,
+                    $attachments
+                );
+
+                Mail::to($r->email_address)->send($mailable);
+
+                $r->update(['status' => 'sent', 'sent_at' => now(), 'error_message' => null]);
+            } catch (\Throwable $e) {
+                $r->update(['status' => 'failed', 'error_message' => $e->getMessage()]);
+            }
+        }
+        $total = $msg->recipients()->count();
+        $sent = $msg->recipients()->where('status', 'sent')->count();
+        $failed = $msg->recipients()->where('status', 'failed')->count();
+
+        $newStatus = 'draft';
+        if ($total === 0) {
+            $newStatus = 'draft';
+        } elseif ($sent === $total) {
+            $newStatus = 'sent';
+        } elseif ($sent > 0 && $failed > 0) {
+            $newStatus = 'partial';
+        } else {
+            $newStatus = 'failed';
+        }
+
+        $msg->update([
+            'status' => $newStatus,
+            'sent_at' => $sent > 0 ? now() : $msg->sent_at,
+        ]);
+    }
+}

+ 58 - 0
app/Mail/GenericMail.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Collection;
+
+class GenericMail extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    protected array $filesData = [];
+
+    public function __construct(string $subjectLine, string $html, iterable $attachments = [])
+    {
+        $this->subject = $subjectLine;
+        $this->html = $html;
+
+        $items = $attachments instanceof Collection ? $attachments->all()
+            : (is_array($attachments) ? $attachments : []);
+
+        $this->filesData = array_values(array_map(function ($a) {
+            if (is_object($a)) {
+                $disk = $a->disk ?? null;
+                $path = $a->path ?? null;
+                $name = $a->name ?? null;
+            } else {
+                $disk = $a['disk'] ?? null;
+                $path = $a['path'] ?? null;
+                $name = $a['name'] ?? null;
+            }
+            return [
+                'disk' => $disk,
+                'path' => $path,
+                'name' => $name ?? ($path ? basename($path) : null),
+            ];
+        }, $items));
+    }
+
+    public function build()
+    {
+        $mail = $this->subject($this->subject)
+            ->html($this->html);
+
+        foreach ($this->filesData as $att) {
+            $disk = $att['disk'] ?? null;
+            $path = $att['path'] ?? null;
+            $name = $att['name'] ?? ($path ? basename($path) : null);
+            if ($disk && $path) {
+                $mail->attachFromStorageDisk($disk, $path, $name);
+            }
+        }
+
+        return $mail;
+    }
+}

+ 42 - 0
app/Models/Azienda.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Validator;
 
 class Azienda extends Model
 {
@@ -45,6 +46,47 @@ class Azienda extends Model
         'scadenza_pagamenti_uscita' => 'date',
     ];
 
+    /**
+     * Valida i campi richiesti prima della generazione del PDF.
+     *
+     * @return array|bool  true se valido, oppure array di errori se non valido
+     */
+    public function validate()
+    {
+        $rules = [
+            'ragione_sociale' => 'required|string|max:255',
+            'email' => 'required|email|max:255',
+            'pec' => 'required|email|max:255',
+            'cellulare' => 'required|string|max:20',
+        ];
+
+        $rules_human = [
+            'ragione_sociale' => "Ragione sociale",
+            'email' => "Email",
+            'pec' => "Pec",
+            'cellulare' => "Cellulare",
+        ];
+
+        $validator = Validator::make($this->attributesToArray(), $rules);
+
+        if ($validator->fails()) {
+            $errors = [];
+            foreach ($validator->errors()->messages() as $field => $error) {
+                $errors[$field] = isset($rules_human[$field]) ? $rules_human[$field] : $field;
+            }
+            return $errors;
+        }
+
+        return true;
+    }
+
+    /**
+     * Restituisce true se l’azienda è valida (tutti i campi richiesti presenti).
+     */
+    public function isValid()
+    {
+        return $this->validate() === true;
+    }
 
     /**
      * Get the logo URL attribute.

+ 2 - 1
app/Models/Causal.php

@@ -20,7 +20,8 @@ class Causal extends Model
         'user_status',
         'no_first',
         'no_records',
-        'enabled'
+        'enabled',
+        'no_reports',
     ];
 
 

+ 73 - 0
app/Models/EmailMessage.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+
+class EmailMessage extends Model
+{
+    protected $fillable = [
+        'subject',
+        'content_html',
+        'status',
+        'schedule_at',
+        'sent_at',
+        'created_by'
+    ];
+
+    protected $casts = [
+        'schedule_at' => 'datetime',
+        'sent_at' => 'datetime',
+    ];
+
+    public function recipients()
+    {
+        return $this->hasMany(EmailMessageRecipient::class);
+    }
+    public function attachments()
+    {
+        return $this->hasMany(EmailMessageAttachment::class);
+    }
+    public function creator()
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function duplicate(bool $withRecipients = false)
+    {
+        return DB::transaction(function () use ($withRecipients) {
+            $copy = $this->replicate(['status', 'schedule_at', 'sent_at']);
+            $copy->status = 'draft';
+            $copy->schedule_at = null;
+            $copy->sent_at = null;
+            $copy->save();
+
+            foreach ($this->attachments as $a) {
+                $copy->attachments()->create($a->only(['disk', 'path', 'name', 'size']));
+            }
+
+            if ($withRecipients) {
+                $payload = $this->recipients()
+                    ->get(['member_id', 'email_address'])
+                    ->map(fn($r) => [
+                        'member_id' => $r->member_id,
+                        'email_address' => $r->email_address,
+                        'status' => 'pending',
+                    ])
+                    ->all();
+
+                if ($payload) {
+                    $copy->recipients()->createMany($payload);
+                }
+            }
+
+            return $copy;
+        });
+    }
+
+    public function isLocked(): bool
+    {
+        return in_array($this->status, ['processing', 'sent']);
+    }
+}

+ 38 - 0
app/Models/EmailMessageAttachment.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Storage;
+
+class EmailMessageAttachment extends Model
+{
+    protected $fillable = ['email_message_id', 'disk', 'path', 'name', 'size'];
+    protected $appends = ['public_url', 'is_image', 'size_human'];
+
+    public function message()
+    {
+        return $this->belongsTo(EmailMessage::class);
+    }
+
+    public function getPublicUrlAttribute(): ?string
+    {
+        if (!$this->disk || !$this->path) return null;
+        return Storage::disk($this->disk)->url($this->path);
+    }
+
+    public function getIsImageAttribute(): bool
+    {
+        $ext = strtolower(pathinfo($this->name ?: $this->path, PATHINFO_EXTENSION));
+        return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
+    }
+
+    public function getSizeHumanAttribute(): ?string
+    {
+        if (!$this->size) return null;
+        $kb = $this->size / 1024;
+        return $kb >= 1024
+            ? number_format($kb / 1024, 2) . ' MB'
+            : number_format($kb, 1) . ' KB';
+    }
+}

+ 28 - 0
app/Models/EmailMessageRecipient.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class EmailMessageRecipient extends Model
+{
+    protected $fillable = [
+        'email_message_id',
+        'member_id',
+        'email_address',
+        'status',
+        'error_message',
+        'sent_at'
+    ];
+
+    protected $casts = ['sent_at' => 'datetime'];
+
+    public function message()
+    {
+        return $this->belongsTo(EmailMessage::class);
+    }
+    public function member()
+    {
+        return $this->belongsTo(Member::class);
+    }
+}

+ 60 - 0
app/Models/EmailRecipients.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class EmailRecipients extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'email_template_id',
+        'member_id',
+        'email_address',
+        'status',
+        'error_message',
+        'sent_at'
+    ];
+
+    protected $casts = [
+        'sent_at' => 'datetime',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function templateEmail()
+    {
+        return $this->belongsTo(EmailTemplate::class, 'email_template_id');
+    }
+
+    public function member()
+    {
+        return $this->belongsTo(Member::class, 'member_id');
+    }
+
+    public function markAsSent()
+    {
+        $this->update([
+            'status' => 'sent',
+            'sent_at' => now()
+        ]);
+    }
+
+    public function markAsFailed($errorMessage = null)
+    {
+        $this->update([
+            'status' => 'failed',
+            'error_message' => $errorMessage
+        ]);
+    }
+
+    public function markAsBounced($errorMessage = null)
+    {
+        $this->update([
+            'status' => 'bounced',
+            'error_message' => $errorMessage
+        ]);
+    }
+}

+ 1 - 1
app/Models/EmailScheduled.php

@@ -39,7 +39,7 @@ class EmailScheduled extends Model
 
     public function recipients()
     {
-        return $this->belongsToMany(User::class, 'email_scheduled_recipients', 'email_scheduled_id', 'user_id')
+        return $this->belongsToMany(Member::class, 'email_scheduled_recipients', 'email_scheduled_id', 'member_id')
                     ->withPivot(['email_address', 'status', 'error_message', 'sent_at'])
                     ->withTimestamps();
     }

+ 6 - 1
app/Models/EmailTemplate.php

@@ -11,7 +11,7 @@ class EmailTemplate extends Model
     protected $fillable = [
         'name',
         'content',
-        'created_by'
+        'created_by',
     ];
 
     protected $casts = [
@@ -24,6 +24,11 @@ class EmailTemplate extends Model
         return $this->belongsTo(User::class, 'created_by');
     }
 
+    public function attachments()
+    {
+        return $this->hasMany(EmailTemplateAttachment::class, 'attachments');
+    }
+
     public function scheduledEmails()
     {
         return $this->hasMany(EmailScheduled::class, 'template_id');

+ 28 - 0
app/Models/EmailTemplateAttachment.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class EmailTemplateAttachment extends Model
+{
+    use HasFactory;
+    
+    protected $fillable = [
+        'template_id',
+        'name',
+        'path',
+        'size',
+    ];
+
+    protected $casts = [
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function template()
+    {
+        return $this->belongsTo(EmailTemplate::class, 'template_id');
+    }
+}

+ 38 - 0
database/migrations/2025_09_23_195123_create_email_attachments_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Database\Migrations\TenantMigration;
+
+return new class extends TenantMigration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('email_attachments', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('template_id')->nullable();
+            $table->string('name');
+            $table->string('path');
+            $table->integer('size');
+            $table->timestamps();
+
+            $table->foreign('template_id')->references('id')->on('email_templates')->onDelete('set null');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('email_attachments');
+    }
+};

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

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Database\Migrations\TenantMigration;
+
+return new class extends TenantMigration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('causals', function (Blueprint $table) {
+            $table->boolean('no_reports')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('causals', function (Blueprint $table) {
+            $table->dropColumn('no_reports');
+        });
+    }
+};

+ 47 - 0
database/migrations/2025_10_01_144330_rename_user_id_to_member_id_in_email_scheduled_recipients_table.php

@@ -0,0 +1,47 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Database\Migrations\TenantMigration;
+
+return new class extends TenantMigration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('email_scheduled_recipients', function (Blueprint $table) {
+            if (Schema::hasColumn('email_scheduled_recipients', 'user_id')) {
+                $table->dropForeign(['user_id']);
+                $table->renameColumn('user_id', 'member_id');
+            }
+
+            if (!Schema::hasColumn('email_scheduled_recipients', 'member_id')) {
+                $table->unsignedBigInteger('member_id')->after('email_scheduled_id');
+            }
+
+            $table->foreign('member_id')
+                ->references('id')->on('members')
+                ->cascadeOnDelete();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('email_scheduled_recipients', function (Blueprint $table) {
+            $table->dropForeign(['member_id']);
+            $table->renameColumn('member_id', 'user_id');
+
+            $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
+        });
+    }
+};

+ 41 - 0
database/migrations/2025_10_02_071721_create_email_recipients_table.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Database\Migrations\TenantMigration;
+
+return new class extends TenantMigration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('email_recipients', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('email_template_id');
+            $table->unsignedBigInteger('member_id');
+            $table->string('email_address');
+            $table->enum('status', ['pending', 'sent', 'failed', 'bounced'])->default('pending');
+            $table->text('error_message')->nullable();
+            $table->timestamp('sent_at')->nullable();
+            $table->timestamps();
+
+            $table->foreign('email_template_id')->references('id')->on('email_templates')->onDelete('cascade');
+            $table->foreign('member_id')->references('id')->on('members');
+            $table->unique(['email_template_id', 'member_id']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('email_recipients');
+    }
+};

+ 53 - 0
database/migrations/2025_10_18_135607_create_email_messages.php

@@ -0,0 +1,53 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Database\Migrations\TenantMigration;
+
+return new class extends TenantMigration
+{
+    public function up()
+    {
+        Schema::create('email_messages', function (Blueprint $t) {
+            $t->id();
+            $t->string('subject');
+            $t->longText('content_html');
+            $t->enum('status', ['draft', 'scheduled', 'processing', 'sent', 'failed', 'canceled'])->default('draft')->index();
+            $t->dateTime('schedule_at')->nullable()->index();
+            $t->dateTime('sent_at')->nullable();
+            $t->foreignId('created_by')->constrained('users')->cascadeOnDelete();
+            $t->timestamps();
+        });
+
+        Schema::create('email_message_recipients', function (Blueprint $t) {
+            $t->id();
+            $t->foreignId('email_message_id')->constrained('email_messages')->cascadeOnDelete();
+            $t->foreignId('member_id')->nullable()->constrained('members')->nullOnDelete();
+            $t->string('email_address');
+            $t->enum('status', ['pending', 'sent', 'failed', 'bounced', 'skipped'])->default('pending')->index();
+            $t->text('error_message')->nullable();
+            $t->dateTime('sent_at')->nullable();
+            $t->timestamps();
+
+            $t->index(['email_message_id', 'status']);
+        });
+
+        Schema::create('email_message_attachments', function (Blueprint $t) {
+            $t->id();
+            $t->foreignId('email_message_id')->constrained('email_messages')->cascadeOnDelete();
+            $t->string('disk')->default('public');
+            $t->string('path');
+            $t->string('name');
+            $t->unsignedBigInteger('size')->nullable();
+            $t->timestamps();
+        });
+    }
+
+    public function down()
+    {
+        Schema::dropIfExists('email_message_attachments');
+        Schema::dropIfExists('email_message_recipients');
+        Schema::dropIfExists('email_messages');
+    }
+};

BIN
public/apple-touch-icon.png


+ 333 - 0
public/assets/js/datatable_export_action.js

@@ -0,0 +1,333 @@
+function colIdxToLetter(n) {
+    let s = "";
+    n = n + 1;
+    while (n > 0) {
+        const m = (n - 1) % 26;
+        s = String.fromCharCode(65 + m) + s;
+        n = Math.floor((n - m) / 26);
+    }
+    return s;
+}
+
+function escapeXmlText(txt) {
+    return String(txt ?? "")
+        .replace(/&/g, "&amp;")
+        .replace(/</g, "&lt;")
+        .replace(/>/g, "&gt;");
+}
+
+function readCellText($cell, sheet, xlsx) {
+    const t = $cell.attr("t");
+    if (t === "inlineStr") {
+        const tNode = $cell.find("is t");
+        return tNode.length ? tNode.text() : "";
+    }
+    if (t === "s") {
+        const v = $cell.find("v").text();
+        const idx = parseInt(v, 10);
+        const sst = xlsx.xl["sharedStrings.xml"];
+        if (!sst) return "";
+        const si = $("sst si", sst).eq(idx);
+        if (!si.length) return "";
+
+        let out = "";
+        si.find("t").each(function () {
+            out += $(this).text();
+        });
+        return out;
+    }
+
+    const vNode = $cell.find("v");
+    return vNode.length ? vNode.text() : "";
+}
+
+function newexportaction(e, dt, button, config, cb) {
+    const self = this;
+    const settings = dt.settings()[0];
+    const isServerSide = settings.oFeatures && settings.oFeatures.bServerSide;
+    const hasAjax = !!settings.ajax;
+
+    const customizeHeaderExcel = function (xlsx) {
+        const sheet = xlsx.xl.worksheets["sheet1.xml"];
+        const styles = xlsx.xl["styles.xml"];
+
+        // --- crea background azzurro ---
+        const fills = $("fills", styles);
+        fills.append(
+            '<fill><patternFill patternType="solid">' +
+                '<fgColor rgb="FFDDEBF7"/><bgColor indexed="64"/>' +
+                "</patternFill></fill>"
+        );
+        const fillId = $("fill", styles).length - 1;
+        fills.attr("count", $("fill", styles).length); // aggiorna count
+
+        // --- crea FONT bold nero ---
+        const fonts = $("fonts", styles);
+        fonts.append(
+            '<font><b/><sz val="11"/><color rgb="FF000000"/><name val="Calibri"/></font>'
+        );
+        const fontId = $("font", styles).length - 1;
+        fonts.attr("count", $("font", styles).length); // aggiorna count
+
+        // --- crea XF senza bordi, bold + fill azzurro ---
+        const cellXfs = $("cellXfs", styles);
+        cellXfs.append(
+            `<xf xfId="0" applyFont="1" applyFill="1" borderId="0" fontId="${fontId}" fillId="${fillId}">
+                    <alignment vertical="center"/>
+                </xf>`
+        );
+        const xfId = $("cellXfs xf", styles).length - 1;
+        cellXfs.attr("count", $("cellXfs xf", styles).length); // aggiorna count
+
+        // --- applica lo stile alla PRIMA riga (header) ---
+        $("row:first c", sheet).attr("s", xfId);
+    };
+
+    const alignLeftCells = function (xlsx, config) {
+        let leftCols = Array.isArray(config.exportLeftCols)
+            ? [...config.exportLeftCols]
+            : [];
+        if (leftCols.length === 0 && dt && dt.columns) {
+            const headerIdxs = [];
+            $(dt.columns().header()).each(function (i, th) {
+                if ($(th).hasClass("export-left")) headerIdxs.push(i);
+            });
+            leftCols = headerIdxs;
+        }
+
+        if (leftCols.length) {
+            const sheet = xlsx.xl.worksheets["sheet1.xml"];
+            const styles = xlsx.xl["styles.xml"];
+
+            const cellXfs = $("cellXfs", styles);
+            cellXfs.append(
+                `<xf xfId="0" applyAlignment="1">
+                        <alignment horizontal="left" vertical="center"/>
+                    </xf>`
+            );
+            const leftXfId = $("cellXfs xf", styles).length - 1;
+            cellXfs.attr("count", $("cellXfs xf", styles).length);
+
+            leftCols.forEach(function (colIdx) {
+                const colLetter = colIdxToLetter(colIdx);
+
+                $('row:not(:first) c[r^="' + colLetter + '"]', sheet).each(
+                    function () {
+                        const cell = $(this);
+                        const sAttr = cell.attr("s");
+                        if (sAttr === undefined) {
+                            cell.attr("s", leftXfId);
+                        } else {
+                            const xfs = $("cellXfs xf", styles);
+                            const baseXf = xfs.eq(parseInt(sAttr, 10)).clone();
+                            if (baseXf.find("alignment").length === 0) {
+                                baseXf.append(
+                                    '<alignment horizontal="left" vertical="center"/>'
+                                );
+                            } else {
+                                baseXf
+                                    .find("alignment")
+                                    .attr("horizontal", "left")
+                                    .attr("vertical", "center");
+                            }
+                            cellXfs.append(baseXf);
+
+                            const newId = $("cellXfs xf", styles).length - 1;
+                            cellXfs.attr(
+                                "count",
+                                $("cellXfs xf", styles).length
+                            );
+                            cell.attr("s", newId);
+                        }
+                    }
+                );
+            });
+        }
+    };
+
+    const forceAllCellsAsText = function (xlsx) {
+        const sheet = xlsx.xl.worksheets["sheet1.xml"];
+        if (!sheet) return;
+
+        $("worksheet sheetData row c", sheet).each(function () {
+            const $cell = $(this);
+            const text = readCellText($cell, sheet, xlsx);
+            // sostituisco contenuto con inline string
+            $cell.attr("t", "inlineStr");
+            $cell.find("v").remove();
+            $cell.find("is").remove();
+            const safe = escapeXmlText(text);
+            $cell.append(`<is><t>${safe}</t></is>`);
+            // NON tocco l’attributo 's' (stile) se già esiste
+        });
+    };
+
+    // --- CLIENT-SIDE ---
+    if (!isServerSide || !hasAjax) {
+        // Esporta tutte le righe lato client
+        const origExportOptions = config.exportOptions
+            ? { ...config.exportOptions }
+            : {};
+        config.exportOptions = config.exportOptions || {};
+        config.exportOptions.modifier = {
+            ...(config.exportOptions.modifier || {}),
+            page: "all",
+        };
+
+        // PDF: tabella full-width
+        // const isPdf = button[0].className.indexOf('buttons-pdf') >= 0;
+        // const origCustomize = config.customize;
+        // if (isPdf) {
+        //     config.customize = function(doc) {
+        //         const table = doc.content[1] && doc.content[1].table ? doc.content[1].table : null;
+        //         if (table && table.body && table.body[0]) {
+        //             table.widths = Array(table.body[0].length).fill('*');
+        //         }
+        //         if (typeof origCustomize === 'function') origCustomize(doc);
+        //     };
+        // }
+
+        const cls = button[0].className;
+
+        if (cls.includes("buttons-copy")) {
+            $.fn.dataTable.ext.buttons.copyHtml5.action.call(
+                self,
+                e,
+                dt,
+                button,
+                config,
+                cb
+            );
+        } else if (cls.includes("buttons-excel")) {
+            const origCustomize = config.customize;
+            config.filename = config.title;
+            config.title = null;
+            config.customize = function (xlsx) {
+                if (typeof origCustomize === "function") origCustomize(xlsx);
+                customizeHeaderExcel(xlsx);
+                alignLeftCells(xlsx, config);
+                forceAllCellsAsText(xlsx);
+            };
+
+            ($.fn.dataTable.ext.buttons.excelHtml5.available(dt, config)
+                ? $.fn.dataTable.ext.buttons.excelHtml5
+                : $.fn.dataTable.ext.buttons.excelFlash
+            ).action.call(self, e, dt, button, config, cb);
+
+            config.customize = origCustomize; // ripristina
+        } else if (cls.includes("buttons-csv")) {
+            ($.fn.dataTable.ext.buttons.csvHtml5.available(dt, config)
+                ? $.fn.dataTable.ext.buttons.csvHtml5
+                : $.fn.dataTable.ext.buttons.csvFlash
+            ).action.call(self, e, dt, button, config, cb);
+        } else if (cls.includes("buttons-pdf")) {
+            ($.fn.dataTable.ext.buttons.pdfHtml5.available(dt, config)
+                ? $.fn.dataTable.ext.buttons.pdfHtml5
+                : $.fn.dataTable.ext.buttons.pdfFlash
+            ).action.call(self, e, dt, button, config, cb);
+        } else if (cls.includes("buttons-print")) {
+            $.fn.dataTable.ext.buttons.print.action.call(
+                self,
+                e,
+                dt,
+                button,
+                config,
+                cb
+            );
+        }
+
+        // Ripristina config
+        config.exportOptions = origExportOptions;
+        // if (isPdf) config.customize = origCustomize;
+
+        if (typeof cb === "function") cb();
+        return;
+    }
+
+    // --- SERVER-SIDE + AJAX: fetch completo poi export ---
+    const info = dt.page.info();
+    const oldStart = info.start;
+    const oldLength = info.length;
+    const targetLength = info.recordsTotal > 0 ? info.recordsTotal : 2147483647;
+
+    dt.one("preXhr", function (e2, s, data) {
+        data.start = 0;
+        data.length = targetLength;
+
+        dt.one("preDraw", function (e3, stg) {
+            const cls = button[0].className;
+
+            if (cls.includes("buttons-copy")) {
+                $.fn.dataTable.ext.buttons.copyHtml5.action.call(
+                    self,
+                    e,
+                    dt,
+                    button,
+                    config,
+                    cb
+                );
+            } else if (cls.includes("buttons-excel")) {
+                const origCustomize = config.customize;
+                config.filename = config.title;
+                config.title = null;
+                config.customize = function (xlsx) {
+                    if (typeof origCustomize === "function")
+                        origCustomize(xlsx);
+                    customizeHeaderExcel(xlsx);
+                    alignLeftCells(xlsx, config);
+                    forceAllCellsAsText(xlsx);
+                };
+
+                ($.fn.dataTable.ext.buttons.excelHtml5.available(dt, config)
+                    ? $.fn.dataTable.ext.buttons.excelHtml5
+                    : $.fn.dataTable.ext.buttons.excelFlash
+                ).action.call(self, e, dt, button, config, cb);
+
+                config.customize = origCustomize;
+            } else if (cls.includes("buttons-csv")) {
+                ($.fn.dataTable.ext.buttons.csvHtml5.available(dt, config)
+                    ? $.fn.dataTable.ext.buttons.csvHtml5
+                    : $.fn.dataTable.ext.buttons.csvFlash
+                ).action.call(self, e, dt, button, config, cb);
+            } else if (cls.includes("buttons-pdf")) {
+                // Full-width PDF
+                // const origCustomize = config.customize;
+                // config.customize = function(doc) {
+                //     const t = doc.content[1] && doc.content[1].table ? doc.content[1].table : null;
+                //     if (t && t.body && t.body[0]) t.widths = Array(t.body[0].length).fill('*');
+                //     if (typeof origCustomize === 'function') origCustomize(doc);
+                // };
+                ($.fn.dataTable.ext.buttons.pdfHtml5.available(dt, config)
+                    ? $.fn.dataTable.ext.buttons.pdfHtml5
+                    : $.fn.dataTable.ext.buttons.pdfFlash
+                ).action.call(self, e, dt, button, config, cb);
+                // config.customize = origCustomize;
+            } else if (cls.includes("buttons-print")) {
+                $.fn.dataTable.ext.buttons.print.action.call(
+                    self,
+                    e,
+                    dt,
+                    button,
+                    config,
+                    cb
+                );
+            }
+
+            dt.one("preXhr", function (e4, s2, data2) {
+                data2.start = oldStart;
+                data2.length = oldLength;
+                stg._iDisplayStart = oldStart;
+            });
+
+            setTimeout(() => {
+                dt.ajax.reload(() => {
+                    if (typeof cb === "function") cb();
+                }, false);
+            }, 0);
+
+            return false;
+        });
+    });
+
+    dt.ajax.reload();
+}

+ 55 - 0
public/assets/libraries/ckeditor5/LICENSE-ckeditor5.md

@@ -0,0 +1,55 @@
+Software License Agreement
+==========================
+
+**CKEditor&nbsp;5** (https://github.com/ckeditor/ckeditor5)<br>
+Copyright (c) 2003–2025, [CKSource Holding sp. z o.o.](https://cksource.com) All rights reserved.
+
+Licensed under a dual-license model, this software is available under:
+
+* the [GNU General Public License Version 2 or later](https://www.gnu.org/licenses/gpl.html) (see COPYING.GPL),
+* or commercial license terms from CKSource Holding sp. z o.o.
+
+For more information, see: [https://ckeditor.com/legal/ckeditor-licensing-options](https://ckeditor.com/legal/ckeditor-licensing-options).
+
+If you are using CKEditor under commercial terms, you are free to remove the COPYING.GPL file with the full copy of a GPL license.
+
+Sources of Intellectual Property Included in CKEditor
+-----------------------------------------------------
+
+Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission.
+
+The following libraries are included in CKEditor under the [MIT license](https://opensource.org/licenses/MIT):
+
+* @types/color-convert - Copyright (c) DefinitelyTyped.
+* blurhash - Copyright (c) Wolt Enterprises.
+* color-convert - Copyright (c) 2011–2016 Heather Arthur <fayearthur@gmail.com>, copyright (c) 2016–2021 Josh Junon <josh@junon.me>.
+* color-parse - Copyright (c) 2015 Dmitry Ivanov.
+* emojibase-data - Copyright (c) 2017-2019 Miles Johnson.
+* es-toolkit - Copyright (c) 2024 Viva Republica, Inc.
+* fuzzysort - Copyright (c) 2018 Stephen Kamenar.
+* is-emoji-supported - Copyright (c) 2016-2020 Koala Interactive, Inc.
+* vanilla-colorful - Copyright (c) 2020 Serhii Kulykov <iamkulykov@gmail.com>.
+* Regular Expression for URL validation - Copyright (c) 2010-2018 Diego Perini.
+* @types/hast - Copyright (c) Microsoft Corporation.
+* hast-util-to-html - Copyright (c) Titus Wormer <tituswormer@gmail.com>
+* hast-util-to-mdast - Copyright (c) Titus Wormer <tituswormer@gmail.com> and Copyright (c) Seth Vincent <sethvincent@gmail.com>
+* hastscript - Copyright (c) Titus Wormer <tituswormer@gmail.com>
+* rehype-remark - Copyright (c) Titus Wormer <tituswormer@gmail.com>
+* remark-breaks - Copyright (c) 2017 Titus Wormer <tituswormer@gmail.com>
+* remark-gfm - Copyright (c) Titus Wormer <tituswormer@gmail.com>
+* remark-parse - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>
+* remark-rehype - Copyright (c) Titus Wormer <tituswormer@gmail.com>
+* remark-stringify - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>
+* unified - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>
+* unist-util-visit - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>
+
+The following libraries are included in CKEditor under the [ISC license](https://opensource.org/license/isc-license-txt):
+
+* hast-util-from-dom - Copyright (c) Keith McKnight <keith@mcknig.ht>
+* rehype-dom-parse - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>
+* rehype-dom-stringify - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>
+
+Trademarks
+----------
+
+**CKEditor** is a trademark of [CKSource Holding sp. z o.o.](https://cksource.com) All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/ckeditor5-content.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/ckeditor5-editor.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/ckeditor5.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/assets/libraries/ckeditor5/ckeditor5.css.map


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/ckeditor5.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/assets/libraries/ckeditor5/ckeditor5.js.map


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 10 - 0
public/assets/libraries/ckeditor5/ckeditor5.umd.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
public/assets/libraries/ckeditor5/ckeditor5.umd.js.map


+ 289 - 0
public/assets/libraries/ckeditor5/config.js

@@ -0,0 +1,289 @@
+/**
+ * This configuration was generated using the CKEditor 5 Builder. You can modify it anytime using this link:
+ * https://ckeditor.com/ckeditor-5/builder/#installation/NoRgLANARATAdAdjgVitEAGMA2AzNgDgE5CQFlcFcZwzcCxkZkQRiRciMbmiWY0UAJYAXNBgigIIabJkYAuugAmAUwIAjTFAVA==
+ */
+
+import {
+	Alignment,
+	Autoformat,
+	AutoImage,
+	AutoLink,
+	Autosave,
+	BalloonToolbar,
+	Bold,
+	Essentials,
+	FontBackgroundColor,
+	FontColor,
+	FontFamily,
+	FontSize,
+	GeneralHtmlSupport,
+	Heading,
+	ImageEditing,
+	ImageInline,
+	ImageInsert,
+	ImageInsertViaUrl,
+	ImageResize,
+	ImageStyle,
+	ImageTextAlternative,
+	ImageToolbar,
+	ImageUpload,
+	ImageUtils,
+	Indent,
+	IndentBlock,
+	Italic,
+	Link,
+	List,
+	ListProperties,
+	Paragraph,
+	PasteFromOffice,
+	PlainTableOutput,
+	RemoveFormat,
+	SimpleUploadAdapter,
+	Strikethrough,
+	Style,
+	Table,
+	TableCaption,
+	TableCellProperties,
+	TableColumnResize,
+	TableLayout,
+	TableProperties,
+	TableToolbar,
+	TextTransformation,
+	Underline
+} from 'ckeditor5';
+
+import translations from 'ckeditor5/translations/it.js';
+
+/**
+ * Create a free account with a trial: https://portal.ckeditor.com/checkout?plan=free
+ */
+const LICENSE_KEY = 'GPL'; // or <YOUR_LICENSE_KEY>.
+
+const editorConfig = {
+	toolbar: {
+		items: [
+			'undo',
+			'redo',
+			'|',
+			'heading',
+			'style',
+			'|',
+			'fontSize',
+			'fontFamily',
+			'fontColor',
+			'fontBackgroundColor',
+			'|',
+			'bold',
+			'italic',
+			'underline',
+			'|',
+			'link',
+			'insertImage',
+			'insertTable',
+			'insertTableLayout',
+			'|',
+			'alignment',
+			'|',
+			'bulletedList',
+			'numberedList',
+			'outdent',
+			'indent'
+		],
+		shouldNotGroupWhenFull: false
+	},
+	plugins: [
+		Alignment,
+		Autoformat,
+		AutoImage,
+		AutoLink,
+		Autosave,
+		BalloonToolbar,
+		Bold,
+		Essentials,
+		FontBackgroundColor,
+		FontColor,
+		FontFamily,
+		FontSize,
+		GeneralHtmlSupport,
+		Heading,
+		ImageEditing,
+		ImageInline,
+		ImageInsert,
+		ImageInsertViaUrl,
+		ImageResize,
+		ImageStyle,
+		ImageTextAlternative,
+		ImageToolbar,
+		ImageUpload,
+		ImageUtils,
+		Indent,
+		IndentBlock,
+		Italic,
+		Link,
+		List,
+		ListProperties,
+		Paragraph,
+		PasteFromOffice,
+		PlainTableOutput,
+		RemoveFormat,
+		SimpleUploadAdapter,
+		Strikethrough,
+		Style,
+		Table,
+		TableCaption,
+		TableCellProperties,
+		TableColumnResize,
+		TableLayout,
+		TableProperties,
+		TableToolbar,
+		TextTransformation,
+		Underline
+	],
+	balloonToolbar: ['bold', 'italic', '|', 'link', 'insertImage', '|', 'bulletedList', 'numberedList'],
+	fontFamily: {
+		supportAllValues: true
+	},
+	fontSize: {
+		options: [10, 12, 14, 'default', 18, 20, 22],
+		supportAllValues: true
+	},
+	heading: {
+		options: [
+			{
+				model: 'paragraph',
+				title: 'Paragraph',
+				class: 'ck-heading_paragraph'
+			},
+			{
+				model: 'heading1',
+				view: 'h1',
+				title: 'Heading 1',
+				class: 'ck-heading_heading1'
+			},
+			{
+				model: 'heading2',
+				view: 'h2',
+				title: 'Heading 2',
+				class: 'ck-heading_heading2'
+			},
+			{
+				model: 'heading3',
+				view: 'h3',
+				title: 'Heading 3',
+				class: 'ck-heading_heading3'
+			},
+			{
+				model: 'heading4',
+				view: 'h4',
+				title: 'Heading 4',
+				class: 'ck-heading_heading4'
+			},
+			{
+				model: 'heading5',
+				view: 'h5',
+				title: 'Heading 5',
+				class: 'ck-heading_heading5'
+			},
+			{
+				model: 'heading6',
+				view: 'h6',
+				title: 'Heading 6',
+				class: 'ck-heading_heading6'
+			}
+		]
+	},
+	htmlSupport: {
+		allow: [
+			{
+				name: /^.*$/,
+				styles: true,
+				attributes: true,
+				classes: true
+			}
+		]
+	},
+	image: {
+		toolbar: ['imageTextAlternative', '|', 'imageStyle:inline', 'imageStyle:alignLeft', 'imageStyle:alignRight', '|', 'resizeImage'],
+		styles: {
+			options: ['inline', 'alignLeft', 'alignRight']
+		}
+	},
+	language: 'it',
+	licenseKey: LICENSE_KEY,
+	link: {
+		addTargetToExternalLinks: true,
+		defaultProtocol: 'https://',
+		decorators: {
+			toggleDownloadable: {
+				mode: 'manual',
+				label: 'Downloadable',
+				attributes: {
+					download: 'file'
+				}
+			}
+		}
+	},
+	list: {
+		properties: {
+			styles: true,
+			startIndex: true,
+			reversed: true
+		}
+	},
+	menuBar: {
+		isVisible: true
+	},
+	placeholder: 'Inserisci qui il messaggio',
+	style: {
+		definitions: [
+			{
+				name: 'Article category',
+				element: 'h3',
+				classes: ['category']
+			},
+			{
+				name: 'Title',
+				element: 'h2',
+				classes: ['document-title']
+			},
+			{
+				name: 'Subtitle',
+				element: 'h3',
+				classes: ['document-subtitle']
+			},
+			{
+				name: 'Info box',
+				element: 'p',
+				classes: ['info-box']
+			},
+			{
+				name: 'CTA Link Primary',
+				element: 'a',
+				classes: ['button', 'button--green']
+			},
+			{
+				name: 'CTA Link Secondary',
+				element: 'a',
+				classes: ['button', 'button--black']
+			},
+			{
+				name: 'Marker',
+				element: 'span',
+				classes: ['marker']
+			},
+			{
+				name: 'Spoiler',
+				element: 'span',
+				classes: ['spoiler']
+			}
+		]
+	},
+	table: {
+		contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties']
+	},
+	translations: [translations],
+};
+
+
+// ClassicEditor.create(document.querySelector('#editor'), editorConfig);
+export {editorConfig};

+ 8 - 0
public/assets/libraries/ckeditor5/translations/af.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/af.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/af.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/ar.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/ar.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/ar.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/ast.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/ast.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/ast.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/az.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/az.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/az.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/be.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/be.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/be.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/bg.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/bg.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/bg.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/bn.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/bn.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/bn.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/bs.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/bs.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/bs.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/ca.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/ca.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/ca.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/cs.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/cs.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/cs.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/da.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/da.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/da.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/de-ch.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/de-ch.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/de-ch.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/de.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/de.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/de.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/el.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/el.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/el.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/en-au.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/en-au.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/en-au.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/en-gb.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/en-gb.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/en-gb.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/en.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/en.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 6 - 0
public/assets/libraries/ckeditor5/translations/en.umd.js


+ 8 - 0
public/assets/libraries/ckeditor5/translations/eo.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
public/assets/libraries/ckeditor5/translations/eo.js


Vissa filer visades inte eftersom för många filer har ändrats