From 09fd0bada2703d50d71230958abbf1107b0cf9eb Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 7 May 2026 13:20:19 +0000 Subject: [PATCH] Faza 2 (din continuare): Email notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 Mailables auto-trigger pe model events: - WorkOrderReadyMail: la WO.status → 'ready', către client.email • Atașat PDF fișa lucru (via WorkOrderPdfService) • Total/achitat/rest, recomandări (warning box) - PaymentReceivedMail: la Payment::created, confirmare cu sumă/metodă/ref - AppointmentConfirmedMail: la Appointment::created status='scheduled' - ServiceReminderMail: dispatch manual (vehicle, type=itp/oil/general, note) Layout email branded (resources/views/emails/layout.blade.php): - Header cu logo tenant + theme_color border-bottom - Footer cu telefon/email/disclaimer - Stiluri inline (compatibil tot mail client) Settings page extins cu 4 toggle: - 'Mașina e gata de ridicat' - 'Confirmare plată primită' - 'Programare confirmată' - 'Reminder ITP / revizie' Salvate în companies.settings.notify (JSON), default true. NotificationDispatcher service centralizat: - Verifică isEnabled() pe settings.notify[$key] - Skip dacă client n-are email - Try/catch + Log::warning pe eșec (nu crapă request-ul) Mailables folosesc UsesTenantBranding trait pentru context unitar. Test prin Mailpit: https://mailpit.service.mir.md (capturează toate). --- app/Filament/Tenant/Pages/Settings.php | 20 ++++ app/Mail/AppointmentConfirmedMail.php | 47 ++++++++ app/Mail/Concerns/UsesTenantBranding.php | 23 ++++ app/Mail/PaymentReceivedMail.php | 46 ++++++++ app/Mail/ServiceReminderMail.php | 53 +++++++++ app/Mail/WorkOrderReadyMail.php | 63 +++++++++++ app/Models/Tenant/Appointment.php | 10 ++ app/Models/Tenant/Payment.php | 5 + app/Models/Tenant/WorkOrder.php | 14 +++ app/Services/NotificationDispatcher.php | 106 ++++++++++++++++++ .../emails/appointment-confirmed.blade.php | 46 ++++++++ resources/views/emails/layout.blade.php | 37 ++++++ .../views/emails/payment-received.blade.php | 36 ++++++ .../views/emails/service-reminder.blade.php | 46 ++++++++ .../views/emails/work-order-ready.blade.php | 56 +++++++++ 15 files changed, 608 insertions(+) create mode 100644 app/Mail/AppointmentConfirmedMail.php create mode 100644 app/Mail/Concerns/UsesTenantBranding.php create mode 100644 app/Mail/PaymentReceivedMail.php create mode 100644 app/Mail/ServiceReminderMail.php create mode 100644 app/Mail/WorkOrderReadyMail.php create mode 100644 app/Services/NotificationDispatcher.php create mode 100644 resources/views/emails/appointment-confirmed.blade.php create mode 100644 resources/views/emails/layout.blade.php create mode 100644 resources/views/emails/payment-received.blade.php create mode 100644 resources/views/emails/service-reminder.blade.php create mode 100644 resources/views/emails/work-order-ready.blade.php diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index 9397e9e..77693e1 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -34,6 +34,7 @@ class Settings extends Page $settings = (array) ($company->settings ?? []); // Filament v5: fill via $this->form->fill() (initializes the schema state). + $notify = (array) ($settings['notify'] ?? []); $this->form->fill([ 'display_name' => $company->display_name ?? $company->name, 'city' => $company->city, @@ -45,6 +46,10 @@ class Settings extends Page 'labor_rate' => $settings['labor_rate'] ?? 400, 'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '', 'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '', + 'notify_wo_ready' => $notify['wo_ready'] ?? true, + 'notify_payment' => $notify['payment'] ?? true, + 'notify_appointment' => $notify['appointment'] ?? true, + 'notify_reminder' => $notify['reminder'] ?? true, ]); } @@ -106,6 +111,15 @@ class Settings extends Page ->maxSize(512) ->helperText('PNG/ICO, max 512 KB.'), ]), + Schemas\Components\Section::make('Notificări email') + ->description('Activează / dezactivează emailurile auto către clienți.') + ->columns(2) + ->schema([ + Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true), + Forms\Components\Toggle::make('notify_payment')->label('Confirmare plată primită')->default(true), + Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true), + Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true), + ]), ]) ->statePath('data'); } @@ -131,6 +145,12 @@ class Settings extends Page 'labor_rate' => (float) ($data['labor_rate'] ?? 400), 'services' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['services'] ?? ''))))), 'cars' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['cars'] ?? ''))))), + 'notify' => [ + 'wo_ready' => (bool) ($data['notify_wo_ready'] ?? true), + 'payment' => (bool) ($data['notify_payment'] ?? true), + 'appointment' => (bool) ($data['notify_appointment'] ?? true), + 'reminder' => (bool) ($data['notify_reminder'] ?? true), + ], ]), ]); diff --git a/app/Mail/AppointmentConfirmedMail.php b/app/Mail/AppointmentConfirmedMail.php new file mode 100644 index 0000000..f552275 --- /dev/null +++ b/app/Mail/AppointmentConfirmedMail.php @@ -0,0 +1,47 @@ +company = $company; + } + + public function envelope(): Envelope + { + return new Envelope( + subject: "[{$this->company->name}] Programare confirmată — " . $this->appointment->date?->format('d.m.Y'), + from: new \Illuminate\Mail\Mailables\Address( + config('mail.from.address'), + $this->company->display_name ?? $this->company->name, + ), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.appointment-confirmed', + with: array_merge($this->buildBrandingContext(), [ + 'appointment' => $this->appointment, + 'client' => $this->appointment->client, + 'vehicle' => $this->appointment->vehicle, + 'master' => $this->appointment->master, + 'post' => $this->appointment->post, + ]), + ); + } +} diff --git a/app/Mail/Concerns/UsesTenantBranding.php b/app/Mail/Concerns/UsesTenantBranding.php new file mode 100644 index 0000000..4f3a079 --- /dev/null +++ b/app/Mail/Concerns/UsesTenantBranding.php @@ -0,0 +1,23 @@ + $this->company->display_name ?? $this->company->name, + 'themeColor' => $this->company->settings['theme_color'] ?? '#3B82F6', + 'phone' => $this->company->phone, + 'email' => $this->company->email, + 'city' => $this->company->city, + 'logoUrl' => $this->company->getLogoUrl(), + 'tenantUrl' => $this->company->url('/app'), + ]; + } +} diff --git a/app/Mail/PaymentReceivedMail.php b/app/Mail/PaymentReceivedMail.php new file mode 100644 index 0000000..a87cd0a --- /dev/null +++ b/app/Mail/PaymentReceivedMail.php @@ -0,0 +1,46 @@ +company = $company; + } + + public function envelope(): Envelope + { + return new Envelope( + subject: "[{$this->company->name}] Confirmare plată — " . number_format((float) $this->payment->amount, 2, '.', ' ') . ' ' . ($this->company->settings['currency'] ?? 'MDL'), + from: new \Illuminate\Mail\Mailables\Address( + config('mail.from.address'), + $this->company->display_name ?? $this->company->name, + ), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.payment-received', + with: array_merge($this->buildBrandingContext(), [ + 'payment' => $this->payment, + 'client' => $this->payment->client, + 'workOrder' => $this->payment->workOrder, + 'currency' => $this->company->settings['currency'] ?? 'MDL', + ]), + ); + } +} diff --git a/app/Mail/ServiceReminderMail.php b/app/Mail/ServiceReminderMail.php new file mode 100644 index 0000000..7f2730e --- /dev/null +++ b/app/Mail/ServiceReminderMail.php @@ -0,0 +1,53 @@ +company = $company; + } + + public function envelope(): Envelope + { + $titles = ['itp' => 'Reminder ITP', 'oil' => 'Reminder schimb ulei', 'general' => 'Reminder revizie']; + $title = $titles[$this->reminderType] ?? 'Reminder'; + + return new Envelope( + subject: "[{$this->company->name}] {$title} — " . trim(($this->vehicle->make ?? '') . ' ' . ($this->vehicle->model ?? '')), + from: new \Illuminate\Mail\Mailables\Address( + config('mail.from.address'), + $this->company->display_name ?? $this->company->name, + ), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.service-reminder', + with: array_merge($this->buildBrandingContext(), [ + 'vehicle' => $this->vehicle, + 'client' => $this->vehicle->client, + 'reminderType' => $this->reminderType, + 'note' => $this->note, + ]), + ); + } +} diff --git a/app/Mail/WorkOrderReadyMail.php b/app/Mail/WorkOrderReadyMail.php new file mode 100644 index 0000000..421c9f1 --- /dev/null +++ b/app/Mail/WorkOrderReadyMail.php @@ -0,0 +1,63 @@ +company = $company; + } + + public function envelope(): Envelope + { + return new Envelope( + subject: "[{$this->company->name}] Mașina dvs. este gata — fișa {$this->workOrder->number}", + from: new \Illuminate\Mail\Mailables\Address( + config('mail.from.address'), + $this->company->display_name ?? $this->company->name, + ), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.work-order-ready', + with: array_merge($this->buildBrandingContext(), [ + 'workOrder' => $this->workOrder, + 'client' => $this->workOrder->client, + 'vehicle' => $this->workOrder->vehicle, + 'paid' => (float) $this->workOrder->payments->sum('amount'), + 'total' => (float) $this->workOrder->total, + 'currency' => $this->company->settings['currency'] ?? 'MDL', + ]), + ); + } + + public function attachments(): array + { + try { + $pdf = app(WorkOrderPdfService::class)->generate($this->workOrder); + return [ + Attachment::fromData(fn () => $pdf->output(), app(WorkOrderPdfService::class)->filename($this->workOrder)) + ->withMime('application/pdf'), + ]; + } catch (\Throwable $e) { + return []; + } + } +} diff --git a/app/Models/Tenant/Appointment.php b/app/Models/Tenant/Appointment.php index d5054ff..e154500 100644 --- a/app/Models/Tenant/Appointment.php +++ b/app/Models/Tenant/Appointment.php @@ -52,4 +52,14 @@ class Appointment extends Model { return $this->belongsTo(Deal::class); } + + /** Auto-send confirmation email when a 'scheduled' appointment is created. */ + protected static function booted(): void + { + static::created(function (self $a) { + if ($a->status === 'scheduled') { + app(\App\Services\NotificationDispatcher::class)->appointmentConfirmed($a); + } + }); + } } diff --git a/app/Models/Tenant/Payment.php b/app/Models/Tenant/Payment.php index 7d72509..9c23ce0 100644 --- a/app/Models/Tenant/Payment.php +++ b/app/Models/Tenant/Payment.php @@ -75,5 +75,10 @@ class Payment extends Model static::saved($sync); static::deleted($sync); + + // Auto-send confirmation email on new payment. + static::created(function (self $payment) { + app(\App\Services\NotificationDispatcher::class)->paymentReceived($payment); + }); } } diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 8352908..aed1907 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -108,4 +108,18 @@ class WorkOrder extends Model ->count(); return sprintf('WO-%s-%04d', $year, $count + 1); } + + /** Auto-send 'ready' email when status transitions to 'ready'. */ + protected static function booted(): void + { + static::updated(function (self $wo) { + if ( + $wo->wasChanged('status') + && $wo->status === 'ready' + && $wo->getOriginal('status') !== 'ready' + ) { + app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo); + } + }); + } } diff --git a/app/Services/NotificationDispatcher.php b/app/Services/NotificationDispatcher.php new file mode 100644 index 0000000..8e79df9 --- /dev/null +++ b/app/Services/NotificationDispatcher.php @@ -0,0 +1,106 @@ + true). + * + * Usage: + * app(NotificationDispatcher::class)->workOrderReady($workOrder); + */ +class NotificationDispatcher +{ + public function workOrderReady(WorkOrder $wo): bool + { + $company = $this->companyFor($wo); + if (! $this->isEnabled($company, 'wo_ready')) return false; + + $email = $wo->client?->email; + if (! $email) return false; + + try { + Mail::to($email)->send(new WorkOrderReadyMail($wo, $company)); + return true; + } catch (\Throwable $e) { + Log::warning('workOrderReady mail failed', ['wo' => $wo->id, 'err' => $e->getMessage()]); + return false; + } + } + + public function paymentReceived(Payment $payment): bool + { + $company = $this->companyFor($payment); + if (! $this->isEnabled($company, 'payment')) return false; + + $email = $payment->client?->email; + if (! $email) return false; + + try { + Mail::to($email)->send(new PaymentReceivedMail($payment, $company)); + return true; + } catch (\Throwable $e) { + Log::warning('paymentReceived mail failed', ['payment' => $payment->id, 'err' => $e->getMessage()]); + return false; + } + } + + public function appointmentConfirmed(Appointment $a): bool + { + $company = $this->companyFor($a); + if (! $this->isEnabled($company, 'appointment')) return false; + + $email = $a->client?->email; + if (! $email) return false; + + try { + Mail::to($email)->send(new AppointmentConfirmedMail($a, $company)); + return true; + } catch (\Throwable $e) { + Log::warning('appointmentConfirmed mail failed', ['appt' => $a->id, 'err' => $e->getMessage()]); + return false; + } + } + + public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool + { + $company = $this->companyFor($v); + if (! $this->isEnabled($company, 'reminder')) return false; + + $email = $v->client?->email; + if (! $email) return false; + + try { + Mail::to($email)->send(new ServiceReminderMail($v, $type, $note, $company)); + return true; + } catch (\Throwable $e) { + Log::warning('serviceReminder mail failed', ['vehicle' => $v->id, 'err' => $e->getMessage()]); + return false; + } + } + + protected function companyFor($model): Company + { + return Company::withoutGlobalScopes()->findOrFail($model->company_id); + } + + protected function isEnabled(Company $company, string $key): bool + { + $settings = (array) ($company->settings ?? []); + $notify = (array) ($settings['notify'] ?? []); + // default: enabled (toate notificările active by default) + return ($notify[$key] ?? true) === true; + } +} diff --git a/resources/views/emails/appointment-confirmed.blade.php b/resources/views/emails/appointment-confirmed.blade.php new file mode 100644 index 0000000..fb429f3 --- /dev/null +++ b/resources/views/emails/appointment-confirmed.blade.php @@ -0,0 +1,46 @@ +@component('emails.layout', [ + 'companyName' => $companyName, + 'themeColor' => $themeColor, + 'phone' => $phone ?? null, + 'email' => $email ?? null, + 'city' => $city ?? null, + 'logoUrl' => $logoUrl ?? null, + 'title' => 'Programare confirmată', +]) +

