Faza 2 (din continuare): Email notifications

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).
This commit is contained in:
2026-05-07 13:20:19 +00:00
parent bfe58ed286
commit 09fd0bada2
15 changed files with 608 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\UsesTenantBranding;
use App\Models\Central\Company;
use App\Models\Tenant\Appointment;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AppointmentConfirmedMail extends Mailable
{
use Queueable, SerializesModels, UsesTenantBranding;
public function __construct(public Appointment $appointment, Company $company)
{
$this->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,
]),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Mail\Concerns;
use App\Models\Central\Company;
trait UsesTenantBranding
{
public Company $company;
public function buildBrandingContext(): array
{
return [
'companyName' => $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'),
];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\UsesTenantBranding;
use App\Models\Central\Company;
use App\Models\Tenant\Payment;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentReceivedMail extends Mailable
{
use Queueable, SerializesModels, UsesTenantBranding;
public function __construct(public Payment $payment, Company $company)
{
$this->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',
]),
);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\UsesTenantBranding;
use App\Models\Central\Company;
use App\Models\Tenant\Vehicle;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ServiceReminderMail extends Mailable
{
use Queueable, SerializesModels, UsesTenantBranding;
public function __construct(
public Vehicle $vehicle,
public string $reminderType, // 'itp' / 'oil' / 'general'
public ?string $note,
Company $company,
) {
$this->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,
]),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\UsesTenantBranding;
use App\Models\Central\Company;
use App\Models\Tenant\WorkOrder;
use App\Services\WorkOrderPdfService;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WorkOrderReadyMail extends Mailable
{
use Queueable, SerializesModels, UsesTenantBranding;
public function __construct(public WorkOrder $workOrder, Company $company)
{
$this->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 [];
}
}
}