فهرست منبع

nuova prima nota

FabioFratini 7 ماه پیش
والد
کامیت
7199539887

+ 64 - 0
app/Events/ExportCompleted.php

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

+ 58 - 0
app/Events/ExportFailed.php

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

+ 715 - 85
app/Http/Livewire/Record.php

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

+ 378 - 0
app/Jobs/ExportPrimaNota.php

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

+ 66 - 0
app/Mail/ExportNotification.php

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

+ 416 - 0
app/Services/RecordFileService.php

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

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

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

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

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 772 - 520
resources/views/livewire/records.blade.php


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است