📅 Programare confirmată

+

Bună ziua{{ $client?->name ? ', ' . $client->name : '' }},

+

Programarea dvs. este confirmată:

+ + + + + + + @if ($appointment->title) + + + @endif + @if ($vehicle) + + + @endif + @if ($master) + + + @endif + @if ($post) + + + @endif +
Data + {{ $appointment->date?->format('d.m.Y') }} +
Ora + {{ \Illuminate\Support\Str::substr($appointment->time_start, 0, 5) }} – {{ \Illuminate\Support\Str::substr($appointment->time_end, 0, 5) }} +
Subiect{{ $appointment->title }}
Auto + {{ $vehicle->make }} {{ $vehicle->model }} + @if ($vehicle->plate) [{{ $vehicle->plate }}] @endif +
Maistru{{ $master->name }}
Pod{{ $post->name }}
+ +

Vă așteptăm! 🚗

+

Pentru reprogramare sau anulare, sunați-ne la {{ $phone ?? '—' }}.

+@endcomponent diff --git a/resources/views/emails/layout.blade.php b/resources/views/emails/layout.blade.php new file mode 100644 index 0000000..d7745f2 --- /dev/null +++ b/resources/views/emails/layout.blade.php @@ -0,0 +1,37 @@ + + + + + +{{ $title ?? 'AutoCRM' }} + + + + +
+ + + + + + +
+ @if (!empty($logoUrl)) + logo + @else +
{{ $companyName ?? 'AutoCRM' }}
+ @endif + @if (!empty($city)) +
{{ $city }}
+ @endif +
+ {{ $slot }} +
+
{{ $companyName ?? 'AutoCRM' }}
+ @if (!empty($phone))
📞 {{ $phone }}
@endif + @if (!empty($email))
✉ {{ $email }}
@endif +
Acest email a fost generat automat. Vă rugăm nu răspundeți.
+
+
+ + diff --git a/resources/views/emails/payment-received.blade.php b/resources/views/emails/payment-received.blade.php new file mode 100644 index 0000000..0a84b7c --- /dev/null +++ b/resources/views/emails/payment-received.blade.php @@ -0,0 +1,36 @@ +@component('emails.layout', [ + 'companyName' => $companyName, + 'themeColor' => $themeColor, + 'phone' => $phone ?? null, + 'email' => $email ?? null, + 'city' => $city ?? null, + 'logoUrl' => $logoUrl ?? null, + 'title' => 'Plată confirmată', +]) +

