فهرست منبع

Modulo comunicazione - Modifica completa

ferrari 3 ماه پیش
والد
کامیت
864b040a2d

+ 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');
     }

+ 349 - 149
app/Http/Livewire/EmailComunications.php

@@ -3,219 +3,316 @@
 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, $attachments = [], $recipients = [], $scheduledDateTime, $sendNow = true;
-    public $dataId, $update = false, $add = false;
-    public $users = [];
-    public $categories = [];
-
-    protected $rules = [
-        'subject' => 'required|string|max:255',
-        'message' => 'required|string',
-        // 'recipients' => 'required|array|min:1',
-        'scheduledDateTime' => 'required_if:sendNow,false|date|after:now',
-    ];
-
-    protected $messages = [
-        'subject.required' => 'L\'oggetto è obbligatorio',
-        'message.required' => 'Il messaggio è obbligatorio',
-        // 'recipients.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 function boot()
-    {
-        app(TenantMiddleware::class)->setupTenantConnection();
-    }
+    public ?int $messageId = null;
+    public string $subject = '';
+    public string $content_html = '';
 
-    public function sortBy($field)
-    {
-        if ($this->sortField === $field) {
-            $this->sortAsc = ! $this->sortAsc;
-        } else {
-            $this->sortAsc = true;
-        }
+    public array $recipients = [];
 
-        $this->sortField = $field;
-    }
+    public array $existingAttachments = [];
+    public $newAttachments = [];
 
-    public function resetFields()
-    {
-        $this->subject = '';
-        $this->message = '';
-        $this->attachments = [];
-        $this->recipients = [];
-        $this->sendNow = true;
-        $this->scheduledDateTime = now()->addHour()->format('Y-m-d\TH:i');
+    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;
 
-        $this->emit('load-data-table');
-        $this->emit('load-editor');
+    public function boot()
+    {
+        app(TenantMiddleware::class)->setupTenantConnection();
     }
 
     public function mount()
     {
-        if (Auth::user()->level != env('LEVEL_ADMIN', 0))
+        if (auth()->user()?->level != env('LEVEL_ADMIN', 0)) {
             return redirect()->to('/dashboard');
-
-        $this->users = Member::select('id', 'last_name', 'email')->get();
+        }
 
         $this->categories = [];
         $this->getCategories(\App\Models\Category::select('id', 'name')->where('parent_id', null)->orderBy('name')->get(), 0);
 
-        $this->scheduledDateTime = now()->addHour()->format('Y-m-d\TH:i');
+        $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()
     {
-        $this->records = EmailTemplate::orderBy($this->sortField, $this->sortAsc ? 'asc' : 'desc')->get();
+        if (!$this->showForm) {
+            $this->records = EmailMessage::withCount(['attachments', 'recipients'])
+                ->orderBy('created_at', 'desc')
+                ->get();
+        } else {
+            $this->categories = [];
+            $this->getCategories(\App\Models\Category::select('id', 'name')->where('parent_id', null)->orderBy('name')->get(), 0);
 
-        $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);
+        }
 
         return view('livewire.email_comunications');
     }
 
-    public function add()
+    protected function baseRules(): array
     {
-        $this->resetFields();
-        $this->add = true;
-        $this->update = false;
+        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 store()
+    protected function validateDraft(): void
     {
-        $this->validate();
+        $this->validate($this->baseRules());
+    }
 
-        try {
-            $template = EmailTemplate::create([
-                'name' => $this->subject,
-                'content' => $this->message,
-                'created_by' => Auth::id(),
-            ]);
+    protected function validateSend(): void
+    {
+        $this->validate($this->baseRules());
+    }
 
-            $recipients = User::whereIn('id', $this->recipients)->get();
+    protected function validateSchedule(): void
+    {
+        $rules = $this->baseRules();
+        $rules['schedule_at'] = 'required|date|after:now';
+        $this->validate($rules);
+    }
 
-            if ($this->sendNow) {
-                $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);
-            }
+    public function add()
+    {
+        $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->resetFields();
-            $this->add = false;
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
-        }
+        $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 edit($id)
     {
         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;
-
-                $this->emit('load-editor');
-            }
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
+            $msg = EmailMessage::with(['recipients', 'attachments'])->findOrFail($id);
+
+            $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();
+
+            $this->showForm = true;
+            $this->locked = $msg->isLocked();
+
+            $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 update()
-    {
-        $this->validate([
-            'subject' => 'required|string|max:255',
-            'message' => 'required|string',
-        ]);
 
+    public function duplicate($id, $withRecipients = true)
+    {
         try {
-            EmailTemplate::whereId($this->dataId)->update([
-                'name' => $this->subject,
-                'content' => $this->message,
-            ]);
-            session()->flash('success', 'Template Email aggiornato');
-            $this->resetFields();
-            $this->update = false;
-        } catch (\Exception $ex) {
-            session()->flash('error', 'Errore (' . $ex->getMessage() . ')');
+            $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 cancel()
+    public function saveDraft($html = null)
     {
-        $this->add = false;
-        $this->update = false;
-        $this->resetFields();
+        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 sendTemplate($id)
+    public function sendNow($html = null)
     {
-        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() . ')');
+        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;
+            }
         }
+
+        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,
+        ]);
     }
 
-    private function sendEmailNow($template, $recipients)
+    public function scheduleMessage($html = null)
     {
-        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());
-                }
-            } else {
-                Log::warning("User {$recipient->id} has no email address");
+        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,
+        ]);
     }
 
-    private function scheduleEmail($template, $recipients)
+    protected function upsertMessage(string $status, $scheduleAt): EmailMessage
     {
-        $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(),
-        ]);
+        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();
 
-        $scheduled->recipients()->attach($recipients->pluck('id'));
+        $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(),
+            ]);
+        }
+    }
+
+    public function cancel()
+    {
+        $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');
 
-        Log::info("Email scheduled for {$this->scheduledDateTime} to {$recipients->count()} recipients: {$template->name}");
+        $this->dispatchBrowserEvent('init-archive-table');
     }
 
     public function getCategories($records, $indentation)
@@ -226,4 +323,107 @@ class EmailComunications extends Component
                 $this->getCategories($record->childs, $indentation + 1);
         }
     }