💳 Plată confirmată

+

Bună ziua{{ $client?->name ? ', ' . $client->name : '' }},

+

Confirmăm primirea plății dvs.:

+ + + + + + + + + @if ($payment->reference) + + + @endif + @if ($workOrder) + + + @endif +
Sumă achitată + {{ number_format((float) $payment->amount, 2, '.', ' ') }} {{ $currency }} +
Metoda + {{ \App\Models\Tenant\Payment::METHODS[$payment->method] ?? $payment->method }} +
Data{{ $payment->paid_at?->format('d.m.Y') }}
Referință{{ $payment->reference }}
Fișă lucru{{ $workOrder->number }}
+ +

Vă mulțumim!

+@endcomponent diff --git a/resources/views/emails/service-reminder.blade.php b/resources/views/emails/service-reminder.blade.php new file mode 100644 index 0000000..d94e51c --- /dev/null +++ b/resources/views/emails/service-reminder.blade.php @@ -0,0 +1,46 @@ +@php + $titles = [ + 'itp' => '🛡️ Reminder ITP', + 'oil' => '🛢️ Reminder schimb ulei', + 'general' => '🔧 Reminder revizie', + ]; + $body = [ + 'itp' => 'ITP-ul autoturismului dvs. expiră în curând. Programați-vă din timp pentru a evita amenzile.', + 'oil' => 'A trecut o perioadă de la ultimul schimb de ulei. Recomandăm o nouă verificare.', + 'general' => 'Vă reamintim că autoturismul dvs. necesită o verificare programată.', + ]; +@endphp +@component('emails.layout', [ + 'companyName' => $companyName, + 'themeColor' => $themeColor, + 'phone' => $phone ?? null, + 'email' => $email ?? null, + 'city' => $city ?? null, + 'logoUrl' => $logoUrl ?? null, + 'title' => $titles[$reminderType] ?? 'Reminder', +]) +

{{ $titles[$reminderType] ?? 'Reminder' }}

+

Bună ziua{{ $client?->name ? ', ' . $client->name : '' }},

+

{{ $body[$reminderType] ?? 'Vă reamintim despre vehiculul dvs.' }}

+ + + +
+
Vehicul
+
+ {{ $vehicle->make }} {{ $vehicle->model }} {{ $vehicle->year ?? '' }} + @if ($vehicle->plate) [{{ $vehicle->plate }}] @endif +
+ @if ($vehicle->mileage) +
Kilometraj: {{ number_format($vehicle->mileage, 0, '.', ' ') }} km
+ @endif +
+ +@if ($note) +
+ {{ $note }} +
+@endif + +

Programați-vă online sau sunați la {{ $phone ?? '—' }}.

+@endcomponent diff --git a/resources/views/emails/work-order-ready.blade.php b/resources/views/emails/work-order-ready.blade.php new file mode 100644 index 0000000..0e48107 --- /dev/null +++ b/resources/views/emails/work-order-ready.blade.php @@ -0,0 +1,56 @@ +@php $balance = max(0, $total - $paid); @endphp +@component('emails.layout', [ + 'companyName' => $companyName, + 'themeColor' => $themeColor, + 'phone' => $phone ?? null, + 'email' => $email ?? null, + 'city' => $city ?? null, + 'logoUrl' => $logoUrl ?? null, + 'title' => 'Mașina e gata', +]) +

✅ Mașina dvs. este gata!

+

Bună ziua{{ $client?->name ? ', ' . $client->name : '' }},

+

Vă informăm că lucrările pentru autoturismul dvs. au fost finalizate. Puteți veni să-l ridicați.

+ + + +
+
Fișă lucru
+
{{ $workOrder->number }}
+ @if ($vehicle) +
+ {{ $vehicle->make }} {{ $vehicle->model }} {{ $vehicle->year ?? '' }} + @if ($vehicle->plate) [{{ $vehicle->plate }}] @endif +
+ @endif +
+ + + + + + + @if ($paid > 0) + + + + + @endif + @if ($balance > 0) + + + + + @endif +
Total{{ number_format($total, 2, '.', ' ') }} {{ $currency }}
Achitat{{ number_format($paid, 2, '.', ' ') }} {{ $currency }}
Rest de plată{{ number_format($balance, 2, '.', ' ') }} {{ $currency }}
+ +@if ($workOrder->recommendations) +
+ ⚠ Recomandări:
+ {{ $workOrder->recommendations }} +
+@endif + +

Atașat veți găsi fișa de lucru completă (PDF).

+

Vă mulțumim pentru încredere!

+@endcomponent