+
+    public function getCourses($records, $indentation)
+    {
+        /** @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);
+                }
+            }
+        } catch (\Throwable $e) {
+        }
+
+        $att->delete();
+
+        $this->existingAttachments = array_values(array_filter(
+            $this->existingAttachments,
+            fn($a) => (int)$a['id'] !== (int)$id
+        ));
+    }
+
+
+    protected function refreshAttachments(EmailMessage $msg): void
+    {
+        $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) {
+            }
+        }
+
+        $msg->delete();
+
+        $this->dispatchBrowserEvent('email-deleted', ['id' => $id]);
+    }
 }

+ 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;
+    }
+}

+ 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();
     }

+ 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');
+    }
+};

+ 20 - 3
public/css/new_style.css

@@ -9,7 +9,7 @@
     --color-outile: #8979ff;
     --color-rosso: #d6234f;
     --color-verde: #339e8e;
-    --color-arancione: #FFB624;
+    --color-arancione: #ffb624;
 }
 
 ::-webkit-scrollbar {
@@ -1064,10 +1064,27 @@ body .user--profile_resume .resume--tab_info .title {
     display: none;
 }
 
-.ck-rounded-corners .ck.ck-editor__main>.ck-editor__editable, .ck.ck-editor__main>.ck-editor__editable {
+.ck-rounded-corners .ck.ck-editor__main > .ck-editor__editable,
+.ck.ck-editor__main > .ck-editor__editable {
     min-height: 200px;
 }
 
 table#recipients-table td {
     padding-right: 30px;
-}
+}
+
+.recipients {
+    padding: 16px 30px;
+    background: var(--color-lilla);
+    border-radius: 7px;
+    border: 1px solid var(--color-outile);
+    overflow: auto;
+    max-height: 220px;
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(min(333px, 100%), 1fr));
+    font-size: 14px;
+}
+
+.recipients .recipient span.recipient-name {
+    font-weight: bold;
+}

+ 448 - 351
resources/views/livewire/email_comunications.blade.php

@@ -1,163 +1,190 @@
 <div class="col card--ui" id="card--dashboard">
 
-    @if(!$add && !$update)
-
-        <header id="title--section" style="display:none !important" class="d-flex align-items-center justify-content-between">
-            <div class="title--section_name d-flex align-items-center justify-content-between">
-                <i class="ico--ui title_section utenti me-2"></i>
-                <h2 class="primary">@if(!$add && !$update)Email Templates @else Inserimento/modifica template Email @endif</h2>
-            </div>
+    <header id="title--section" style="display:none !important" class="d-flex align-items-center justify-content-between">
+        <div class="title--section_name d-flex align-items-center justify-content-between">
+            <i class="ico--ui title_section utenti me-2"></i>
+            <h2 class="primary">Emails</h2>
+        </div>
 
-            @if(!$add && !$update)
-                <div class="title--section_addButton" wire:click="add()" style="cursor: pointer;">
-                    <div class="btn--ui entrata d-flex justify-items-between">
-                        <a href="#" wire:click="add()" style="color:white">Aggiungi</a>
-                    </div>
+        @if(!$showForm)
+            <div class="title--section_addButton" wire:click="add()" style="cursor: pointer;" wire:ignore>
+                <div class="btn--ui entrata d-flex justify-items-between">
+                    <a href="#" style="color:white;">Aggiungi</a>
                 </div>
-            @endif
-
-        </header>
-
-        <a class="btn--ui lightGrey" href="/settings?type=comunicazioni"><i class="fa-solid fa-arrow-left"></i></a><br>
+            </div>
+        @endif
+    </header>
 
-        <section id="resume-table">
-            <div class="compare--chart_wrapper d-none"></div>
+    <a class="btn--ui lightGrey" @if(!$showForm) href="/settings?type=comunicazioni" @else href="/mail_comunications" @endif><i class="fa-solid fa-arrow-left"></i></a><br/><br/>
 
+    {{-- LISTA MESSAGGI --}}
+    <section id="resume-table" @if($showForm) style="display:none" @endif>
+        <div wire:ignore>
             <table class="table tablesaw tableHead tablesaw-stack" id="tablesaw-350" width="100%">
                 <thead>
                     <tr>
-                        <th scope="col">Oggetto</th>
-                        <th scope="col">Messaggio</th>
-                        <th scope="col">Parole</th>
-                        <th scope="col">Data Creazione</th>
-                        <th scope="col">...</th>
+                        <th>ID</th>
+                        <th>Oggetto</th>
+                        <th># Attachments</th>
+                        <th># Destinatari</th>
+                        <th>Stato</th>
+                        <th>Programmata per</th>
+                        <th>Data invio</th>
+                        <th>Data creazione</th>
+                        <th>...</th>
                     </tr>
                 </thead>
                 <tbody id="checkall-target">
                     @foreach($records as $record)
-                    <tr>
-                        <td>
-                            <strong>{{$record->name}}</strong>
-                        </td>
-                        <td>
-                            {{ Str::limit(strip_tags($record->content), 80) }}
-                        </td>
-                        <td>
-                            @php
-                            $wordCount = str_word_count(strip_tags($record->content));
-                            $badgeClass = $wordCount > 500 ? 'bg-warning' : 'bg-info';
-                            @endphp
-                            <span class="badge {{ $badgeClass }}">{{ $wordCount }} parole</span>
-                        </td>
-                        <td>{{ $record->created_at->format('d/m/Y H:i') }}</td>
-                        <td>
-                            <button type="button" class="btn" wire:click="sendTemplate({{ $record->id }})" data-bs-toggle="popover" data-bs-trigger="hover focus" data-bs-placement="bottom" data-bs-content="Invia Email"><i class="fa-solid fa-envelope"></i></button>
-                            <button type="button" class="btn" wire:click="edit({{ $record->id }})" data-bs-toggle="popover" data-bs-trigger="hover focus" data-bs-placement="bottom" data-bs-content="Modifica"><i class="fa-regular fa-pen-to-square"></i></button>
-                        </td>
-                    </tr>
+                        @php
+                            $state = $record->status;
+
+                            if (!$state) {
+                                if (($record->recipients_sent_count ?? 0) > 0 && ($record->recipients_failed_count ?? 0) > 0) {
+                                    $state = 'partial';
+                                } elseif (($record->recipients_sent_count ?? 0) > 0) {
+                                    $state = 'sent';
+                                } elseif (!empty($record->schedule_at)) {
+                                    $state = 'scheduled';
+                                } else {
+                                    $state = 'draft';
+                                }
+                            }
+
+                            $badgeMap = [
+                                'draft'      => 'secondary',
+                                'processing' => 'info',
+                                'scheduled'  => 'primary',
+                                'partial'    => 'warning',
+                                'sent'       => 'success',
+                                'failed'     => 'danger',
+                            ];
+                        @endphp
+                        <tr id="row_email_{{ $record->id }}">
+                            <td>{{ $record->id }}</td>
+                            <td><strong>{{ $record->subject }}</strong></td>
+                            <td style="padding-right: 20px">{{ $record->attachments_count ?? $record->attachments()->count() }}</td>
+                            <td style="padding-right: 20px">{{ $record->recipients_count ?? $record->recipients()->count() }}</td>
+                            <td><span class="badge bg-{{$badgeMap[$state]}}">{{ $record->status }}</span></td>
+                            <td>
+                                @if(!empty($record->schedule_at))
+                                    {{ optional($record->schedule_at)->setTimezone('Europe/Rome')->format('d/m/Y H:i') }}
+                                @endif
+                            </td>
+                            <td>
+                                @if(!empty($record->sent_at))
+                                    {{ optional($record->sent_at)->setTimezone('Europe/Rome')->format('d/m/Y H:i') }}
+                                @endif
+                            </td>
+                            <td>{{ optional($record->created_at)->setTimezone('Europe/Rome')->format('d/m/Y H:i') }}</td>
+                            <td class="d-flex gap-2">
+                                <button type="button" class="btn" wire:click="edit({{ $record->id }})" data-bs-toggle="tooltip" data-bs-trigger="hover focus" data-bs-placement="bottom" title="Modifica">
+                                    <i class="fa-regular fa-pen-to-square"></i>
+                                </button>
+                                <button type="button" class="btn" wire:click="duplicate({{ $record->id }})" data-bs-toggle="tooltip" data-bs-trigger="hover focus" data-bs-placement="bottom" title="Duplica">
+                                    <i class="fa-solid fa-copy"></i>
+                                </button>
+                                
+                                @if(in_array($record->status, ['draft','failed']))
+                                    <button type="button" class="btn text-danger"
+                                            onclick="if (confirm('Eliminare definitivamente questa email?')) { Livewire.find('{{ $this->id }}').call('deleteMessage', {{ $record->id }}); }"
+                                            data-bs-toggle="tooltip" title="Elimina">
+                                        <i class="fa-solid fa-trash"></i>
+                                    </button>
+                                @endif
+                            </td>
+                        </tr>
                     @endforeach
                 </tbody>
             </table>
-        </section>
-
-    @else
-
-        <a class="btn--ui lightGrey" href="/email_comunications"><i class="fa-solid fa-arrow-left"></i></a><br>
+        </div>
+    </section>
 
+    {{-- FORM MESSAGGIO --}}
+    <section wire:key="email-form" @if(!$showForm) style="display:none" @endif>
         <div class="container">
 
-            @if (session()->has('error'))
-                <div class="alert alert-danger" role="alert">
-                    {{ session()->get('error') }}
-                </div>
+            @if ($error)
+                <div class="alert alert-danger" role="alert">{{ $error }}</div>
+            @endif
+            @if ($success)
+                <div class="alert alert-success" role="alert">{{ $success }}</div>
             @endif
 
             <div class="row">
                 <div class="col">
 
-                    <form action="">
-
-                        @if ($add)
-                            {{-- <div class="row mb-4">
-                                <div class="col">
-                                    <label class="form-label">Destinatari</label>
-                                    <div class="mb-2">
-                                        <button type="button" class="btn btn-outline-primary btn-sm" onclick="selectAllUsers()">
-                                            <i class="fas fa-users me-1"></i>Seleziona Tutti
-                                        </button>
-                                        <button type="button" class="btn btn-outline-secondary btn-sm ms-2" onclick="deselectAllUsers()">
-                                            <i class="fas fa-times me-1"></i>Deseleziona Tutti
-                                        </button>
-                                    </div>
-                                    <div style="max-height: 200px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 10px;">
-                                        @foreach($users as $user)
-                                            <div class="form-check mb-1">
-                                                <input class="form-check-input" type="checkbox" value="{{ $user->id }}" wire:model="selectedRecipients" id="recipient_{{ $user->id }}">
-                                                <label class="form-check-label" for="recipient_{{ $user->id }}">
-                                                    <strong>{{ $user->name }}</strong>
-                                                    @if($user->email)
-                                                        <small class="text-muted">({{ $user->email }})</small>
-                                                    @else
-                                                        <small class="text-danger">(no email)</small>
-                                                    @endif
-                                                </label>
+                    <form>
+
+                        {{-- Destinatari (selezionati) --}}
+                        <div class="row mb-5">
+                            <div class="col-12 mb-2">
+                                <h4>Destinatari</h4>
+                                <div class="recipients">
+                                    @if (empty($recipients))
+                                        <span>Nessun destinatario selezionato</span>
+                                    @else
+                                        @foreach ($recipients as $r)
+                                            @php
+                                                $fullName = trim(($r['last_name'] ?? '').' '.($r['first_name'] ?? ''));
+                                            @endphp
+                                            <div class="recipient">
+                                                <span class="recipient-name">{{ $fullName !== '' ? $fullName : '—' }}</span>
+                                                <span class="recipient-email">({{ $r['email_address'] }})</span>
                                             </div>
                                         @endforeach
-                                    </div>
-                                    <div class="form-text">
-                                        <small class="text-muted">Selezionati: <span id="selectedCount">{{ count($selectedRecipients) }}</span> utenti</small>
-                                    </div>
-                                    @error('selectedRecipients')
-                                    <div class="text-danger">{{ $message }}</div>
-                                    @enderror
+                                    @endif
                                 </div>
-                            </div> --}}
-
-                            <div class="row mb-4">
+                                @error('recipients') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
+                            </div>
+                            <div class="col"></div>
+                            <div class="col-auto">
+                                <a style="cursor:pointer" class="addRecipients btn--ui"><i class="fa-solid fa-plus"></i></a>
+                            </div>
+                        </div>
 
-                                <div class="col-xs-12">
-                                    <div class="showFilter" style="display: none">
-                                        <hr size="1">
-                                        <div class="row g-3">
-                                            <div class="col-md-3">
-                                                <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Età</b>
-                                                    </div>
-                                                    <div class="col-12">
-                                                        <div class="row mb-2">
-                                                            <div class="col-3"><label class="form-check-label ms-2" >Da</label></div>
-                                                            <div class="col-9"><input class="form-control " type="number" name="txtFromYear"></div>
-                                                        </div>
+                        {{-- FILTRI + TABELLA DESTINATARI --}}
+                        <div class="row mb-5" wire:ignore id="addRecipientsRow" style="display: none">
+                            <div class="col-xs-12">
+                                <div class="showFilter" style="display: none">
+                                    <hr size="1">
+                                    <div class="row g-3">
+                                        <div class="col-md-3">
+                                            <div class="row">
+                                                <div class="col-md-12 mb-2"><b>Età</b></div>
+                                                <div class="col-12">
+                                                    <div class="row mb-2">
+                                                        <div class="col-3"><label class="form-check-label ms-2">Da</label></div>
+                                                        <div class="col-9"><input class="form-control" type="number" name="txtFromYear"></div>
                                                     </div>
-                                                    <div class="col-12">
-                                                        <div class="row">
-                                                            <div class="col-3"><label class="form-check-label ms-2" >A</label></div>
-                                                            <div class="col-9"><input class="form-control " type="number"  name="txtToYear"></div>
-                                                        </div>
+                                                </div>
+                                                <div class="col-12">
+                                                    <div class="row">
+                                                        <div class="col-3"><label class="form-check-label ms-2">A</label></div>
+                                                        <div class="col-9"><input class="form-control" type="number" name="txtToYear"></div>
                                                     </div>
                                                 </div>
                                             </div>
-                                            <div class="col-md-3">
-                                                <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Tipologia di tesseramento</b>
-                                                    </div>
-                                                    <div class="col-12">
-                                                        <select name="filterCards" class="form-select filterCards">
-                                                            <option value="">Tutte
-                                                            @foreach(getCards() as $card)
-                                                                <option value="{{$card->id}}">{{$card->name}}
-                                                            @endforeach
-                                                        </select>
-                                                    </div>
+                                        </div>
+
+                                        {{-- Altri filtri come da tua UI esistente --}}
+                                        <div class="col-md-3">
+                                            <div class="row">
+                                                <div class="col-md-12 mb-2"><b>Tipologia di tesseramento</b></div>
+                                                <div class="col-12">
+                                                    <select name="filterCards" class="form-select filterCards">
+                                                        <option value="">Tutte
+                                                        @foreach(getCards() as $card)
+                                                            <option value="{{$card->id}}">{{$card->name}}
+                                                        @endforeach
+                                                    </select>
                                                 </div>
                                             </div>
+                                        </div>
+
                                             <div class="col-md-3">
                                                 <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Stato tesseramento</b>
-                                                    </div>
+                                                    <div class="col-md-12 mb-2"><b>Stato tesseramento</b></div>
                                                     <div class="col-12">
                                                         <select name="filterStatus" class="form-select filterStatus" multiple="multiple">
                                                             <option value="2">Attivo
@@ -169,9 +196,7 @@
                                             </div>
                                             <div class="col-md-3">
                                                 <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Gruppo di appartenenza</b>
-                                                    </div>
+                                                    <div class="col-md-12 mb-2"><b>Gruppo di appartenenza</b></div>
                                                     <div class="col-12">
                                                         <select name="filterCategories" class="form-select filterCategories" multiple="multiple">
                                                             <option value="">Tutte</option>
@@ -186,9 +211,7 @@
                                             </div>
                                             <div class="col-md-3">
                                                 <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Anno di nascita</b>
-                                                    </div>
+                                                    <div class="col-md-12 mb-2"><b>Anno di nascita</b></div>
                                                     <div class="col-12">
                                                         <div class="row mb-2">
                                                             <div class="col-3"><label class="form-check-label ms-2" >Da</label></div>
@@ -205,9 +228,22 @@
                                             </div>
                                             <div class="col-md-3">
                                                 <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Scadenza certificato medico</b>
+                                                    <div class="col-md-12 mb-2"><b>Corso</b></div>
+                                                    <div class="col-12">
+                                                        <select name="filterCourses" class="form-select filterCourses" multiple="multiple">
+                                                            <option value="">Tutti</option>
+                                                            @foreach($courses as $course)
+                                                                <option value="{{ $course['id'] }}">
+                                                                    {!! str_repeat('&bull; ', $course['indentation'] ?? 0) !!}{{ $course['name'] }}
+                                                                </option>
+                                                            @endforeach
+                                                        </select>
                                                     </div>
+                                                </div>
+                                            </div>
+                                            <div class="col-md-3">
+                                                <div class="row">
+                                                    <div class="col-md-12 mb-2"><b>Scadenza certificato medico</b></div>
                                                     <div class="col-12">
                                                         <select name="filterScadenza" class="form-select filterScadenza" multiple="multiple">
                                                             <option value="1">Scaduti
@@ -220,9 +256,7 @@
                                             </div>
                                             <div class="col-md-3">
                                                 <div class="row">
-                                                    <div class="col-md-12" style="margin-bottom:10px;">
-                                                        <b>Tipologia certificato medico</b>
-                                                    </div>
+                                                    <div class="col-md-12 mb-2"><b>Tipologia certificato medico</b></div>
                                                     <div class="col-12">
                                                         <select name="filterCertificateType" class="form-select filterCertificateType" multiple="multiple">
                                                             <option value="">Tutti
@@ -233,47 +267,45 @@
                                                 </div>
                                             </div>
 
+                                    </div>
+                                    <div class="row g-3">
+                                        <div class="col-md-12" style="text-align:right">
+                                            <button class="btn--ui lightGrey" onclick="resetFilters(event)">Reset</button>
+                                            <button class="btn--ui" onclick="event.preventDefault();loadDataTable()">FILTRA</button>
                                         </div>
-                                        <div class="row g-3">
-                                            <div class="col-md-12" style="text-align:right">
-                                                <button class="btn--ui lightGrey" onclick="resetFilters(event)">Reset</button>
-                                                <button class="btn--ui" onclick="loadDataTable(event)">FILTRA</button>
-                                            </div>
-                                        </div>
-                                        <hr size="1">
                                     </div>
+                                    <hr size="1">
                                 </div>
+                            </div>
 
-                                <div class="col-xs-12">
-                                    <table id="recipients-table" class="table tablesaw tableHead tablesaw-stack">
-                                        <thead>
-                                            <tr>
-                                                <th>Cognome</th>
-                                                <th>Nome</th>
-                                                {{-- <th>Email</th> --}}
-                                                <th>Telefono</th>
-                                                <th>Età</th>
-                                                <th>Anno</th>
-                                                <th>Stato</th>
-                                                <th>Certificato</th>
-                                                {{-- <th>Gruppi di interesse</th>
-                                                <th>Corsi</th>
-                                                <th>Certificato</th>
-                                                <th>Tesseramento</th>
-                                                <th>Rresidenza</th> --}}
-                                            </tr>
-                                        </thead>
-                                        <tbody></tbody>
-                                    </table>
-                                </div>
+                            <div class="col-xs-12">
+                                <table id="recipients-table" class="table tablesaw tableHead tablesaw-stack w-100">
+                                    <thead>
+                                        <tr>
+                                            <th></th>
+                                            <th>Cognome</th>
+                                            <th>Nome</th>
+                                            <th>Email</th>
+                                            <th>Telefono</th>
+                                            <th>Età</th>
+                                            <th>Anno</th>
+                                            <th>Stato</th>
+                                            <th>Certificato</th>
+                                            <th>Gruppi</th>
+                                            {{-- <th>Corsi</th> --}}
+                                        </tr>
+                                    </thead>
+                                    <tbody></tbody>
+                                </table>
                             </div>
-                        @endif
+                        </div>
 
-                        <div class="row mb-4">
+                        {{-- Oggetto --}}
+                        <div class="row mb-5">
                             <div class="col">
                                 <div class="form--item">
-                                    <label for="subject" class="form-label">Oggetto</label>
-                                    <input type="text" class="form-control @error('subject') is-invalid @enderror" id="subject" wire:model="subject" placeholder="Inserisci l'oggetto del template">
+                                    <h4>Oggetto</h4>
+                                    <input type="text" class="form-control @error('subject') is-invalid @enderror" id="subject" wire:model.defer="subject" placeholder="Oggetto email" @if($locked) disabled @endif>
                                     @error('subject')
                                     <div class="invalid-feedback">{{ $message }}</div>
                                     @enderror
@@ -281,74 +313,129 @@
                             </div>
                         </div>
 
-                        <div class="row mb-4">
+                        {{-- Messaggio (CKEditor → content_html) --}}
+                        <div class="row mb-5">
                             <div class="col">
                                 <div wire:ignore class="form--item">
-                                    <label for="message" class="form-label">Messaggio</label>
-                                    {{-- @if(false)
-                                    <textarea class="form-control @error('message') is-invalid @enderror" id="message" wire:model="message" rows="10" placeholder="Inserisci il contenuto del messaggio (supporta HTML)"></textarea>
-                                    <div class="form-text">
-                                        <small class="text-muted">Puoi utilizzare HTML per formattare il messaggio. Parole: <span class="fw-bold">{{ str_word_count(strip_tags($message)) }}</span></small>
-                                    </div>
-                                    @endif --}}
-                                    {{-- <div class="editor-container editor-container_classic-editor editor-container_include-style editor-container_include-block-toolbar" id="editor-container">
-                                        <div class="editor-container__editor">
-                                            <div id="editor"></div>
-                                        </div>
-                                    </div> --}}
-                                    <textarea wire:model="message" class="form-control required" name="message" id="message"></textarea>
-                                    @error('message')
-                                    <div class="invalid-feedback">{{ $message }}</div>
+                                    <h4>Messaggio</h4>
+                                    <textarea class="form-control" id="message"></textarea>
+                                    @error('content_html')
+                                        <div class="invalid-feedback d-block">{{ $message }}</div>
                                     @enderror
                                 </div>
                             </div>
                         </div>
 
-                        @if($add)
-                            <div class="row mb-4">
-                                <div class="col">
-                                    <label class="form-label">Opzioni di Invio</label>
-                                    <div class="d-flex gap-2 comunication-send-options">
-                                        <label class="form-check" for="sendNow">
-                                            <input class="form-check-input" type="radio" name="sendOption" id="sendNow" wire:model="sendNow" value="1">
-                                            <i class="fas fa-envelope me-2"></i>
-                                            <span>Invia Immediatamente</span>
-                                        </label>
-                                        <label class="form-check" for="scheduleFor">
-                                            <input class="form-check-input" type="radio" name="sendOption" id="scheduleFor" wire:model="sendNow" value="0">
-                                            <i class="fas fa-clock me-2"></i>
-                                            <span>Programma per dopo</span>
-                                        </label>
-                                    </div>
+                        {{-- Allegati --}}
+                        <div class="row mb-5">
+                            <div class="col">
+                                <div class="form--item">
+                                    <h4>Allegati</h4>
+                                    <input type="file" class="form-control @error('newAttachments.*') is-invalid @enderror" wire:model="newAttachments" multiple @if($locked) disabled @endif>
+                                    <small class="text-muted d-block mt-1">Formati: pdf, docx, jpg, png</small>
+                                    @error('newAttachments.*')
+                                        <div class="invalid-feedback d-block">{{ $message }}</div>
+                                    @enderror
+
+                                    @if(!empty($existingAttachments))
+                                    <ul class="list-group mt-2">
+                                        @foreach($existingAttachments as $att)
+                                        <li class="list-group-item d-flex justify-content-between align-items-center gap-3">
+                                            <div class="d-flex align-items-center gap-3 text-truncate" style="min-width:0">
+                                            @if(!empty($att['img']) && !empty($att['url']))
+                                                <img src="{{ $att['url'] }}" alt="" style="width:40px;height:40px;object-fit:cover;border-radius:6px;border:1px solid #eee">
+                                            @else
+                                                <i class="fa-regular fa-file" style="font-size:20px;width:40px;text-align:center"></i>
+                                            @endif
+
+                                            <div class="text-truncate d-flex ">
+                                                @if(!empty($att['url']))
+                                                <a href="{{ $att['url'] }}" target="_blank" class="text-decoration-none text-truncate d-inline-block" style="max-width:420px">
+                                                    {{ $att['name'] }}
+                                                </a>
+                                                @else
+                                                <span class="text-truncate d-inline-block" style="max-width:420px">{{ $att['name'] }}</span>
+                                                @endif
+                                                @if(!empty($att['size']))
+                                                <small class="text-muted ms-2">({{ $att['size'] }})</small>
+                                                @endif
+                                            </div>
+                                            </div>
+
+                                            @unless($locked)
+                                            <button type="button"
+                                                    class="btn btn-sm btn-outline-danger"
+                                                    wire:click="removeExistingAttachment({{ (int)$att['id'] }})"
+                                                    title="Rimuovi">
+                                                <i class="fa-solid fa-trash"></i>
+                                            </button>
+                                            @endunless
+                                        </li>
+                                        @endforeach
+                                    </ul>
+                                    @endif
+
+                                    {{-- File appena selezionati (non salvati) --}}
+                                    @if(is_array($newAttachments) && count($newAttachments))
+                                    <ul class="list-group mt-2">
+                                        @foreach($newAttachments as $idx => $tmp)
+                                        <li class="list-group-item d-flex justify-content-between align-items-center">
+                                            <div class="text-truncate d-flex ">
+                                                <i class="fa-regular fa-file me-2"></i>
+                                                {{ $tmp->getClientOriginalName() }}
+                                                <small class="text-muted ms-2">({{ number_format($tmp->getSize()/1024,1) }} KB) — non ancora salvato</small>
+                                            </div>
+                                            <button type="button" class="btn btn-sm btn-outline-danger" wire:click="removeNewAttachment({{ $idx }})">
+                                            <i class="fa-solid fa-xmark"></i>
+                                            </button>
+                                        </li>
+                                        @endforeach
+                                    </ul>
+                                    @endif
+
+
                                 </div>
                             </div>
+                        </div>
 
-                            @if(!$sendNow)
-                                <div class="row mb-4">
-                                    <div class="col-md-6">
-                                        <label for="scheduledDateTime" class="form-label">Data e Ora di Invio</label>
-                                        <input type="datetime-local" class="form-control @error('scheduledDateTime') is-invalid @enderror" id="scheduledDateTime" wire:model="scheduledDateTime">
-                                        @error('scheduledDateTime')
-                                        <div class="invalid-feedback">{{ $message }}</div>
-                                        @enderror
-                                    </div>
+                        {{-- Opzioni invio --}}
+                        <div class="row mb-5">
+                            <div class="col">
+                                <h4>Opzioni di Invio</h4>
+                                <div class="d-flex gap-3 comunication-send-options">
+                                    <label class="form-check">
+                                        <input class="form-check-input" type="radio" wire:model="mode" value="now">
+                                        <i class="fas fa-envelope me-2"></i> <span>Invia subito</span>
+                                    </label>
+                                    <label class="form-check">
+                                        <input class="form-check-input" type="radio" wire:model="mode" value="schedule">
+                                        <i class="fas fa-clock me-2"></i> <span>Programma</span>
+                                    </label>
                                 </div>
-                            @endif
+                            </div>
+                        </div>
+
+                        @if(!$locked && $mode === 'schedule')
+                            <div class="row mb-5">
+                                <div class="col-md-6">
+                                    <label for="scheduledDateTime" class="form-label">Data e Ora di Invio</label>
+                                    <input type="datetime-local" class="form-control @error('schedule_at') is-invalid @enderror" id="scheduledDateTime" wire:model="schedule_at">
+                                    @error('schedule_at') <div class="invalid-feedback">{{ $message }}</div> @enderror
+                                </div>
+                            </div>
                         @endif
 
-                        <div class="form--item mt-5">
-                            <button type="button" class="btn--ui lightGrey" wire:click="cancel()">Annulla</button>
-                            @if($add)
-                                <button type="submit" class="btn--ui" wire:click.prevent="store()">
-                                    @if($sendNow)
-                                    Salva e Invia
-                                    @else
-                                    Salva e Programma
-                                    @endif
-                                </button>
-                            @endif
-                            @if($update)
-                                <button type="submit" class="btn--ui" wire:click.prevent="update()">Salva</button>
+                        <div class="form--item mt-5 mb-5 d-flex gap-2">
+                            @if(!$locked)
+                                <a class="btn--ui lightGrey" href="/mail_comunications">Annulla</a>
+                                <button type="button" class="btn--ui" onclick="submitEmail('draft')" style="margin-right: auto">Salva bozza</button>
+                                @if($mode==='now')
+                                    <button type="button" class="btn--ui" onclick="submitEmail('send')">Invia ora</button>
+                                @else
+                                    <button type="button" class="btn--ui" onclick="submitEmail('schedule')">Salva & Programma</button>
+                                @endif
+                            @else
+                                <a class="btn--ui lightGrey" href="/mail_comunications">Torna indietro</a>
                             @endif
                         </div>
 
@@ -356,20 +443,19 @@
                 </div>
             </div>
         </div>
-
-    @endif
+    </section>
 </div>
 
 @if (session()->has('success'))
 <div class="alert alert-success alert-dismissible fade show mt-3" role="alert">
-    {{ session()->get('success') }}
+    {{ session('success') }}
     <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
 </div>
 @endif
 
 @if (session()->has('error'))
 <div class="alert alert-danger alert-dismissible fade show mt-3" role="alert">
-    {{ session()->get('error') }}
+    {{ session('error') }}
     <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
 </div>
 @endif
@@ -389,26 +475,42 @@
 
 @push('scripts')
 <script type="text/javascript">
+    document.addEventListener('livewire:load', () => {
+        if (!$.fn.DataTable.isDataTable('#tablesaw-350')) {
+            loadArchiveDataTable();
+        }
 
-    $(document).ready(function() {
-        loadDataTable();
-        loadArchiveDataTable();
+        window.addEventListener('email-deleted', (e) => {
+            const id = e.detail?.id;
+            const table = $('#tablesaw-350');
+            if (!id || !$.fn.DataTable.isDataTable(table)) return;
+
+            const dt = table.DataTable();
+            const rowEl = document.getElementById('row_email_' + id);
+            if (rowEl) {
+                dt.row(rowEl).remove().draw(false);
+            }
+        });
     });
 
-    Livewire.on('load-data-table', () => {
-        loadDataTable();
-        loadArchiveDataTable();
+
+
+    window.addEventListener('init-recipients-table', (e) => {
+        const selected = e.detail?.selected || [];
+        loadDataTable(selected);
     });
 
     function loadArchiveDataTable(){
-        if ($.fn.DataTable.isDataTable('#tablesaw-350')) {
-            $('#tablesaw-350').DataTable().destroy();
+        let table = $('#tablesaw-350').DataTable();
+        if ( $.fn.DataTable.isDataTable('#tablesaw-350') ) {
+            table.destroy();
         }
 
         $('#tablesaw-350').DataTable({
             thead: {
-            'th': {'background-color': 'blue'}
+                'th': {'background-color': 'blue'}
             },
+            order: [[7, 'desc']],
             layout: {
                 topStart : null,
                 topEnd : null,
@@ -419,23 +521,23 @@
                             text: 'ESPORTA',
                             buttons: [
                                 {
-                                extend: 'excelHtml5',
-                                    title: 'Templates Email',
+                                    extend: 'excelHtml5',
+                                    title: 'Email',
                                     exportOptions: {
                                         columns: ":not(':last')"
                                     }
                                 },
                                 {
                                     extend: 'pdfHtml5',
-                                    title: 'Templates Email',
-                                    exportOptions: {
-                                        columns: ":not(':last')"
-                                    }
-                                },
+                                    title: 'Email',
+                                        exportOptions: {
+                                            columns: ":not(':last')"
+                                        }
+                                    },
                                 {
                                     extend: 'print',
                                     text: 'Stampa',
-                                    title: 'Templates Email',
+                                    title: 'Email',
                                     exportOptions: {
                                         columns: ":not(':last')"
                                     }
@@ -453,38 +555,36 @@
                 top1C :'search',
             },
             pagingType: 'numbers',
-            "language": {
+            language: {
                 "url": "/assets/js/Italian.json"
             },
-            "fnInitComplete": function (oSettings, json) {
+            fnInitComplete: function (oSettings, json) {
                 var html = '&nbsp;<a href="#" class="addData btn--ui"><i class="fa-solid fa-plus"></i></a>';
                 $(".dt-search").append(html);
             }
         });
-        $('#tablesaw-350 thead tr th').addClass('col');
-        $('#tablesaw-350 thead tr th').css("background-color", "#f6f8fa");
 
-        $(document).ready(function() {
-            $(document).on("click",".addData",function() {
-                $(".title--section_addButton").trigger("click")
-            });
+        $('#tablesaw-350 thead tr th').addClass('col').css("background-color", "#f6f8fa");
+
+        $(document).on("click",".addData",function() {
+            @this.add();
         });
     }
-    
-    $(document).ready(function() {
-        $(document).on("click",".showHideFilter",function() {
-            $(".showFilter").toggle();
-            
-            $('.filterCards').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-            $('.filterStatus').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-            $('.filterScadenza').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-            $('.filterCertificateType').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
-            $('.filterCategories').select2({"language": {"noResults": function(){return "Nessun risultato";}}});
+
+    $(document).on("click",".showHideFilter",function() {
+        $(".showFilter").toggle();
+        $('.filterCards,.filterStatus,.filterScadenza,.filterCertificateType,.filterCategories,.filterCourses').each(function(){
+            $(this).select2({
+                language: { noResults: ()=>"Nessun risultato" }
+            });
         });
     });
 
-    function resetFilters(event)
-    {
+    $(document).on("click", ".addRecipients", function() {
+        $("#addRecipientsRow").toggle();
+    });
+
+    window.resetFilters = function(event){
         if (event) event.preventDefault();
 
         $('.filterCards').val('').trigger('change');
@@ -492,6 +592,7 @@
         $('.filterScadenza').val('-1').trigger('change');
         $('.filterCertificateType').val('-1').trigger('change');
         $('.filterCategories').val('-1').trigger('change');
+        $('.filterCourses').val('-1').trigger('change');
 
 
         $('input[name="txtFromYear"]').val('');
@@ -502,60 +603,59 @@
         loadDataTable();
     }
 
-    function loadDataTable(event = null) {
-        if (event) event.preventDefault();
+    function loadDataTable(preselected = []) {
+        const selectedIds = new Set((preselected || []).map(x => parseInt(x, 10)).filter(Boolean));
 
         if ($.fn.DataTable.isDataTable('#recipients-table')) {
             $('#recipients-table').DataTable().destroy();
         }
 
         var fromYear = $('input[name="txtFromYear"]').val();
-        localStorage.setItem("fromYearMember", fromYear);
-
         var toYear = $('input[name="txtToYear"]').val();
-        localStorage.setItem("toYearMember", toYear);
-
         var fromYearYear = $('input[name="txtFromYearYear"]').val();
-        localStorage.setItem("fromYearYearMember", fromYearYear);
-
         var toYearYear = $('input[name="txtToYearYear"]').val();
-        localStorage.setItem("toYearYearMember", toYearYear);
-
         var filterCards = $('.filterCards').val();
-        localStorage.setItem("filterCardsMember", filterCards);
-
         var filterStatus = $('.filterStatus').val();
-        localStorage.setItem("filterStatusMember", filterStatus);
-
         var filterScadenza = $('.filterScadenza').val();
-        localStorage.setItem("filterScadenzaMember", filterScadenza);
-
         var filterCertificateType = $('.filterCertificateType').val();
-        localStorage.setItem("filterCertificateTypeMember", filterCertificateType);
-
         var filterCategories = $('.filterCategories').val();
-        localStorage.setItem("filterCategoriesMember", filterCategories)
-
-        const pageLength = 10;
+        var filterCourses = $('.filterCourses').val();
 
         const dataTable = $('#recipients-table').DataTable({
             serverSide: true,
-            ajax: '/get_members?cards=' + filterCards + "&filterCategories=" + filterCategories + "&filterCertificateType=" + filterCertificateType + "&filterScadenza=" + filterScadenza + "&filterStatus=" + filterStatus + "&fromYear=" + fromYear + "&toYear=" + toYear + "&fromYearYear=" + fromYearYear + "&toYearYear=" + toYearYear,
+            ajax: '/get_recipients?cards=' + filterCards + "&filterCategories=" + filterCategories + "&filterCertificateType=" + filterCertificateType + "&filterScadenza=" + filterScadenza + "&filterStatus=" + filterStatus + "&fromYear=" + fromYear + "&toYear=" + toYear + "&fromYearYear=" + fromYearYear + "&toYearYear=" + toYearYear + "&filterCourses=" + (filterCourses || ""),
             columns: [
+                {
+                    orderable: false,
+                    data: "id",
+                    render: function (data){
+                        const id = parseInt(data, 10);
+                        const checked = selectedIds.has(id) ? 'checked' : '';
+                        return `<input type="checkbox" value="${id}" ${checked} onclick="toggleRecipient(${id})" id="recipient_${id}"/>`;
+                    }
+                },
                 {
                     data: "last_name",
                     render: function (data){
                         const d = data.split("|");
-                        var ret = '<a style="cursor:pointer" onclick="showData(' + d[1] + ')">' + d[0] + '</a>';
-                        return ret;
+                        const id = d[1], value = d[0];
+                        return `<label for="recipient_${id}">${value}</label>`;
                     }
                 },
                 {
                     data: "first_name",
                     render: function (data){
                         const d = data.split("|");
-                        var ret = '<a style="cursor:pointer" onclick="showData(' + d[1] + ')">' + d[0] + '</a>';
-                        return ret;
+                        const id = d[1], value = d[0];
+                        return `<label for="recipient_${id}">${value}</label>`;
+                    }
+                },
+                {
+                    data: "email",
+                    render: function (data){
+                        const d = data.split("|");
+                        const id = d[1], value = d[0];
+                        return `<label for="recipient_${id}">${value}</label>`;
                     }
                 },
                 { data: "phone"},
@@ -565,41 +665,23 @@
                     data: "status",
                     render: function (data){
                         const d = data.split("|");
-                        var ret = '<span class="tablesaw-cell-content"><span class="badge tessera-badge ' + d[0] + '">' + d[1] + '</span></span>';
-                        return ret;
+                        return '<span class="tablesaw-cell-content"><span class="badge tessera-badge ' + d[0] + '">' + d[1] + '</span></span>';
                     }
                 },
                 {
                     data: "certificate",
                     render: function (data){
-                        var ret = '';
-                        if (data != "") {
-                            const d = data.split("|");
-                            ret += '<span class="tablesaw-cell-content d-flex align-items-center">';
-                            if (d[0] == "0") {
-                                ret += '<i class="ico--ui check suspended me-2"></i>';
-                                ret += 'Scaduto : ';
-                            }
-                            if (d[0] == "1") {
-                                ret += '<i class="ico--ui check due me-2"></i>';
-                                ret += 'In scadenza : ';
-                            }
-                            if (d[0] == "2") {
-                                ret += '<i class="ico--ui check active me-2"></i>';
-                                ret += 'Scadenza : ';
-                            }
-                            ret += d[1];
-                            ret += '</span>';
-                        }
-                        if(data == ""){
-                            ret += '<span class="tablesaw-cell-content d-flex align-items-center">';
-                            ret += '<i class="ico--ui check absent me-2"></i>';
-                            ret += 'Non consegnato';
-                            ret += '</span>';
-                        }
-                        return ret;
+                        if (!data) return '<span class="tablesaw-cell-content d-flex align-items-center"><i class="ico--ui check absent me-2"></i>Non consegnato</span>';
+                        const d = data.split("|");
+                        const icon = d[0] === "0" ? "suspended" : (d[0] === "1" ? "due" : "active");
+                        const label = d[0] === "0" ? "Scaduto" : (d[0] === "1" ? "In scadenza" : "Scadenza");
+                        return '<span class="tablesaw-cell-content d-flex align-items-center"><i class="ico--ui check '+icon+' me-2"></i>'+label+' : '+d[1]+'</span>';
                     }
                 },
+                { data: "categories" },
+            ],
+            order: [
+                [1, 'desc']
             ],
             fixedHeader: false,
             thead: {
@@ -617,21 +699,20 @@
                 top1C :'search',
             },
             pagingType: 'numbers',
-            "language": {
+            language: {
                 "url": "/assets/js/Italian.json"
             },
-            "fnInitComplete": function (oSettings, json) {
+            fnInitComplete: function (oSettings, json) {
                 var html = '&nbsp;<a style="cursor:pointer" class="showHideFilter btn--ui"><i class="fa-solid fa-sliders"></i></a>';
                 $(".dt-search").append(html);
             }
         });
 
-        $('#recipients-table thead tr th').addClass('col');
-        $('#recipients-table thead tr th').css("background-color", "#f6f8fa");
-        $('#recipients-table').on('draw.dt', function() {
-            $('[data-bs-toggle="popover"]').popover()
-        });
+        $('#recipients-table thead tr th').addClass('col').css("background-color", "#f6f8fa");
+        $('#recipients-table').on('draw.dt', function() { $('[data-bs-toggle="popover"]').popover() });
     }
+
+    window.toggleRecipient = function(id) { @this.toggleRecipient(id); }
 </script>
 
 {{-- CKEditor --}}
@@ -645,33 +726,49 @@
     }
 </script>
 <script type="module">
-    import { ClassicEditor } from "ckeditor5";
-        import { editorConfig } from "/assets/libraries/ckeditor5/config.js";
-
-        Livewire.on("load-editor", () => {
-            let messageField = document.querySelector('#message');
-
-            if (messageField) {
-                if (messageField.ckeditorInstance) messageField.ckeditorInstance.destroy();
-
-                editorConfig.simpleUpload = {
-                    uploadUrl: "{{route('ckeditor.upload', ['_token' => csrf_token()])}}"
-                }
-
-                console.log(editorConfig);
-
-                ClassicEditor
-                    .create(messageField, editorConfig)
-                    .then(editor => {
-                        editor.model.document.on('change:data', () => {
-                            @this.set('message', editor.getData());
-                        })
-                    })
-                    .catch(error => {
-                        console.error(error);
-                    });
+import { ClassicEditor } from "ckeditor5";
+import { editorConfig } from "/assets/libraries/ckeditor5/config.js";
+
+window.addEventListener("load-editor", (e) => {
+    let detail = e.detail || {};
+    let latestLocked = !!detail.locked;
+    let html = detail.html ?? '';
+
+    let el = document.querySelector('#message');
+    if (el.ckeditorInstance) el.ckeditorInstance.destroy();
+    
+    editorConfig.simpleUpload = {
+        uploadUrl: "{{ route('ckeditor.upload', ['_token' => csrf_token()]) }}"
+    };
+
+    ClassicEditor
+        .create(el, editorConfig)
+        .then(editor => {
+            el.ckeditorInstance = editor;
+
+            editor.setData(html);
+
+            if (latestLocked) {
+                editor.enableReadOnlyMode('locked');
+                return;
             }
-        });
+        })
+        .catch(console.error)
+});
+
+window.submitEmail = function(action){
+    const ed = document.querySelector('#message')?.ckeditorInstance;
+    const html = ed ? ed.getData() : '';
+
+    if (action === 'draft') {
+        @this.call('saveDraft', html);
+    } else if (action === 'send') {
+        @this.call('sendNow', html);
+    } else {
+        @this.call('scheduleMessage', html);
+    }
+};
 </script>
+
 {{-- END CKEditor --}}
 @endpush

+ 352 - 4
routes/web.php

@@ -138,6 +138,36 @@ Route::get('/receipt/{id}', function ($id) {
         );*/
 });
 
+Route::get('/all_receipts', function () {
+    $receipts = \App\Models\Receipt::with('member')->get();
+
+    if ($receipts->isEmpty()) {
+        abort(404, 'Nessuna ricevuta trovata.');
+    }
+
+    $zipFileName = 'ricevute_' . now()->format('Ymd_His') . '.zip';
+    $zipPath = storage_path('app/' . $zipFileName);
+
+    $zip = new ZipArchive();
+    if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+        abort(500, 'Impossibile creare lo ZIP.');
+    }
+
+    foreach ($receipts as $receipt) {
+        $lastName = Illuminate\Support\Str::slug($receipt->member->last_name ?? 'sconosciuto', '_');
+
+        $pdfName = "Ricevuta_{$lastName}_{$receipt->number}_{$receipt->year}.pdf";
+
+        $pdf = PDF::loadView('receipt', ['receipt' => $receipt]);
+
+        $zip->addFromString($pdfName, $pdf->output());
+    }
+
+    $zip->close();
+
+    return response()->download($zipPath)->deleteFileAfterSend(true);
+});
+
 Route::get('/receipt/mail/{id}', function ($id) {
     $receipt = \App\Models\Receipt::findOrFail($id);
     if ($receipt->status == 99)
@@ -210,14 +240,12 @@ Route::get('/get_members', function () {
 
     $datas = [];
 
-    // ALWAYS exclude archived members - this is the key change
     $x = \App\Models\Member::select('id', 'first_name', 'last_name', 'phone', 'birth_date', 'to_complete', 'current_status', 'certificate', 'certificate_date')
         ->where(function($query) {
             $query->where('is_archived', false)
                   ->orWhereNull('is_archived');
         });
 
-    // All your existing filtering logic remains exactly the same
     if (isset($_GET["search"]["value"]) && $_GET["search"]["value"] != "") {
         $v = str_replace("'", "\'", stripcslashes($_GET["search"]["value"]));
         $x = $x->where(function ($query) use ($v) {
@@ -372,8 +400,6 @@ Route::get('/get_members', function () {
 
     $count = $x->count();
 
-    
-    // Rest of the ordering logic remains the same
     if (isset($_GET["order"])) {
         $column = '';
         if ($_GET["order"][0]["column"] == 0)
@@ -469,6 +495,7 @@ Route::get('/get_members', function () {
         }
 
         $datas[] = array(
+            'id' => $r->id,
             'last_name' => $r->last_name . "|" . $r->id,
             'first_name' => $r->first_name . "|" . $r->id,
             'phone' => $r->phone,
@@ -483,6 +510,327 @@ Route::get('/get_members', function () {
     return json_encode(array("data" => $datas, "recordsTotal" => $count, "recordsFiltered" => $count));
 });
 
+Route::get('/get_recipients', function () {
+    app(\App\Http\Middleware\TenantMiddleware::class)->setupTenantConnection();
+
+    $datas = [];
+
+    // BASE IDENTICA ALLA TUA get_members
+    $x = \App\Models\Member::select('id', 'first_name', 'last_name', 'email', 'phone', 'birth_date', 'to_complete', 'current_status', 'certificate', 'certificate_date')
+        ->where(function($query) {
+            $query->where('is_archived', false)
+                  ->orWhereNull('is_archived');
+        });
+
+    if (isset($_GET["search"]["value"]) && $_GET["search"]["value"] != "") {
+        $v = str_replace("'", "\'", stripcslashes($_GET["search"]["value"]));
+        $x = $x->where(function ($query) use ($v) {
+            $query->whereRaw("CONCAT(TRIM(first_name), ' ', TRIM(last_name)) like ?", ["%{$v}%"])
+                ->orWhereRaw("CONCAT(TRIM(last_name), ' ', TRIM(first_name)) like ?", ["%{$v}%"]);
+        });
+    }
+
+    if ($_GET["cards"] != "") {
+        $card_ids = \App\Models\MemberCard::whereIn('card_id', explode(",", $_GET["cards"]))->pluck('member_id');
+        $x = $x->whereIn('id', $card_ids);
+    }
+
+    if ($_GET["filterCategories"] != "null") {
+        $categories = [];
+        $cats = explode(",", $_GET["filterCategories"]);
+
+        foreach ($cats as $c) {
+            $categories[] = $c;
+            $childs = \App\Models\Category::where('parent_id', $c)->get();
+            foreach ($childs as $_cc) {
+                $categories[] = $_cc->id;
+                $childss = \App\Models\Category::where('parent_id', $_cc->id)->get();
+                foreach ($childss as $ccc) {
+                    $categories[] = $ccc->id;
+                }
+            }
+        }
+
+        $cc = array();
+        foreach ($categories as $c) {
+            $m_ids = \App\Models\MemberCategory::where('category_id', $c)->pluck('member_id')->toArray();
+            $cc = array_merge($cc, $m_ids);
+        }
+
+        $x = $x->whereIn('id', $cc);
+    }
+
+    if ($_GET["fromYear"] != "") {
+        $x = $x->where('birth_date', '<', date("Y-m-d", strtotime("-" . $_GET["fromYear"] . " year", time())));
+    }
+    if ($_GET["toYear"] != "") {
+        $x = $x->where('birth_date', '>', date("Y-m-d", strtotime("-" . $_GET["toYear"] . " year", time())));
+    }
+    if ($_GET["fromYearYear"] != "") {
+        $x = $x->whereYear('birth_date', '>=', $_GET["fromYearYear"]);
+    }
+    if ($_GET["toYearYear"] != "") {
+        $x = $x->whereYear('birth_date', '<=', $_GET["toYearYear"]);
+    }
+
+    $ids = [];
+
+    if ($_GET["filterCertificateType"] != "null") {
+        $types = \App\Models\MemberCertificate::where('type', $_GET["filterCertificateType"])
+            ->where('expire_date', '>', date("Y-m-d"))
+            ->pluck('member_id');
+        $x = $x->whereIn('id', $types->toArray());
+    }
+
+    if ($_GET["filterScadenza"] != "null") {
+        $scadenzaValues = explode(",", $_GET["filterScadenza"]);
+        $allScadIds = [];
+
+        foreach ($scadenzaValues as $filterValue) {
+            if ($filterValue == "1") {
+                $memberLatestCerts = DB::table('member_certificates')
+                    ->select('member_id', DB::raw('MAX(expire_date) as latest_expire_date'))
+                    ->groupBy('member_id')
+                    ->get();
+
+                $expiredMemberIds = [];
+                foreach ($memberLatestCerts as $cert) {
+                    if (date('Y-m-d', strtotime($cert->latest_expire_date)) < date('Y-m-d')) {
+                        $expiredMemberIds[] = $cert->member_id;
+                    }
+                }
+                $scadIds = $expiredMemberIds;
+            } else if ($filterValue == "2") {
+                $memberLatestCerts = DB::table('member_certificates')
+                    ->select('member_id', DB::raw('MAX(expire_date) as latest_expire_date'))
+                    ->groupBy('member_id')
+                    ->get();
+
+                $expiringMemberIds = [];
+                foreach ($memberLatestCerts as $cert) {
+                    $expireDate = date('Y-m-d', strtotime($cert->latest_expire_date));
+                    $today = date('Y-m-d');
+                    $oneMonthLater = date('Y-m-d', strtotime("+1 month"));
+
+                    if ($expireDate >= $today && $expireDate <= $oneMonthLater) {
+                        $expiringMemberIds[] = $cert->member_id;
+                    }
+                }
+                $scadIds = $expiringMemberIds;
+            } else if ($filterValue == "3") {
+                $scadIds = \App\Models\Member::whereNotIn('id', \App\Models\MemberCertificate::pluck('member_id'))
+                    ->where(function($query) {
+                        $query->where('is_archived', false)
+                              ->orWhereNull('is_archived');
+                    })
+                    ->pluck('id')->toArray();
+            } else if ($filterValue == "4") {
+                $memberLatestCerts = DB::table('member_certificates')
+                    ->select('member_id', DB::raw('MAX(expire_date) as latest_expire_date'))
+                    ->groupBy('member_id')
+                    ->get();
+
+                $validMemberIds = [];
+                foreach ($memberLatestCerts as $cert) {
+                    $expireDate = date('Y-m-d', strtotime($cert->latest_expire_date));
+                    $oneMonthLater = date('Y-m-d', strtotime("+1 month"));
+
+                    if ($expireDate > $oneMonthLater) {
+                        $validMemberIds[] = $cert->member_id;
+                    }
+                }
+                $scadIds = $validMemberIds;
+            }
+
+            $allScadIds = array_merge($allScadIds, $scadIds);
+        }
+
+        $allScadIds = array_unique($allScadIds);
+        $x = $x->whereIn('id', $allScadIds);
+    }
+
+    if ($_GET["filterStatus"] != "null") {
+        $status = explode(",", $_GET["filterStatus"]);
+        $members = \App\Models\Member::where(function($query) {
+            $query->where('is_archived', false)
+                  ->orWhereNull('is_archived');
+        })->get();
+
+        foreach ($status as $s) {
+            foreach ($members as $m) {
+                $state = $m->getStatus();
+                if ($state["status"] == $s)
+                    $ids[] = $m->id;
+            }
+        }
+    }
+
+    if (sizeof($ids) > 0) {
+        $x = $x->whereIn('id', $ids);
+    } else {
+        if ($_GET["filterStatus"] != "null")
+            $x = $x->whereIn('id', [-1]);
+    }
+
+    if (isset($_GET["filterCourses"]) && $_GET["filterCourses"] !== "" && $_GET["filterCourses"] !== "null") {
+        $requested = array_filter(explode(",", $_GET["filterCourses"]), fn($v) => $v !== "");
+
+        $courseIds = [];
+        foreach ($requested as $cId) {
+            $courseIds[] = (int)$cId;
+
+            $children = \App\Models\Course::where('parent_id', $cId)->pluck('id')->all();
+            foreach ($children as $_cid) {
+                $courseIds[] = (int)$_cid;
+
+                $grand = \App\Models\Course::where('parent_id', $_cid)->pluck('id')->all();
+                foreach ($grand as $__cid) {
+                    $courseIds[] = (int)$__cid;
+                }
+            }
+        }
+        $courseIds = array_values(array_unique($courseIds));
+
+        if (!empty($courseIds)) {
+            $memberIdsByCourse = \App\Models\MemberCourse::whereIn('course_id', $courseIds)
+                ->pluck('member_id')->unique()->values()->all();
+
+            $x = !empty($memberIdsByCourse)
+                ? $x->whereIn('id', $memberIdsByCourse)
+                : $x->whereIn('id', [-1]);
+        }
+    }
+
+    $count = $x->count();
+
+    if (isset($_GET["order"])) {
+        $column = '';
+        if ($_GET["order"][0]["column"] == 0)
+            $column = 'last_name';
+        if ($_GET["order"][0]["column"] == 1)
+            $column = 'first_name';
+        if ($_GET["order"][0]["column"] == 2)
+            $column = 'email';
+        if ($_GET["order"][0]["column"] == 3)
+            $column = 'phone';
+        if ($_GET["order"][0]["column"] == 4)
+            $column = 'birth_date';
+        if ($_GET["order"][0]["column"] == 5)
+            $column = 'birth_date';
+        if ($_GET["order"][0]["column"] == 6)
+            $column = 'current_status';
+        if ($_GET["order"][0]["column"] == 7)
+            $column = 'certificate';
+
+        if ($column != '') {
+            if ($column == 'last_name') {
+                $x = $x->orderBy('to_complete', 'DESC');
+            }
+            if ($column == 'certificate')
+                $x = $x->orderBy('certificate_date', $_GET["order"][0]["dir"]);
+            elseif ($column == 'current_status') {
+                $x = $x->orderBy('to_complete', 'DESC');
+                $x = $x->orderBy($column, $_GET["order"][0]["dir"]);
+            } else {
+                $x = $x->orderBy($column, $_GET["order"][0]["dir"]);
+            }
+        } else {
+            $x = $x->orderBy('to_complete', 'DESC');
+            $x = $x->orderBy('last_name', 'ASC')->orderBy('first_name', 'ASC');
+        }
+    } else {
+        $x = $x->orderBy('to_complete', 'DESC');
+        $x = $x->orderBy('last_name', 'ASC')->orderBy('first_name', 'ASC');
+    }
+
+    if (isset($_GET["start"]))
+        $x = $x->offset($_GET["start"])->limit($_GET["length"])->get();
+    else
+        $x = $x->get();
+
+    $memberIds = $x->pluck('id')->all();
+    $catsByMember = \App\Models\MemberCategory::with('category')
+        ->whereIn('member_id', $memberIds)
+        ->get()
+        ->groupBy('member_id')
+        ->map(function($rows){
+            $names = $rows->pluck('category.name')->filter()->unique()->sort()->values()->all();
+            return implode(', ', $names);
+        });
+
+    $latestCertificates = [];
+    foreach ($x as $member) {
+        $latestCert = \App\Models\MemberCertificate::where('member_id', $member->id)
+            ->orderBy('expire_date', 'desc')
+            ->first();
+
+        if ($latestCert) {
+            $latestCertificates[$member->id] = $latestCert->expire_date;
+        }
+    }
+
+    foreach ($x as $idx => $r) {
+        $status = $r->current_status;
+        $class = $status > 0 ? ($status == 2 ? 'active' : 'due') : 'suspended';
+        $text = $status > 0 ? ($status == 2 ? 'Tesserato' : 'Sospeso') : 'Non tesserato';
+
+        if ($r->to_complete) {
+            $text = 'Da completare';
+            $class = "complete";
+        }
+
+        $y = '';
+
+        if (isset($latestCertificates[$r->id])) {
+            $latest_date = $latestCertificates[$r->id];
+            $certStatus = '';
+
+            if ($latest_date < date("Y-m-d")) {
+                $certStatus = "0";
+            } else if ($latest_date <= date("Y-m-d", strtotime("+1 month"))) {
+                $certStatus = "1";
+            } else {
+                $certStatus = "2";
+            }
+
+            $y = $certStatus . "|" . date("d/m/Y", strtotime($latest_date));
+        } else if ($r->certificate_date != '') {
+            $certStatus = '';
+
+            if ($r->certificate_date < date("Y-m-d")) {
+                $certStatus = "0";
+            } else if ($r->certificate_date >= date("Y-m-d") && $r->certificate_date <= date("Y-m-d", strtotime("+1 month"))) {
+                $certStatus = "1";
+            } else if ($r->certificate_date > date("Y-m-d", strtotime("+1 month"))) {
+                $certStatus = "2";
+            }
+
+            $y = $certStatus . "|" . date("d/m/Y", strtotime($r->certificate_date));
+        }
+
+        $datas[] = array(
+            'id' => $r->id,
+            'last_name' => $r->last_name . "|" . $r->id,
+            'first_name' => $r->first_name . "|" . $r->id,
+            'email' => $r->email . "|" . $r->id,
+            'phone' => $r->phone,
+            'age' => $r->getAge(),
+            'year' => $r->birth_date ? date("Y", strtotime($r->birth_date)) : 'N/A',
+            'status' => $class . "|" . $text,
+            'certificate' => $y,
+            'categories' => $catsByMember[$r->id] ?? '',
+            'action' => $r->id
+        );
+    }
+
+    return response()->json([
+        "data" => $datas,
+        "recordsTotal" => $count,
+        "recordsFiltered" => $count
+    ]);
+});
+
+
 Route::get('/get_record_in', function () {
 
     $datas = [];